aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--archive/task-archive.org2317
-rwxr-xr-xarchsetup584
-rw-r--r--assets/2026-07-15-velox-zbm-zfs-list-photo.jpgbin0 -> 514227 bytes
-rw-r--r--assets/2026-07-19-settings-panel-scope-decisions.org15
-rw-r--r--assets/2026-07-31-agenda-json-api-contract.txt79
-rw-r--r--assets/2026-07-31-agenda-json-render-profile.txt55
-rw-r--r--assets/2026-07-31-agenda-json-window-refresh.txt53
-rw-r--r--assets/2026-08-13-velox-amd-board-swap-context.org5
-rw-r--r--assets/2026-08-13-velox-bios-boot-manager-photo.jpgbin0 -> 135646 bytes
-rw-r--r--assets/2026-08-13-velox-bios-setup-utility-photo.jpgbin0 -> 234084 bytes
-rw-r--r--assets/2026-10-06-history-rewrite-commit-map.txt954
-rw-r--r--configs/wireplumber/50-disable-ucm.conf22
-rw-r--r--docs/2026-08-13-velox-reinstall-runbook.org184
-rw-r--r--docs/2026-08-15-velox-uefi-boot-entry-reference.org108
-rw-r--r--docs/design/2026-07-10-audio-failure-taxonomy.org2
-rw-r--r--docs/design/2026-07-10-net-bt-failure-taxonomy.org4
-rw-r--r--docs/design/2026-07-15-velox-boot-failure-handoff.org61
-rw-r--r--docs/design/2026-08-07-podman-socket-and-camera-udev.md85
-rw-r--r--docs/design/2026-08-14-velox-reinstall-gaps-1.org137
-rw-r--r--docs/design/2026-08-14-velox-reinstall-gaps-2.org63
-rw-r--r--docs/design/2026-08-14-velox-reinstall-gaps-3.org98
-rw-r--r--docs/post-install-checklist.org108
-rw-r--r--docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org5632
-rw-r--r--docs/verification/manual-testing.org93
-rw-r--r--docs/workflows/system-health-check.org35
-rw-r--r--packages/gnome-keyring/0001-daemon-Add-Cinnamon-to-autostart-files.patch51
-rw-r--r--packages/gnome-keyring/0002-secret-service-missing-client.patch275
-rw-r--r--packages/gnome-keyring/PKGBUILD82
-rw-r--r--packages/gnome-keyring/gnome-keyring.install14
-rwxr-xr-xscripts/cmail-setup-finish.sh56
-rwxr-xr-xscripts/post-rebuild-check676
-rw-r--r--scripts/testing/tests/test_config_applied.py3
-rw-r--r--scripts/testing/tests/test_desktop.py25
-rwxr-xr-xscripts/zz-bluetooth-resume95
-rw-r--r--tests/bluetooth-resume/test_bluetooth_resume.py141
-rw-r--r--tests/installer-steps/test_clone_user_repos.py155
-rw-r--r--tests/installer-steps/test_configure_backlight_access.py139
-rw-r--r--tests/installer-steps/test_configure_framework_amd_internal_mic.py277
-rw-r--r--tests/installer-steps/test_configure_hibernate_delay.py131
-rw-r--r--tests/installer-steps/test_configure_openai_chatgpt_repo.py324
-rw-r--r--tests/installer-steps/test_configure_service_discovery.py85
-rw-r--r--tests/installer-steps/test_configure_tlp_power.py239
-rw-r--r--tests/installer-steps/test_configure_tunnel_dns_over_tls.py147
-rw-r--r--tests/installer-steps/test_dotfiles_dependency_packages.py119
-rw-r--r--tests/installer-steps/test_idempotency_cluster.py27
-rw-r--r--tests/installer-steps/test_install_camera_passthrough_rules.py118
-rw-r--r--tests/installer-steps/test_install_cpu_microcode.py110
-rw-r--r--tests/installer-steps/test_install_gnome_keyring_fix.py209
-rw-r--r--tests/installer-steps/test_mask_fwupd_passim.py79
-rw-r--r--tests/installer-steps/test_mask_tmp_mount_for_zfs.py109
-rw-r--r--tests/installer-steps/test_orchestrators.py15
-rw-r--r--tests/net-scenarios/test_run_net_scenarios.py6
-rw-r--r--tests/post-rebuild-check/test_post_rebuild_check.py1145
-rw-r--r--todo.org5732
-rw-r--r--working/clock-display-references/2026-07-30-maeda-cosmos-notes.org11
-rw-r--r--working/clock-display-references/2026-07-30-maeda-cosmos-standalone.html422
-rw-r--r--working/clock-display-references/2026-07-30-maeda-line-notes.org11
-rw-r--r--working/clock-display-references/2026-07-30-maeda-line-standalone.html457
-rw-r--r--working/hyprland-lua-port/README.org131
-rw-r--r--working/hyprland-lua-port/hyprland.lua812
-rw-r--r--working/hyprland-lua-port/hyprland.lua.generated674
-rw-r--r--working/hyprland-lua-port/nomerge.lua768
-rw-r--r--working/hyprland-lua-port/ratio-local.lua43
-rw-r--r--working/hyprland-lua-port/reader-changes-for-lua.patch241
-rw-r--r--working/hyprland-lua-port/test-desktop-for-lua.patch50
-rw-r--r--working/hyprland-lua-port/velox-local.lua64
-rw-r--r--working/keyring-crash/deploy-ratio.sh65
-rw-r--r--working/keyring-crash/gnome-keyring-51.1-secret-service-missing-client.patch275
-rw-r--r--working/keyring-crash/probe.py300
-rwxr-xr-xworking/keyring-crash/run-alias-only.sh23
-rwxr-xr-xworking/keyring-crash/run-package-test.sh24
-rwxr-xr-xworking/keyring-crash/run-regression.sh50
-rwxr-xr-xworking/keyring-crash/run-upstream-tests.sh11
-rw-r--r--working/keyring-crash/setup-guest.sh40
-rw-r--r--working/keyring-crash/status.org118
-rwxr-xr-xworking/keyring-crash/vm-run.sh18
-rw-r--r--working/meeting-transcription-service/2026-09-19-work-handoff.org44
-rw-r--r--working/meeting-transcription-service/Makefile15
-rw-r--r--working/meeting-transcription-service/pyrightconfig.json4
-rw-r--r--working/meeting-transcription-service/src/diarize.py114
-rw-r--r--working/meeting-transcription-service/src/merge_transcript.py329
-rwxr-xr-xworking/meeting-transcription-service/src/ratio-transcribe203
-rwxr-xr-xworking/meeting-transcription-service/src/transcribe-worker255
-rw-r--r--working/meeting-transcription-service/systemd/meeting-transcribe.path12
-rw-r--r--working/meeting-transcription-service/systemd/meeting-transcribe.service9
-rw-r--r--working/meeting-transcription-service/tests/test_diarize.py74
-rw-r--r--working/meeting-transcription-service/tests/test_merge_transcript.py510
-rw-r--r--working/meeting-transcription-service/tests/test_ratio_transcribe.py263
-rw-r--r--working/meeting-transcription-service/tests/test_transcribe_worker.py359
-rw-r--r--working/velox-touchpad-interrupt/touchpad-module-underside-2026-08-15.jpgbin0 -> 3990419 bytes
90 files changed, 26497 insertions, 1580 deletions
diff --git a/archive/task-archive.org b/archive/task-archive.org
index 2b5d26e..f1185c5 100644
--- a/archive/task-archive.org
+++ b/archive/task-archive.org
@@ -1568,3 +1568,2320 @@ Live verification on ratio replaced the planned VM run for remedies 1, 4 and 5,
Left open, not a v1 gap: mpv played silently while the doctor read healthy. The stack was genuinely fine, and per-application stream routing is an explicit spec Non-Goal. Worth a task only if it recurs.
Second sighting, 2026-07-10: Chrome stopped recognizing the microphone while the stack was healthy (Shure MV7+ default, unmuted, 82%, PTT off). Restarting Chrome fixed it; nothing on the machine was touched. Both sightings share a shape the doctor cannot see: the graph is fine and one client cannot use it. A third sighting turns this into a design question, namely whether the doctor should say "the stack is fine, the fault is in the application" rather than a bare healthy.
+** DONE [#C] Net panel: Enterprise error never dismisses :bug:dotfiles:network:
+CLOSED: [2026-07-12 Sun]
+Fixed in dotfiles =a157bed=. Root cause: error toasts are sticky by design (so background refreshes can't wipe an unread error), but the enterprise join hint's flow posts no follow-up status and row clicks post none either, so nothing ever replaced it. Fix: a window-wide capture-phase click gesture dismisses a sticky toast on the user's next interaction; policy in =viewmodel.toast_action_plan= (unit-tested), timed toasts and background clears unchanged. Panel smoke run confirms launch/doctor/close with the gesture installed. Pointer-level dismiss is a manual-testing child (AT-SPI can't drive pointer gestures). Repro screenshot: =~/pictures/screenshots/2026-07-10_195911.png=.
+** DONE [#C] Net diagnostics leak connection names + SSIDs into copyable report and --json :bug:dotfiles:network:solo:
+CLOSED: [2026-07-12 Sun]
+Resolved in dotfiles =df1543a=: the =redact_ssid= toggle now scrubs saved profile names, active SSIDs, envelope-carried names, and =.nmconnection= keyfile basenames from the copyable report and the diag/doctor =--json= envelopes (one systemic pass in =redact.py=; MAC/IP scrub applies to those envelopes too). On-screen output and functional envelopes (status/list) unchanged. 15 new tests; live-verified on ratio (toggle on removes the active connection name from =diagnose --json=, default unchanged).
+The net doctor's copyable report (=report.py=, =scrub_text=) scrubs only MAC/IP, and =net diag/doctor --json= (=cli.py=) dumps the raw dict with no redaction. SSID redaction lives only in the event log (=redact_event=, gated on =redact_ssid=, default off). So a connection name (usually the SSID) appears in the clear in the link-step evidence and in every =--json= consumer — the copyable report is exactly the text a user pastes into a bug report. Secrets (PSK/password/token/portal URL) are already stripped, so this is names, not credentials: Minor severity, graded on severity alone per the privacy carve-out.
+
+Split out of the 2026-07-11 net-doctor-expansion spec review: that spec's new rival-manager/keyfile-perms verdicts keep parity with this pre-existing behavior rather than half-solve it. Fix shape: extend redaction to cover the connection name + keyfile basename across the copyable report and =--json= (one systemic pass, not per-verdict), with a redaction test. Engine-wide, so it wants one coherent change rather than being bolted onto the expansion work.
+** DONE [#B] Bt doctor expansion v1 — build the READY spec :feature:dotfiles:bluetooth:
+CLOSED: [2026-07-12 Sun]
+:PROPERTIES:
+:SPEC_ID: 3d4d61c4-e5df-44e9-b8e0-40b31452c3f7
+:END:
+Build the [[file:docs/specs/2026-07-11-bt-doctor-expansion-spec.org][bt doctor expansion]] (IMPLEMENTED). Adds a dmesg firmware-hint probe (names the missing blob on a no-adapter fault) and a boot-enablement probe (catches an adapter disabled at boot) to the shipped bt doctor (=~/.dotfiles/bluetooth/=). Archsetup owns the dotfiles work end to end. All phases shipped and fake-verified (d19fdca, f05a9b4, d7d859f); the live reboot-persistence half is on the manual-testing checklist.
+*** 2026-07-11 Sat @ 03:06:32 -0500 Built the two read-only probes
+New module =bluetooth/src/bt/probes.py= plus =doctor.py= wiring, on dotfiles main (=d19fdca=, pushed). Two reads the diagnose chain never did: =firmware_hint()= scans the current boot's kernel log for per-vendor firmware-load failures (Intel ibt-*.sfi, MediaTek BT_RAM_CODE, Realtek rtl_bt, Broadcom .hcd, Qualcomm QCA), returning the named blob via a bounded =cmd.run(journalctl -k)= that reuses the =doctor.py:84= precedent; =boot_enablement()= reads three boot-persistence signals (bluez AutoEnable from main.conf [Policy], =systemctl is-enabled bluetooth=, whether TLP lists bluetooth in =DEVICES_TO_DISABLE_ON_STARTUP=). =diagnose()= gates the firmware read to the no-adapter branch and the boot read to the soft-blocked/powered-off branch, so a healthy run reads neither; the raw signals ride a new =probes= key that =doctor()= carries into =--json=. Detection only: no verdict, formatter, or repair change (that's Phase 1). Every read degrades to None on an unreadable tool/file, so a probe that can't see never invents a fault. AutoEnable absent/unset reads None, not false, matching bluez's compiled default of true, so only an explicit =AutoEnable=false= is the fault. New env roots for tests (=BT_MAIN_CONF=, =BT_TLP_CONF=, defaulting to absent temp paths in the Sandbox base so no test reads real /etc); =fake-journalctl= branches on =-k=, =fake-systemctl= answers =is-enabled bluetooth=. 125 bt tests, full =make test= green; =/review-code= approved (no Critical/Important; one Minor noting the firmware read also covers the btctl-unavailable branch, harmless). Inbox note sent to dotfiles.
+*** 2026-07-11 Sat @ 03:16:59 -0500 Built the firmware-hint Guide verdict
+On dotfiles main (=f05a9b4=, pushed). The no-adapter step now names the blob: a new =_no_adapter_step= consults =probes.firmware_hint()= on a genuine no-adapter fault and, on a per-vendor signature match, sets =evidence= to "no Bluetooth adapter found — <Vendor> firmware <blob> failed to load" and =next_action= to "update linux-firmware and reboot", tagged with a new =code="no-adapter-firmware"= so a =--json= consumer can branch without string-matching. A clean log keeps the generic hardware/driver verdict. A Guide, not a repair: the step carries no =repair= action, so =--fix= never touches it, and it needs no privilege model (so Phase 1 lands independently of the shared cross-panel model). A missing =bluetoothctl= (=BtctlError=) short-circuits before the firmware read, so the verdict fires only on a real no-adapter fault, not a broken install — this also tightened Phase 0 (which read the log on both None branches) to the genuine no-adapter case. =_mk= gained a uniform =code= key (default None) added to every diagnose step, mirroring the existing =repair= key; no test asserts an exact step key-set, verified. =format_doctor_human= already renders =evidence=/=next_action=, so no formatter change. 133 bt tests (+8), full =make test= green; =/review-code= clean. Inbox note sent to dotfiles.
+*** 2026-07-11 Sat @ 06:48:21 -0500 Built the persistent-power verdict + fix
+On dotfiles main (=d7d859f=, pushed). =_powered_step= consumes the bt Phase 0 boot-enablement probe: AutoEnable explicitly false, service disabled at boot, or TLP listing bluetooth → =powered-off-persistent= (code + evidence naming the cause) carrying the =persist-power= repair; absent/unset config reads as auto-enable-on (bluez default) → the plain =power-on=, so healthy machines can't false-positive. The =persist-power= repair fixes only the causes set (AutoEnable=true, =systemctl enable bluetooth=, drop bluetooth from the TLP list), powers the adapter on now, and verifies each cause cleared. Config edits are pure idempotent text transforms in a new =bootconf= module (comments preserved), staged and installed via a fixed-destination =cp= verb so the write needs root but the mutation is unit-testable. =priv.py= gained three narrow verbs (=enable-bluetooth=, =write-main-conf=, =write-tlp-conf=). The repair is Privileged and resolves through =panelkit= before running: can't-elevate degrades to the guide. The bt shim gained =panelkit= on its path. 287 bt tests + 65 make-test suites green, review-code Approve, voice. Inbox note sent. Live half (real reboot persistence) is the VM/manual checklist.
+*** 2026-07-12 Sun @ 09:14:00 -0500 Flipped the bt spec to IMPLEMENTED and logged the vNext items
+Spec status heading now IMPLEMENTED (dated history line + Status mirror); all four phase headings DONE. vNext items (stale-bond signature, connection-parameter hints, bt-audio-profile expansion) logged as the "Bt doctor vNext" task. The live half (real reboot persistence) remains on the manual-testing checklist — findings there come back as bugs.
+** DONE [#B] One copy + close control pair on every output wall :feature:dotfiles:solo:
+CLOSED: [2026-07-12 Sun]
+Resolved in dotfiles =dccd744=: every wall carries the o-copy/o-clear overlay pair. bluetooth gained the copy key (transcript via the new =viewmodel.step_copy_line=, CLI-shaped); maint traded its header COPY key for the overlay pair, kept HIDE, and its ✕ clears the session log via =PanelModel.wall_clear=; the four hand-rolled wl-copy calls collapsed into =panelkit.clipboard.copy_text= (PANELKIT_WLCOPY test seam), moving maint off the GTK clipboard so its copies survive the panel closing too. 15 new tests (8 clipboard, 4 step_copy_line, 3 wall_clear); full suite 66 green; all four panel smokes run live off-workspace — maint + audio fully pass, net + bt fail only the pre-existing state-word startup race.
+
+Converge all four instrument-console output walls on the net panel's well controls: a copy glyph and a ✕ close, as an overlay at the top right, hidden until content lands. Craig's call, 2026-07-10, while reviewing the audio doctor's wall: "network panels as standard across all others, make it consistent."
+
+Where they stand today, no two alike. net has copy + ✕ (=net/src/net/gui.py=, the =o-copy= / =o-clear= overlay). bluetooth has ✕ but no copy. maint has COPY + HIDE as keys in a header row, and no ✕. audio just gained copy + ✕ (dotfiles =bd33440=).
+
+Work: give bluetooth a copy key, give maint the overlay pair, and lift the four hand-rolled =_copy_output= implementations into one shared helper rather than a fifth copy. maint keeps HIDE alongside close, because its wall is a persistent session action log you collapse and keep, where net's, bt's, and audio's are per-run results you dismiss.
+
+Copy text is per panel but one rule: it pastes as that panel's CLI prints, so the paste lines up with the terminal a user is already looking at. audio's =viewmodel.wall_copy_text()= is the worked example.
+
+Consistent with the 2026-07-07 scope note on the sibling task above: net's compact glyph overlay is what standardizes, not maint's wide COPY-key header row.
+** DONE [#C] Org-capture float popup grows too large :bug:hyprland:quick:solo:
+CLOSED: [2026-07-14 Tue]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-13
+:END:
+Craig answered the pre-flight (2026-07-14): cap at 120 wide, height proportional. Applied as 120 Emacs columns (11 px/col measured from the live daemon) = 1320 px wide, height 653 px from the old rule's aspect. Ratio's size rule shrank from the 1892x936 scratchpad match and both hosts gained a max_size growth cap (the field is max_size — bare "maxsize" is invalid and hyprctl reload won't say so; check hyprctl configerrors). Verified live: config clean, a probe window floats at exactly 1320x653. Dotfiles 9c4dc2f.
+** DONE [#C] Panel smoke: faceplate state-word assertion fails on the live compositor :bug:dotfiles:test:
+CLOSED: [2026-07-14 Tue]
+Diagnosed and fixed within the session: not a race — test drift. Dotfiles b581d5d (2026-07-05) made the faceplate word the static subsystem identity (NETWORKING / BLUETOOTH / AUDIO) and updated the audio smoke, but the net and bt smokes kept asserting the retired live-state words and had failed on every run since. Both now assert the identity word like audio's (dotfiles 32cd99f); both smokes run RESULT: OK end to end, which also green-gates the doctor-streaming change.
+** DONE [#C] Realtime lamp output for the net + bt doctors :feature:solo:
+CLOSED: [2026-07-14 Tue]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-09
+:END:
+Shipped in dotfiles 0318a91. Both doctors stream: diagnose() emits each step as it completes (bt streams the first diagnosis only — the fix loop's re-diagnoses would replay the chain), and a repair's row goes up amber at attempt start and settles green/red with narration + evidence at completion, so the lamp blinks for the repair's real duration. The 3.5-entry height cap turned out to already be in both wells (it landed with the doctor expansions), so only the streaming half needed building. 5 new tests across net + bt; both suites green; AT-SPI smokes at parity with HEAD (one pre-existing state-word failure, filed separately).
+Retrofit the net doctor (=~/.dotfiles/net/src/net/doctor.py=) and bluetooth doctor (=~/.dotfiles/bluetooth/src/bt/doctor.py=) to stream results as a live output wall — one lamp per escalation step, amber while running, green on success, red on failure — instead of a final summary. Matches the maintenance-console doctor design (see [[file:docs/design/maintenance-console-design-ideas.org][maintenance-console-design-ideas.org]], "Doctor = live output wall"). Goal: every doctor in the system reads the same way. Both doctors already step through an escalation chain re-probing after each, so the steps are natural lamp boundaries.
+
+Scope note (Craig, 2026-07-07): realtime lamp *behavior* only. The maintenance console's wider results-wall layout (date+time stamp column, COPY, persistent history) does NOT backport — the net/bt panels are ~400px wide and lack the horizontal real estate. Their existing output wells keep their compact layout; this task just makes them stream live.
+
+Addendum (Craig, 2026-07-07): DO backport the 3.5-entry height convention — every panel's output well caps at 3.5 visible entries, the half-visible entry being the scroll cue, with the dark slate-on-black scrollbar. Layout stays compact per above; only the height cap + scroll affordance carries over.
+** DONE [#B] Absorb the clock-panel project into the dotfiles :feature:waybar:dotfiles:
+CLOSED: [2026-07-18 Sat]
+Absorbed into =~/.dotfiles= (commit 3fab11d): package =clock/src/clock/= (renamed from clock_panel), the six PNG watchface layers packaged inside the module at =clock/src/clock/assets/=, a stowed =clock-panel= shell shim (LD_PRELOADs gtk4-layer-shell), waybar left-click now =clock-panel toggle= with the absolute path dropped, tests converted pytest→unittest into =tests/clock/= plus an asset-load guard. Kept the layer-shell overlay and the socket toggle. The standalone repo is archived (ARCHIVED.md), kept for its design history. Verified live: the bar click renders the polished watchface.
+** DONE [#A] Velox boot recovery — no kernel in BE :bug:velox:zfs:
+CLOSED: [2026-07-19 Sun]
+Recovered. Velox boots linux-lts 6.18.38 and is back on the tailnet (up 1d+, /boot holds initramfs-linux-lts.img). The pre-pacman ZFS snapshot rollback restored the kernel from the ZBM recovery shell.
+Velox won't boot: ZBM prompts for the passphrase, unlocks, then reports no bootable environment with a kernel. Cause: an interrupted kernel =-Syu= removed the old kernel and never installed the new one — /mnt/be/boot (from zroot/ROOT/default) holds ONLY intel-ucode.img; vmlinuz-linux + both initramfs are gone. /boot lives inside zroot/ROOT/default (no separate boot dataset), so root-dataset snapshots capture it.
+
+Status 2026-07-15: a first rollback attempt did NOT fix it (square zero after reboot) — suspected typo in the snapshot name, so the rollback likely errored and did nothing. NOT verified. Next session: verify state in the ZBM recovery shell BEFORE any reboot.
+
+Recovery lever: the pre-pacman ZFS snapshot hook (live on velox since 2026-06-29) snapshots zroot/ROOT/default@pre-pacman_<ts> before every pacman transaction. The newest =pre-pacman_<ts>= predating the failed upgrade holds the intact old kernel — roll back to it.
+
+Morning steps (Craig at velox ZBM → recovery shell, Ctrl+R):
+#+begin_src sh
+# 1. pool writable + key loaded
+zpool get readonly zroot
+zfs get -H -o value keystatus zroot/ROOT/default
+# if readonly=on: zpool export zroot && zpool import -f -N zroot
+# if keystatus=unavailable: zfs load-key zroot
+
+# 2. list snapshots — COPY THE EXACT NAME (the typo bit here last time)
+zfs list -t snapshot -o name,creation zroot/ROOT/default | grep pre-pacman
+
+# 3. see current /boot state (read-only mount)
+umount /mnt/be 2>/dev/null; mkdir -p /mnt/be
+mount -t zfs -o zfsutil,ro zroot/ROOT/default /mnt/be
+ls -la /mnt/be/boot
+
+# 4. if /boot still shows only intel-ucode.img: redo rollback with the exact name
+umount /mnt/be 2>/dev/null
+zfs rollback -r zroot/ROOT/default@pre-pacman_<EXACT-TS> # -r, NOT -R
+
+# 5. VERIFY before reboot — remount RO, confirm the kernel is back
+mount -t zfs -o zfsutil,ro zroot/ROOT/default /mnt/be
+ls -la /mnt/be/boot # MUST show vmlinuz-linux + initramfs-linux.img
+umount /mnt/be
+
+# 6. only once /boot shows a kernel:
+zpool export zroot && reboot
+#+end_src
+Scope: only zroot/ROOT/default reverts; /home, /var, /media are separate datasets, untouched. After boot: =pacman -Syu= attended, confirm /boot holds vmlinuz-linux + initramfs before any shutdown. Full diagnosis: =docs/design/2026-07-15-velox-boot-failure-handoff.org=; ZBM photo: =assets/2026-07-15-velox-zbm-zfs-list-photo.jpg= (filed from the inbox 2026-10-05, downscaled).
+** DONE [#C] Restore date-format scrolling on the waybar date module :feature:waybar:dotfiles:quick:
+CLOSED: [2026-07-19 Sun]
+Shipped dotfiles 9dfe082: date-only ring (ordinal/full/longdate), on-scroll rewired, layout guard flipped. UTC/time stay on the time module.
+Date and time are separate fixed-position controls. The time display cycles its
+own formats, including UTC; the date/calendar control cycles date-only formats
+and never displays a second time. Implement the dedicated format rings,
+tooltip behavior, and tests together in the dotfiles Waybar configuration.
+Reference material for the compact clock/chronograph treatment is filed in
+[[file:working/clock-display-references/][working/clock-display-references/]].
+
+*** 2026-07-19 Sun @ 04:36:26 -0500 Folded clock-panel interaction direction
+The clock-panel handoff settled the prior open question: UTC belongs only to
+the time ring, while the date ring is date-only. The existing task is therefore
+a focused follow-up, not a two-line restoration of the old combined ring.
+** DONE [#C] Notification sound loudness :chore:audio:quick:solo:
+CLOSED: [2026-07-19 Sun]
+Shipped dotfiles 808ca23: NOTIFY_VOLUME default 65536->39322 (0.6 gain) in both notify copies.
+Reduce notification-sound playback loudness by 40% (0.6 gain, approximately
+-4.4 dB). Change the =NOTIFY_VOLUME= playback control rather than re-encoding
+the normalized sound files; verify each notification type still plays clearly.
+** DONE [#C] Show the active wired interface in the Waybar network module :feature:waybar:network:
+CLOSED: [2026-07-19 Sun]
+Shipped dotfiles 22867f9: select_device prefers connected wifi -> connected ethernet -> wifi fallback, so a live cable shows the wired glyph+iface instead of Offline.
+When Ethernet is active, replace the offline-WiFi presentation with the wired
+interface glyph and interface name.
+** DONE [#C] Let the clock panel dismiss itself on right click :feature:clock:waybar:
+CLOSED: [2026-07-19 Sun]
+Shipped dotfiles fc9a2b7: secondary-button gesture -> ClockApplication._dismiss hides the open panel. Live-verified with Craig 2026-07-19.
+Make a right click inside the open clock panel toggle it closed. Preserve left
+click for its established interaction; the Waybar time module remains the
+explicit way to reopen the panel.
+** DONE [#C] Make the WiFi toggle connect the best available profile :feature:network:
+CLOSED: [2026-07-19 Sun]
+Shipped dotfiles 9105361: manage.wifi_radio -> _connect_best_saved activates the strongest in-range saved profile on enable; nothing in range falls back to NM autoconnect.
+When enabling WiFi, automatically connect to the highest-priority available
+saved network instead of requiring a panel selection first.
+** DONE [#A] Tracked WireGuard private keys in repo — public leak, resolved :bug:security:network:
+CLOSED: [2026-07-20 Mon]
+Confirmed a live public leak, not just at-risk: git.cjennings.net runs cgit (scan-path=/var/git), so archsetup.git was anonymously cloneable over https. An unauthenticated clone pulled the configs with intact PrivateKeys. Exposed 2026-07-05 (c7b7d16) to 2026-07-20. Regraded to P1/[#A] (public credential exposure, severity-alone carve-out) from the initial [#B].
+Scope was wider than first found: the current 3 configs (assets/wireguard-config/wg-*.conf) plus 7 older ones at the pre-reorg path assets/wireguard/ (switzerland x2, USCALA/USCASF/USDC/USGAAT/USNY) — 10 config files, all with real keys.
+Resolution: Craig expired all the Proton WireGuard configs (keys dead). Purged all 10 from every commit with git filter-repo, force-pushed main + v0.5, and ran git gc --prune=now on the server bare repo. Verified via anonymous clone: zero real-key blobs reachable, all old exposed commits gone. Stopped tracking plaintext (gitignore + README, out-of-band configs only).
+Follow-ups filed below: harden cgit exposure; installer no longer ships configs.
+** DONE [#C] Installer chpasswd unguarded — unloggable primary user :bug:solo:quick:
+CLOSED: [2026-07-20 Mon]
+Fixed (fa3135a): extracted set_user_password, which guards the chpasswd with error_fatal so a failure aborts loudly instead of silently leaving no password. Fake-chpasswd test pins the guard fires on failure and stays quiet on success.
+Grading: Major severity (fresh system's primary user can't log in) x rare edge case (chpasswd seldom fails) = P3 = [#C].
+archsetup:1168 runs =echo "$user:$pass" | chpasswd= with no guard, then unsets the password next line; set -e is off (line 21), so a silent failure leaves no password and no log entry. Fix: guard with error_fatal (report + "set it by hand: passwd $user") before unsetting. See findings doc (S2).
+** DONE [#C] Installer nvme early module never built into initramfs :bug:solo:
+CLOSED: [2026-07-20 Mon]
+Fixed in e0d22bd: extracted ensure_nvme_early_module, which rebuilds the initramfs whenever it changed the conf (regardless of ZFS root) and scopes the presence check to the MODULES line. TDD via tests/installer-steps/test_ensure_nvme_early_module.py.
+Grading: Minor severity (module autoload still boots the system) x most-machines (all Craig's ZFS-root boxes) = P3 = [#C].
+archsetup:2910 writes MODULES=(nvme) but the only mkinitcpio -P in boot_ux runs =if ! is_zfs_root=, so on ZFS-root non-Framework machines the early-load hardening is never compiled in. Also archsetup:2918 greps the whole file for "nvme" (not the MODULES line). Fix: rebuild initramfs after the MODULES edit regardless of ZFS; scope the presence grep to =^MODULES=(=. See findings doc (S3).
+** DONE [#C] Installer disk-space pre-flight check is fragile :bug:solo:quick:
+CLOSED: [2026-07-20 Mon]
+Fixed in aef074f: extracted check_disk_space using df -P (wrap-safe) and a KB comparison (no truncation bias); non-numeric df output falls back to zero so a malformed read aborts loudly. TDD via tests/installer-steps/test_check_disk_space.py.
+Grading: Major severity (aborts a valid install) x some (df wraps long device names on a live ISO / device-mapper root) = P3 = [#C].
+archsetup:487 parses =df / | awk 'NR==2'=, which reads the device-name line (empty $4 -> 0 GB) when df wraps; archsetup:488 also integer-truncates the GB compare against the 20 GB floor. Fix: =df -P /= (single-line) or =df --output=avail=; compare in KB to avoid the rounding bias. See findings doc (S1).
+** DONE [#C] Installer run_step state + exit-code handling :bug:solo:
+CLOSED: [2026-07-20 Mon]
+Fixed in 6de55d2: run_step records the state marker whenever the step function returns (a return past error_fatal's exit means only a non-fatal warning is left), added local to run_step/show_status, and captured pacman's real exit in the refresh loop. TDD via tests/installer-steps/test_run_step.py.
+Grading: Major severity (resume re-runs steps and can abort on a survivable warning) x some (a step whose last action is a non-fatal failure) = P3 = [#C].
+archsetup:298 marks a step complete only when its function returns 0, but error_warn/run_task return 1, so a non-fatal-failing step never writes its marker and re-runs on resume. Also archsetup:1034 reports =$?= of the =false= test, not pacman's real exit code; and run_step locals (290/318) leak to global scope. Fix: step functions =return 0= explicitly (or gate run_step on a per-step error flag); capture the real exit code; add =local=. See findings doc (S1).
+** DONE [#C] cmail password decrypted world-readable before chmod :bug:security:solo:quick:cmail:
+CLOSED: [2026-07-20 Mon]
+Already fixed in dffecf5 (before this session): decrypt_to_secure wraps the gpg decrypt in a 0077-umask subshell so the file is 0600 from creation, with tests/cmail/ verifying the umask at write time. The task was stale; verified green and closed.
+Grading: security carve-out — brief local plaintext exposure of the mail password, requires a concurrent local shell during install; narrow window = low severity = P3 = [#C].
+scripts/cmail-setup-finish.sh:52 gpg-decrypts to ~/.config/.cmailpass at the process umask (often 0644), then chmod 600 on the next line. Fix: =(umask 077; gpg ... --output ...)= or decrypt to a mktemp 0600 file and mv into place (mirror the import-wireguard mktemp -d 0700 pattern). See findings doc (S4).
+** DONE [#C] Installer sudoers.pacnew blind copy risks lockout :bug:solo:quick:
+CLOSED: [2026-07-20 Mon]
+Fixed in c80e855: extracted replace_sudoers_pacnew, which runs visudo -cf on the pacnew and only copies a validated file (warns and keeps the working sudoers otherwise). TDD via tests/installer-steps/test_replace_sudoers_pacnew.py.
+Grading: Major severity (a malformed sudoers locks out privilege escalation) x rare edge case = P3 = [#C].
+archsetup:1146 does =[ -f /etc/sudoers.pacnew ] && cp /etc/sudoers.pacnew /etc/sudoers= with no validation, right before the NOPASSWD rule at 1183. Fix: =visudo -cf /etc/sudoers.pacnew && cp ... || error_warn=. See findings doc (S2).
+** DONE [#C] WireGuard import leaves full-tunnel VPN live on failure :bug:solo:network:
+CLOSED: [2026-07-20 Mon]
+Fixed in 36daf76: the down now runs before the rename modify (targets the stable UUID), so a failed modify under set -e can't leave a live full-tunnel VPN. Added a connection-down case to fake-nmcli and two ordering tests.
+Grading: Major severity (all traffic silently routed through Proton until manual cleanup) x rare (nmcli modify failure) = P3 = [#C].
+scripts/import-wireguard-configs.sh:51-62 imports (which brings the 0.0.0.0/0 tunnel up), renames, then deactivates; under set -e a failed modify aborts before the down, leaving the tunnel live. Fix: bring the connection down right after parsing the UUID, before the rename. See findings doc (S4).
+** DONE [#C] net-scenarios diagnose failure exits green :bug:test:solo:
+CLOSED: [2026-07-20 Mon]
+Fixed in cf211cd: a diagnose miss sets a per-scenario rc carried to the subshell exit, so the run fails honestly while still running fix + assert. New harness at tests/net-scenarios/ drives the real script with stubbed ssh/rsync/jq.
+Grading: Major severity (a net-doctor diagnosis regression is reported as a passing run — false green on a diagnostic tool) x rare edge case (only when a diagnosis regresses and this first-draft harness is relied on) = P3 = [#C].
+scripts/testing/run-net-scenarios.sh:103 — the scenario_diagnose_expect else-branch prints fail "...diagnose did NOT name it" but never forces a non-zero subshell exit, so ( ... ) || fails=... leaves fails unincremented and the script prints "all scenarios passed" + exit 0. Fix: exit 1 in that branch like the other two checks. See findings doc (S5).
+** DONE [#C] pacman-hook-order test is a tautology :test:solo:quick:
+CLOSED: [2026-07-20 Mon]
+Fixed in 1b7236b: the test now extracts the hook filenames the installer writes and compares them against the stock 60-mkinitcpio-remove name (pacman's filename ordering is the real invariant, not source position). Mutation-verified: a 05->70 rename fails the new compare where the old literal compare stayed true.
+Grading: Major severity (guards boot-critical hook ordering — a reorder that removes the current initramfs without a rebuild is unbootable, and this test would ship it green) x rare (hook order rarely changes) = P3 = [#C].
+tests/installer-steps/test_pacman_hook_order.py:20 — the two assertLess calls compare string literals ("05..." < "60..."), a constant ASCII fact always true regardless of file content; the ordering the test exists to protect is never measured. Only the assertIn presence checks do real work. Fix: assert on positions — text.index("05-zfs-snapshot.hook") < text.index("60-mkinitcpio-remove.hook") (and the guard hook). See findings doc (S6).
+** DONE [#C] Add inetutils to install base :feature:solo:quick:network:
+CLOSED: [2026-07-20 Mon]
+Already done in 1115543 (earlier today): inetutils sits in install_required_software, with tests/installer-steps/test_required_software.py pinning it (test_installs_inetutils_for_ftp, green). The task was stale; verified and closed. The next full VM run covers the install-path verification.
+Original context: TRAMP's /ftp: method needs =/usr/bin/ftp= (GNU inetutils); dirvish has an FTP quick-access entry. Installed manually on ratio 2026-07-14. From .emacs.d handoff 2026-07-14-1751.
+** DONE [#D] Installer resume-idempotency cluster :bug:solo:
+CLOSED: [2026-07-20 Mon]
+Fixed in 8917f2f: extracted crontab_append_once (dedup guard), zfs_scrub_timer_units (one timer per pool, warn on none instead of @.timer), and enable_user_service (wants-symlink; gamemode now uses it and syncthing folds into the shared helper). TDD via tests/installer-steps/test_idempotency_cluster.py.
+Grading: Minor severity x rare edge case (re-run after a mid-step failure) = P4 = [#D]. Group of small non-idempotent / wrong-target spots.
+crontab log-cleanup line duplicates on resume (archsetup:1713 — guard on absence); zfs scrub timer picks an arbitrary pool via =head -1= and yields =@.timer= when empty (archsetup:1857); gamemode enabled via =systemctl --user= which the script itself documents fails at install time (archsetup:2419 — use the manual wants-symlink like syncthing). See findings doc (S2, S3).
+** DONE [#D] Installer unguarded chmod/cp after non-fatal ops :bug:solo:quick:
+CLOSED: [2026-07-20 Mon]
+Fixed in dd41036: extracted install_executable (guarded cp + chmod +x) for the two zfs scripts; guarded the two hypr-live-update-guard chmods inline with error_warn. TDD via tests/installer-steps/test_install_executable.py.
+Grading: Minor severity x rare edge case (only when a preceding non-fatal cp/clone failed) = P4 = [#D].
+With set -e off, unguarded chmod/cp hit missing/partial files silently: hypr-live-update-guard chmods (archsetup:2108/2144), zfs-replicate cp (archsetup:1820) leaving a service with a dead ExecStart, zfs-pre-snapshot cp (archsetup:1943) leaving a broken pacman hook. Fix: wrap each in =(...) >> log 2>&1 || error_warn=. See findings doc (S2, S3).
+** DONE [#D] normalize-notify-sounds temp/atomicity can corrupt tracked file :bug:solo:quick:
+CLOSED: [2026-07-20 Mon]
+Fixed in a29769e: resolves the real target via readlink -f, stages the temp beside it, guards on a non-empty encode, and atomically mv's into place (preserving the stow symlink); an EXIT trap cleans a leaked temp. TDD via tests/normalize-notify/ with fake ffmpeg.
+Grading: Minor severity (corrupts a repo-tracked sound file, recoverable via git) x rare (ffmpeg failure/interrupt) = P4 = [#D].
+scripts/normalize-notify-sounds.sh:39-46 has no EXIT trap on the mktemp and does =cat "$tmp" > "$f"= (truncate-first) where $f is a stow symlink into the repo; a zero-byte/failed encode writes a corrupt file. Fix: EXIT trap; =[ -s "$tmp" ]= guard; write $f.tmp and overwrite on success. See findings doc (S4).
+** DONE [#D] VM test-framework robustness cluster :bug:test:solo:
+CLOSED: [2026-07-20 Mon]
+Fixed in 866d327: profile-suffixed PID/monitor/serial paths, kill_qemu reaps-or-polls to death before the snapshot restore, debug-vm uses DISK_PATH, and both runners report an honest ARCHSETUP_COMPLETED marker instead of a fake exit code. TDD via tests/vm-framework/test_vm_utils.py (suffix red->green; kill_qemu as a contract pin).
+Grading: Minor severity x rare edge case (each fires only in a narrow test-harness path) = P4 = [#D]. Group of four small framework bugs from the S5 audit.
+scripts/testing/debug-vm.sh:49 hardcodes the btrfs base disk, ignoring the profile-correct DISK_PATH from init_vm_paths (FS_PROFILE=zfs boots the wrong base or fatals); lib/vm-utils.sh:284 kill_qemu -9's and deletes the PID file without waiting, so a force-kill restore races the dying qemu's qcow2 lock and silently leaves the base image dirty (fix: wait for the PID); lib/vm-utils.sh:69 leaves PID_FILE/MONITOR_SOCK/SERIAL_LOG un-suffixed so parallel btrfs+zfs runs collide (fix: suffix by FS_PROFILE like DISK_PATH); run-test.sh:287 (and run-test-baremetal.sh:234) reports a completion-marker grep as ARCHSETUP_EXIT_CODE, not the installer's real exit — misleading since the installer runs set -e off and can error then still write the marker (fix: rename + capture the true status). Testinfra remains the real pass/fail backstop. See findings doc (S5).
+** DONE [#D] Gallery-widget prototype elisp bugs :bug:design:solo:quick:
+CLOSED: [2026-07-20 Mon]
+Fixed in 552736e: shared clamp feeds needle + readout (150 renders 100%), explicit cl-lib require, and gallery-widget--source-dir with a default-directory fallback. TDD: 3 new ERT tests (clamp red->green; the other two land as pins since svg.el transitively loads cl-lib).
+Grading: Minor severity x rare edge case (out-of-range input / cold byte-compile / interactive re-eval) = P4 = [#D]. Prototype code, all three Minor.
+docs/prototypes/gallery-widget.el:139 renders the readout from the unclamped value while the needle clamps 0-100, so at value 150 the needle pins at +60 degrees but the text reads "150%" (fix: clamp once, format both from it); :69 calls cl-loop without (require 'cl-lib) — works only via the autoload cookie, bites on a cold byte-compile (fix: add the require); :29 computes its dir from (or load-file-name buffer-file-name), both nil on interactive re-eval outside a load/file buffer (fix: fall back to default-directory). See findings doc (S7).
+** DONE [#D] Audit test-quality cluster (Python + elisp) :test:solo:
+CLOSED: [2026-07-20 Mon]
+Fixed in 179fbd5 (plus 552736e for the gauge-level clamp test): socket check via find -type s, gen_tokens degenerate case pinned exactly as characterization, tick count as direct occurrences, and write-svg covered. All five items dispositioned.
+Grading: no runtime behavior change; test-suite quality. Group of five weak/missing tests from the S6/S7 audit.
+scripts/testing/tests/test_desktop.py:96 passes a shell glob to `test -S`, which breaks on zero or multiple sockets (masked today because the test always skips); tests/gallery-tokens/test_gen_tokens.py:181 asserts properties too weak to notice the marker output is garbled (impossible input, so low); tests/gallery-widgets/test-gallery-widget.el:77 counts ticks via split-string + cl-count-if :start 1 (a coincidence of split semantics, not a match count); :47 tests the needle-angle helper's clamp but never the rendered readout at an out-of-range value (exactly why the S7 readout/needle bug ships green — add a gauge-level boundary case); :159 leaves gallery-widget-write-svg uncovered (add a Normal write-to-temp case). See findings doc (S6, S7).
+** DONE [#B] Installer GRUB_CMDLINE overwrite drops boot params :bug:solo:
+CLOSED: [2026-07-21 Tue]
+Fixed in f9da097: update_grub_cmdline merges the current value with archsetup's tokens (existing tokens survive, same-key conflicts resolve to archsetup's value) behind a refuse-to-write safety check, via awk + mv with a backup_system_file first. TDD via tests/installer-steps/test_grub_cmdline.py (8 cases incl. cryptdevice/resume/zfs survival and idempotence).
+Grading: Critical severity (unbootable) x some-users-sometimes (machines whose base install set a cryptdevice=/resume=/zfs= cmdline param) = P2 = [#B].
+archsetup:3054 rewrites the whole GRUB_CMDLINE_LINUX_DEFAULT line with a fixed string; nothing re-adds a pre-existing cryptdevice/resume/zfs token, so grub-mkconfig (3059) can bake an unbootable config. Fix: read the current value and append only the missing tokens; assert any pre-existing boot-critical token survives before grub-mkconfig. See [[file:docs/design/2026-07-19-sentry-code-findings.org][sentry code findings]] (S3).
+** DONE [#C] Maint status wall copy buttons :feature:maint:dotfiles:
+CLOSED: [2026-07-21 Tue]
+Shipped in dotfiles 8bc79ba per Craig's calls (one global button, rendered text): COPY on the doctor row serializes every category band via the same card_spec the GUI renders, through panelkit clipboard. TDD tests/maint/test_status_copy.py, full dotfiles make test green, inbox note sent. Live check pending: open the maint panel, press COPY, paste.
+Craig's roam capture 2026-07-20, routed via .emacs.d sentry inbox-zero as archsetup-owned UI work. Dotfiles maint panel work; archsetup drives it end-to-end per the standing rule.
+** DONE [#B] Build: desktop-settings panel :feature:hyprland:dotfiles:
+CLOSED: [2026-07-22 Wed]
+:PROPERTIES:
+:SPEC_ID: d6bb1e73-ec90-4327-85ee-bfa762da5bce
+:END:
+The GTK build of the desktop-settings panel per the spec (docs/specs/2026-07-02-desktop-settings-panel-spec.org, DOING; normative reference: prototype 37). Work happens in dotfiles settings/ — archsetup drives the lifecycle. Two non-blocking build-time picks live in the spec's Review findings (wallpaper setter tool; store location/format) — decide in phase 1 and record there.
+*** 2026-07-22 Wed @ 13:14:01 -0500 Built the backings engine (phase 1) — dotfiles 7a15237
+Landed as dotfiles settings/src/settings (10 modules) + tests/settings (118 tests against fake binaries, auto-discovered by make test — 81 suites green). Covers brightness/kbd (5% floor, x10 drum), toggles (dim, pointer cycle via toggle-touchpad, caffeine), DND class-split (dunst pause level 60, close-all before unpause, alarms punch through live), powerprofilesctl, nightlight (resident gammastep), hypridle.conf renderer + symlink-safe write + caffeine-respecting reload + hyprlock grace, suntimes (pure NOAA math), and the wallpaper engine (awww/mpvpaper/projector adapters, galleries, random draw, atomic JSON store). All three build-time picks recorded as DONE findings in the spec (setter=awww, store=state.json, nightlight=gammastep). Handoff note in ~/.dotfiles/inbox/.
+*** 2026-07-22 Wed @ 15:26:44 -0500 Built the presenters (phase 2) — dotfiles 5172289
+Three GTK-free models per prototype 37, all at 100% line coverage (tests/settings/test_presenters.py, 100 tests; full repo suite green before and after). programs.py: the matrix — eight complete programs (Craig's four factory scenes drafted here per the pre-flight pick, slots 1-4 first-class), pin rows + power radio row, activate returns the full sets, member writes return apply/updated with active-is-live surviving. bench.py: drum mapping (screen never reads 0, floor 5%; kbd floors at 0), idle rail order clamping between enabled neighbors, park/unpark with re-clamp, caffeine bypass, view-state builder tolerant of no-backlight None. channels.py: the eight-channel bank, per-mode sources visibility, alpha/recency sort (unlabeled last), the shared mint/edit/delete grammar for pairs/sets/colors (press arm-cycle, two-picture set minimum, dup rejection, selection clamping), sources guardrails, interval wheel, previews. Handoff note in ~/.dotfiles/inbox/.
+*** 2026-07-22 Wed @ 16:03:52 -0500 Ported prototype 37's instruments to GTK (phase 3) — dotfiles 33d82eb
+The panel renders P37 end to end. New instruments.py carries the three Cairo instruments as clock-free humble objects: ProgramMatrix (glyph/numbered heads over jewel pins + CPU POWER paper letter wheels), DrumRoller (paper drums, drag-to-set, dimmed n/a on no-backlight machines), TripDial (sqrt 300° scale, colored stage tabs, OFF-notch parking, exact-minutes drag counter, BYPASSED · CAFFEINE stamp, bottom legend). gui.py rebuilt to P37's layout with the wallpaper sub-view: channel bank with drawn faces, minted pair/color/set trays (alpha/time sort, edit/delete chip feet), the three presses (pair arm-cycle, color picker, set press + interval wheel), sources with a folder picker. New GTK-free glue all unit-tested (test_panel_glue.py, 33 tests): dial geometry in bench, matrix/idle/wallpaper wiring in panel, presenter-vocabulary channels (pair/solid/random-from-set) in wallpaper.apply. AT-SPI smoke (make test-panel-settings) drives the real wiring against faked backings + a sandboxed store, pinned to its own child pid so it can never fire a live panel's backings. Visually verified on a headless output against P37 captures (main + pair/single/solid/random). Adaptations recorded in the handoff: five-stage dial (WATCH gets its own green — the engine runs watch separately, P37 merged the label), DESKTOP_SETTINGS_START_VIEW test seam. Full suite 84 suites green; window rule widened for the 540px panel. Handoff note in ~/.dotfiles/inbox/.
+*** 2026-07-22 Wed @ 16:47:54 -0500 Integrated phase 4 — dotfiles 680b50d
+Bar consolidation had landed early (74f723e); this pass shipped the rest. settings-project hosts the watch/clock/world channels as HTML faces (settings/faces/) on a gtk-layer-shell background window over WebKit2 — all three visually verified on a headless output, world reading the waybar worldclock roster via query param. settings-watch is the hypridle watch-stage host: throwaway-profile chrome kiosk that reveals only after its window maps behind the lock and relocks before teardown — a failed face degrades to the plain lock, never a bare desktop (unlocked lifecycle verified live; the locked swap goes to the manual checklist). Sun-pair location reads whereami live per transition with last-good cache in state.json (verified live: 9.5s first beat, New Orleans coords, Gogh day side applied); desktop-settings-tick.timer (2 min, enabled on ratio, added to the installer) drives flips and random draws — 23ms no-op beats. dunstrc history_length 100 protects held alarms (full DND cycle verified against live dunst; wtimer alarms already CRITICAL via the notify wrapper, no promotion rule needed). Live hypridle rewrite verified — five-stage regime rendered through the stow symlink, caffeine respected (found engaged, daemon correctly left stopped). Refresh signals needed no rewiring (touchpad signals itself via toggle-touchpad). 45 new tests; suite 84 suites green; smoke 13/13. Handoff note in ~/.dotfiles/inbox/.
+Velox one-time steps (sync doesn't carry): mpvpaper (AUR), optionally power-profiles-daemon (service off), and systemctl --user enable --now desktop-settings-tick.timer.
+*** 2026-07-22 Wed @ 17:05:58 -0500 Landed the 17-point end-to-end pass — dotfiles 9038eee
+Prototype 37's 17-point suite re-derived against the real panel (the original Playwright script wasn't preserved; the functional surface in the spec's Final prototype section is the source). tests/settings/panel_e2e.py + run-panel-e2e.sh + =make test-panel-e2e=: points 1-14 drive the running panel over AT-SPI (program recall with per-backing verification across FOCUS/BATTERY/slot1, pointer console keys, all eight wallpaper channels including projected watch/world stop/start ordering, close); points 15-17 cover the Cairo instruments (drums, tripper dial clamp/park/render/reload, matrix pins + letter wheels with active-is-live) at the backing layer, since AT-SPI can't reach a DrawingArea's hit-tests. Same safety posture as the smoke: sandboxed store, faked backings, pid-pinned a11y node. 17/17 green on ratio's live compositor; full suite 85 green; smoke 13/13; ruff clean. The drag gestures go to the manual checklist below. Handoff note in ~/.dotfiles/inbox/.
+*** 2026-07-22 Wed @ 17:05:58 -0500 Flipped the spec to IMPLEMENTED
+docs/specs/2026-07-02-desktop-settings-panel-spec.org DOING → IMPLEMENTED with a dated history line naming the shipping commits (dotfiles 7a15237 / 74f723e / 5172289 / 33d82eb / 680b50d / 9038eee) and the verification evidence (85 suites, smoke 13/13, e2e 17/17). The four panel drag-gesture checks and the locked-path night-watch swap live under "Manual testing and validation" — human-eye checks, not implementation blockers.
+** CANCELLED [#B] Hyprland layoutmsg crash — bad_variant_access (upstream) :bug:hyprland:
+CLOSED: [2026-07-21 Tue]
+Dropped 2026-07-21 (Craig's call) — not tracking the upstream report. The crash evidence (both reports + tmpfs log excerpts) and the voice-passed issue draft stay preserved in [[file:working/hyprland-layoutmsg-crash/][working/hyprland-layoutmsg-crash/]] if it recurs and is worth reviving.
+Grading: Critical severity (SIGSEGV kills the whole desktop session; every GUI app's unsaved state lost) x rare edge case (twice in ~4.5 months: 2026-03-07 on v0.54.1, 2026-07-20 on v0.55.4) = P2 = [#B]. Upstream Hyprland bug, not this repo's code — the task tracks reporting it and picking up the fix.
+A layoutmsg mfact dispatch (layout-resize, mod+H/L) throws std::bad_variant_access inside Layout::CAlgorithm::layoutMsg, uncaught, SIGSEGV. Both crashes fired from the layout-resize mfact path (keycode 104 shrink today, 108 grow in March). Layout at crash was master and the identical mfact had worked seconds earlier; the pre-crash window held monocle<->master toggles, two window closes dropping focus to "[Window nullptr]", and togglefloating x2. Monocle is a registered v0.55 layout (log shows graceful "Unknown monocle layoutmsg" rejects), so the config is not at fault; related edges are guarded ("mfact -> no window") while this path misses its variant guard. Repo has no newer build (0.55.4-1 installed and repo).
+Evidence preserved in [[file:working/hyprland-layoutmsg-crash/][working/hyprland-layoutmsg-crash/]] (both crash reports + excerpts from the tmpfs session log, extracted before reboot loses it).
+Next: Craig posts the issue himself (2026-07-20 decision) — the voice-passed draft is [[file:working/hyprland-layoutmsg-crash/issue-draft.md][issue-draft.md]], with both crash reports and the log excerpts beside it for attaching. Watch the repo for a fixed release and close on confirmation. The layout-resize script guard was declined (a script can't observe the internal desync).
+** DONE [#C] WireGuard import is now config-less — decide feature fate :feature:network:
+CLOSED: [2026-07-21 Tue]
+Decided 2026-07-21 (Craig): KEEP the import feature. The out-of-band flow is already in place — =assets/wireguard-config/= carries a README documenting "drop plaintext =*.conf= locally at install time (gitignored); ship encrypted =*.conf.gpg= to track", and its =.gitignore= enforces it (=*.conf= blocked, =!*.conf.gpg= allowed). The script also already no-ops gracefully on an empty dir (=shopt -s nullglob= + a =found= flag), so nothing ships and nothing errors when no configs are present. Nothing to build; the fate decision was the whole task.
+scripts/import-wireguard-configs.sh reads assets/wireguard-config/*.conf, but no configs ship in the repo anymore (removed as a public-leak fix; .gitignore blocks plaintext).
+** DONE [#C] Dupre theme waybar.css drifted from live style.css :bug:dotfiles:waybar:
+CLOSED: [2026-07-21 Tue]
+Fixed in dotfiles 3e4e7ff (2026-07-20, "fix(theme): sync dupre waybar.css with the live weather rules") — dupre/waybar.css is byte-identical to live again, restoring the =#custom-weather= selectors/hover/gold divider, so =tests/theme-css= is green.
+Grading: Minor severity (cosmetic, reverts only on a theme switch) × rare edge case (dupre is already the active theme) = P4 = [#D] on user impact, bumped to [#C] because the dotfiles =make test= stays RED until synced, poisoning the green baseline for every future commit.
+The weather-kit work added =#custom-weather= selectors to =hyprland/.config/waybar/style.css= but never mirrored them into =hyprland/.config/themes/dupre/waybar.css=. =tests/theme-css= asserts the two files are identical (set-theme copies the theme file over the live one), so switching to dupre would silently revert the weather chip styling. Fix: sync the theme file to live. Pre-existing; found 2026-07-19 during an unrelated commit's green-baseline run.
+** DONE [#B] Dotfiles tests leak state across files :bug:test:dotfiles:solo:
+CLOSED: [2026-07-23 Thu]
+Resolved 2026-07-23 as dotfiles =c333598=. The polluter was =tests/weather/test_weather.py=, and it accounted for all 38 failures on its own.
+
+The mechanism was not the env leak the body below guessed at — tests/weather never writes =os.environ=. Its whereami fake did =weather.subprocess.run = ...= on a freshly-loaded module object. The fresh module isolated the weather code, but =weather.subprocess= is the one shared stdlib module object every module in the process holds, so the assignment replaced =subprocess.run= process-wide and never restored it. Every later test file got weather's fake result back from =subprocess.run=; the tell was wtimer asserting on =r.returncode= and getting "'R' object has no attribute 'returncode'", where =R= is weather's fake result class.
+
+Triage: TEST HYGIENE, not production global state. The weather script reads env at import and never writes, so no long-lived-process caching defect sits behind it. A scan for the same pattern (patching a stdlib module attribute reached through another module's namespace) finds exactly one instance in the suite — the three other =setattr= sites all snapshot and restore. So the planned shared env helper across 28 files was aimed at the wrong target and wasn't needed.
+
+Fix: rebind the loaded module's own =subprocess= name to a stub namespace, so nothing outside that module changes and there is nothing to restore.
+
+Gate: =make test= now runs two gates per the add-don't-replace decision — =test-forked= (one process per file, catches order dependence) and the new =test-shared= (every suite in one process, catches leakage). Built on stdlib unittest rather than pytest, since pytest was only the diagnostic tool and isn't a project dependency. Verified as a real gate, not just green today: with the defect deliberately reintroduced it goes red, and green once restored. A focused test in tests/weather pins the invariant on the culprit as well, because the shared gate alone blames the three victim files.
+
+Verification: 3500 tests, both gates, exit 0.
+
+Original finding follows.
+
+Found 2026-07-23 during the speedrun. =make test= is green, but it runs each test file in its own =python3 -m unittest= process, which hides cross-file state leakage. A single-process whole-tree run (=python3 -m pytest tests/ -p no:randomly=) fails 38: 22 in =tests/wtimer/test_wtimer.py=, 10 in =tests/zoom-web/test_zoom_web.py=, 6 in =tests/wlogout-menu/test_wlogout_menu.py=.
+
+Not a regression — a worktree at the pre-speedrun commit produces the identical 22/10/6 profile, so this predates tonight's work. Those three files also pass cleanly when run together (170 passed), so the polluter is a fourth file somewhere in the tree that mutates global state (env var, cwd, or a module-level patch) without restoring it. 28 test files write =os.environ= directly.
+
+Why it matters: the green gate can't see this class of bug, so a real isolation defect — or a genuine failure that only appears under a different order — passes CI silently. Bisect by running the tree with subsets until the polluter is identified (pytest's =-p no:randomly= keeps the order stable while bisecting), fix its cleanup, then decide whether =make test= should gain a single-process pass so the gate covers it.
+** DONE [#B] Wallpaper view freezes the panel — thumbnail decode :bug:dotfiles:solo:
+CLOSED: [2026-07-23 Thu]
+Craig reported 2026-07-23: selecting the wallpaper button freezes the module and the compositor asks whether to kill it. Root cause proven: =_Thumb._draw= decoded each source image with =new_from_file_at_scale= on the GTK main thread. Measured on Craig's 78 wallpapers — a viewport of the 8 largest takes 3.7s, the whole set 13s. That block trips Hyprland's "not responding" watchdog.
+
+Grading: Critical severity (panel unusable, watchdog kill) × every user every time the wallpaper view opens = P1 = [#A] by the matrix. Held at [#B] because step 1 already shipped and removes the user-visible freeze; the remainder is a latency enhancement, not a showstopper.
+
+*** 2026-07-23 Thu @ 15:40 Step 1 — async decode (dotfiles f45f321)
+Moved the decode to a worker thread via a new =settings/thumbcache.py= (pure, injected decode/scheduler/thread; 6 tests). The thumb shows its dark ground until the pixbuf lands, then redraws. Verified live on a headless output: worst main-loop stall opening the pair view dropped from multi-second to 68ms; the cache filled with 81 decoded pixbufs (the one miss is a .webm, correctly falling back to the ▶ glyph). Full suite 3512, both gates, smoke OK. This alone fixes the reported freeze.
+
+*** 2026-07-23 Thu @ 16:30 Step 2 — persistent on-disk cache (dotfiles 463cc4f)
+Built the persistent layer: =settings/thumbstore.py= decodes each source once to a 512px PNG under =~/.cache/settings/thumbs=, keyed by path + mtime so an edited wallpaper self-invalidates. The hot-path decode reads that PNG and scales in-memory. Warming rides the existing =settings tick= CLI verb (the 2-min timer already runs it), building up to =WARM_PER_BEAT=8= missing thumbnails per beat — best-effort, journals a line on failure, never blocks the wallpaper flip. thumbstore is pure (stat/decode/load/save injected); 10 tests.
+
+Went with incremental warming (8/beat, ~10 beats to full) as the safe default rather than full-warm-on-change — the per-beat cap is a one-line flip if Craig wants it faster. Measured: hot-path decode of a viewport dropped from 3.7s cold to 47ms warm. No installer change (the tick service already runs =settings tick=); cache lives outside the repo. Full suite 3522, both gates, smoke OK, live panel verified (81 pixbufs render, 48ms worst stall warm).
+** DONE [#C] Panel scrollbars too short :bug:dotfiles:quick:solo:
+CLOSED: [2026-07-23 Thu]
+Shipped 2026-07-23 as dotfiles =0d64837= (22px scrollbar, 16px trough, 14px slider thickness with a 48px floor along the travel axis). Left open by oversight during the speedrun; closing now.
+
+Follow-on, and my own regression: enlarging the bar to 22px is what made it start covering the thumbnails, because nothing grew the tray to match. Craig reported it the same day ("scrollbars that obscure the images") and it's fixed in =c0ddf57= — the tray now reserves a 22px lane for the bar as a margin on the scrolled box, so the bar sits below the images instead of across them. Measured before: tray 68px, content 68px, a visible 14px bar inside the same 68px. After: tray 90, content 68, bar clear. The lane is a constant under the scrollbar CSS with a note to keep the two in step, since the coupling between bar thickness and tray height is exactly what broke.
+
+From the roam inbox (Craig, claimed 2026-07-23): all scrollbars need to be much taller than before. The always-visible scrollbars shipped in 7e8eb4a set =min-height: 10px; min-width: 10px= on the slider (=settings/src/settings/gui.py=, the =.dupre-panel scrollbar slider= rule) — that's the floor for a short slider, and the trough itself is thin. Raise both the slider floor and the trough thickness so the bar is comfortably grabbable. Cosmetic × every glance at the wallpaper trays = P3 = [#C].
+** DONE [#C] Velox refresh sweep :chore:maint:
+CLOSED: [2026-07-23 Thu]
+From the roam inbox (Craig, claimed 2026-07-23): velox needs bringing up to date, the mouse/touchpad module is still there, investigate what else didn't move over.
+
+Resolved 2026-07-23 by a full sweep over tailscale. The touchpad module was already gone — velox's running waybar (started 01:05, after the reboot) and its tracked config both carry zero =custom/touchpad= entries; what Craig saw was the pre-restow waybar process from before the reboot, and the reboot cleared it. Sweep results: both machines at dotfiles f9b6404 (all three hyprland lock/exit fixes live on velox, config errors clean, =allow_session_lock_restore= reads true); stow restow clean, only the expected skip-worktree files; rulesets pulled to 50fc7ca and =make install= run (agent-text verified working by invoking it — an earlier "MISSING" reading was a PATH artifact of the non-interactive ssh shell, not a real gap); desktop-settings tick timer active; mpvpaper, power-profiles-daemon, gtk4-layer-shell, webkit2gtk all present.
+
+Genuine remaining differences, all per-machine installs rather than sync failures: =cmail-action=, =gcalcli=, and =playwright= aren't installed on velox, and =obsbot-wb-guard.service= isn't enabled there (the OBSBOT lives on ratio). None block anything; file separately if velox should send mail or drive browser tests.
+*** 2026-08-20 Thu @ 09:53:04 -0700 One of those three closed itself; two still stand
+=cmail-action= is on both daily drivers now, and nothing did it deliberately. It moved into rulesets at =claude-templates/bin/=, and rulesets' =make install= links that whole directory into =~/.local/bin= at every session start — so velox picked it up on its own. Verified here: the symlink was written 05:44 this morning by this session's own startup, and the tool runs.
+
+=gcalcli= and =playwright= are still absent on velox, which stays correct until I say velox should send calendar invites or drive browser tests. =obsbot-wb-guard= is still right to be off here; the camera is on ratio.
+
+Leaving the paragraph above as written rather than striking it (rulesets suggested striking). It is the resolution note of a task closed 2026-07-23 and it was accurate that day. Editing a closed record to match today makes it a worse record, and the useful correction is this dated entry, not a redaction.
+** DONE [#C] Weather tooltip sunrise and sunset :feature:waybar:weather:quick:solo:
+CLOSED: [2026-07-23 Thu]
+Shipped 2026-07-23 as dotfiles =de62e9d=. The two rows sit directly below Humidity in the current-conditions block, rendered in the footer's 12-hour format (=%-I:%M %p=) so the tooltip reads one way throughout.
+
+Confirmed the no-extra-round-trip premise held: =sunrise,sunset= joined the existing =&daily== block. Split =forecast_url= and =reading_from= out of =fetch= so both the request and the reading are testable without network — that's what let the new cases cover a payload missing the fields. Six tests (Normal/Boundary/Error): row placement and format, a pre-change cache with no sun fields, an unparseable stamp, today's pair picked out of the six-day arrays, and the API omitting them. Reused the existing =_at= helper rather than adding a near-duplicate =_first=.
+
+Live-verified against the real API: sunrise 6:14 AM, sunset 7:59 PM for today in New Orleans, rendering in the actual tooltip. Full suite 3506 tests, both gates, exit 0.
+
+Open, not blocking: every other header row carries a glyph (thermometer, droplet, wind arrow) and the sun rows are plain text. The file's glyphs are marked font-confirmed codepoints, and I haven't verified a sunrise/sunset glyph renders rather than showing tofu, so I left them bare. Craig's call.
+
+From the roam inbox (Craig, claimed 2026-07-23): in the weather module's hover text, the section immediately after the location ends with the current humidity. Add the sunrise and sunset times for the current location directly below it.
+
+Cheap to source: the module already calls Open-Meteo with a =&daily== block (=common/.local/bin/weather=, the forecast URL around line 336), so =sunrise,sunset= joins that same request with no extra round trip — normalise_daily already parses the daily arrays. Times arrive as local ISO strings; render in Craig's canonical clock format rather than re-deriving one. The settings package's =suntimes.py= (pure NOAA math, no network) stays the offline fallback path if the API field is ever absent — don't duplicate its math here.
+** DONE [#C] Maint doctor-row copy button :refactor:maint:quick:solo:
+CLOSED: [2026-07-23 Thu]
+Shipped 2026-07-23 as dotfiles =761fa5c=, "fix(maint): drop the COPY key from the doctor row" — the key and its orphaned handler removed from =maint/src/maint/gui.py=. =viewmodel.status_copy_text= stays: it's a tested pure serializer and the obvious source if a copy surface returns somewhere better placed.
+
+Correction to the body below: it describes a per-row button and a separate global one. There is only one COPY key, and it IS the global one Craig added in 8bc79ba two days earlier. He tried it and wanted it gone, so the row now reads DOCTOR · CLEAN UP · REVIEW & FIX.
+
+From the roam inbox (Craig, claimed 2026-07-23): remove the per-doctor-row copy button (next to REVIEW and FIX) from the maint status wall. The global COPY key (dotfiles 8bc79ba, "one global button copying rendered text") stays the one copy surface — the per-row button turned out to be clutter next to it.
+** DONE [#C] WiFi tooltip signal strength :feature:waybar:network:
+CLOSED: [2026-07-22 Wed]
+From the roam inbox (Craig, claimed 2026-07-22): add signal strength to the WiFi tooltip.
+
+Resolved 2026-07-22: the tooltip's signal line existed but never fired on ratio — the mt7925 driver leaves /proc/net/wireless empty (legacy WEXT procfs unimplemented), so the dBm read returned None and the bar glyph fell to the weakest tier. Fix in dotfiles net/: an iw-dev-link nl80211 fallback (only spawns when procfs is empty), a signal_percent mapping, and an enriched line — Signal: ▂▄▆█ 100% · -32 dBm (excellent) — bars by band, percent, raw dBm, band word. The bar icon tier fixed itself as a side effect.
+** DONE [#B] Desktop-settings dropdown panel :feature:waybar:
+CLOSED: [2026-07-22 Wed]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-22
+:END:
+Resolved 2026-07-22: shipped end to end via the "Build: desktop-settings panel" task (dotfiles 7a15237 → 9038eee; spec IMPLEMENTED, 85 suites + smoke 13/13 + e2e 17/17). Every open question below got settled in the spec: bar consolidation landed (74f723e), the wallpaper manager became the in-panel sub-view, and the format pickers split into their own sibling spec ([[file:docs/specs/2026-07-19-display-format-single-source-of-truth-spec.org]], DRAFT stub). Remaining human-eye checks live under "Manual testing and validation".
+
+Original body follows as the record.
+
+Initial spec written 2026-07-02: [[file:docs/specs/2026-07-02-desktop-settings-panel-spec.org]] (DRAFT — four decisions await Craig's review before build; architecture updated to the net panel's Blueprint/GTK4 stack).
+
+One waybar dropdown gathering the desktop toggles and sliders into a single settings panel, opened from a gear/settings glyph on the bar. Incorporate:
+- *Auto-dim* toggle (the =custom/dim= feature just shipped — fold in here, or keep the standalone indicator and mirror it).
+- *Brightness* slider (backlight, via brightnessctl).
+- *Keyboard-backlight* brightness slider (brightnessctl on the kbd_backlight class).
+- *Mouse* enable/disable toggle — shown only when a mouse is connected.
+- *Trackpad* enable/disable toggle — shown only when a trackpad is connected (mirror =toggle-touchpad= / =touchpad-auto=).
+- *Idle inhibitor* (the =custom/idle= module that replaced the built-in =idle_inhibitor= 2026-06-24 — toggles the hypridle daemon, state-synced icon).
+- *Airplane mode* (the existing =airplane-mode= toggle; laptop-only).
+
+The conditional rows (mouse, trackpad, airplane) appear only when their hardware/context applies — reuse the laptop/device detection the airplane and touchpad indicators already do.
+
+Design / open questions (propose before building):
+- Panel tech: sliders need a real toolkit (waybar can't host a slider), so a GTK4 + gtk4-layer-shell app like pocketbook is the likely shape.
+- Which existing standalone bar modules (dim, touchpad, airplane, idle_inhibitor) collapse INTO this panel vs. stay on the bar as quick-access indicators. Craig's call.
+
+Implementation notes: a small GTK layer-shell app (mirror pocketbook's structure: src-layout Python package, pytest, Makefile) talking to brightnessctl / hyprctl / the touchpad + airplane helpers. Lives in the dotfiles repo or in-tree like pocketbook. TDD the backing toggle/slider logic. Sizable — worth a design doc first.
+
+Home handoff 2026-07-19 (inbox, resolving the open "few other things" decision — fold into the spec, close the open decision, extend the controls table, then run spec-review, may flip DRAFT→READY). Ownership: home drives the build (dotfiles settings/), archsetup keeps the canonical spec. Full reconciliation in home docs/design/2026-07-19-desktop-settings-module-brainstorm.org.
+- ADD controls: night-light / color temperature; Do Not Disturb / notifications (dunst); lock / suspend quick actions; power profile (performance/balanced/saver); scenes/profiles — one control flipping several toggles at once (Focus, Presentation, Battery-saver, Night). Scenes are the payoff of consolidating everything.
+- OUT (record reasons): volume / master-mute stays with the audio panel (no mirror here); theme light/dark goes to the theme-studio task.
+- FORMAT PICKERS pulled to their own future sibling spec — time/date/weather format is out of THIS panel. Rationale: format settings live in many programs, so the design problem is a single source of truth for the canonical format. Track a future sibling-spec stub in docs/specs (time/date/weather format single-source-of-truth); Craig thinking it through separately, not started.
+- STILL OPEN (spec already flags): wallpaper manager confirmed in scope, but row-that-opens-a-sub-view vs its own sub-spec undecided — resolve at spec-review.
+** DONE [#C] Gallery probe: the fader-drag check is flaky :bug:test:design:quick:solo:
+CLOSED: [2026-07-23 Thu]
+Fixed 2026-07-23. Root cause confirmed rather than suspected: =panel-widget-gallery.html= line 74 sets =html{scroll-behavior:smooth}=, so =scrollIntoView= animates and the fixed 200ms sleep sometimes read =getBoundingClientRect= mid-scroll. The drag then dispatched at stale coordinates, the press missed the fader, and the check reported a dead widget.
+
+Fix: scroll with =behavior:'instant'=. The probe never needed the animation, so this removes the race instead of waiting it out. Also added a =settledRect= guard (rect stable across two reads AND on-screen) for zoom/column relayout, and a =hits()= assertion that the press actually lands on the fader before the drag goes out.
+
+Applied to the toggle-click check too — it shares the same fixed-sleep shape, and it failed for this exact reason during the diagnosis, so fixing only the fader would have left half the defect.
+
+Worth recording: my FIRST fix was wrong and made it worse. Polling until the rect stopped changing returned pre-scroll coordinates every time, because two identical samples are also what you get before the animation starts — an intermittent failure became a consistent one. The new hit-test assertion is what caught it, printing the press point at y=1326 against a 1200px window. That's the argument for asserting the press landed rather than only asserting the readout moved.
+
+Verified against the measured 1-in-6 failure rate: 8 consecutive runs, all three checks passing, with the press point identical every run (429,480) — deterministic, not lucky. Full probe 96 PASS, 0 FAIL, exit 0.
+
+=probe.mjs= check 3 ("fader drag tracks at 3x") intermittently reports =level 68 -> level 68=, i.e. the synthetic drag never registers. It has presumably been doing this all along unnoticed, since the suite is normally run once per batch.
+
+Grading: *Minor* severity (a false FAIL costs a re-run and a few minutes, and never ships a defect) x *most users, frequently* = P3 = =[#C]=.
+
+Frequency measured 2026-07-16, not estimated: 1 failure in 6 consecutive runs, having already fired twice in about fifteen that afternoon. The first grading guessed "some users, sometimes" (~1 in 10); at ~1 in 6, both people who run this suite hit it most sessions, so the row is "most users, frequently". The letter lands on =[#C]= either way, but the input was wrong and the matrix is only worth anything if its inputs are measured.
+
+Suspected cause: the check clicks the 3x size chip, calls =scrollIntoView=, waits a fixed 200ms, then reads =getBoundingClientRect= and dispatches the drag against those coordinates. If the zoom relayout or the smooth scroll hasn't settled, the rect is stale and the press lands off the fader — so the drag is a no-op and the readout never moves. The other timing-sensitive checks share the same fixed-sleep shape.
+
+*Do not fix this by raising the sleep.* That hides the race rather than removing it and leaves the check failing again on a slower run. Wait on the actual condition instead: poll until the rect stops changing between frames, or assert the press landed on the fader before dispatching the drag (the probes' own README already warns that a =find()= miss dispatches into nothing and reports as a widget bug).
+
+Why it matters beyond the annoyance: a gate that cries wolf gets its real failures ignored, and this suite is the only thing standing between the gallery and a silent regression.
+
+Recurrences: 2026-07-18 batch-6 gate (first run, passed 3 reruns); 2026-07-18 batch-9 gate (first cold run, =level 68 -> level 68=, passed 2 reruns); 2026-07-21 double-speedrun run (flashed one RED mid-run, passed on rerun). All were a session's first/early probe run — consistent with the stale-rect theory (cold-start relayout settling slower than the fixed 200ms sleep).
+** DONE [#C] Dotfiles stow conflicts: first-launch risk + restow directory handling :bug:dotfiles:quick:solo:
+CLOSED: [2026-07-23 Thu]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-14
+:END:
+Closed 2026-07-23. The last open item was the velox check, and velox is reachable again. Pulled it from =02df01a= to =de62e9d= (clean tree, fast-forward), then ran =make conflicts hyprland=, which dry-runs every tier: "No stow conflicts", exit 0. Its old conflict copy had already cleared against the updated repo, so there was nothing for =make reset= to do.
+
+Verified the pull is live through the symlinks rather than just present in the repo: =~/.local/bin/weather= resolves into the dotfiles tree and returns today's sun times on velox.
+
+Note for the record: =make conflicts common= is not a valid invocation — =check-de= rejects it, because common and the host tier are auto-included in the DE-scoped run. =make conflicts hyprland= is the whole check.
+*** 2026-07-14 Tue @ 00:51:51 -0500 Ratio calibre check passed; waypaper canonical decided (dark-lion)
+Ratio's ~/.config/calibre is a directory symlink into the dotfiles repo (stow folded the whole dir), so the first-launch gap never existed there — check closed. Craig decided dark-lion.jpg is the canonical waypaper wallpaper; the repo config.ini updated from the that-one-up-there.jpg placeholder (the file is skip-worktree volatile, unskipped for the commit and re-flagged). Remaining: when velox is back online, run make conflicts / make reset there so its old conflict copy clears against the updated repo.
+*** 2026-07-02 Thu @ 17:30:00 -0400 Shipped the Makefile hardening + first-launch guard (dotfiles 42a82d2)
+The solo-able subset landed in the speedrun. =make conflicts <de>= is the loud first-launch guard: dry-runs all tiers, parses all four stow error shapes (plain file conflict, foreign symlink, dir-over-file, and restow's unstow_contents non-directory ERROR), lists each blocker with a directory/foreign-symlink marker, exits 1 when any exist. =make reset= now pre-clears the directory and foreign-symlink blockers =--adopt= aborts atomically on (removals printed; repo version wins per the target's contract), then adopts + git-checkouts as before. =make restow='s overwrite path switched rm -f → rm -rf so directory conflicts clear. 8 sandbox tests drive the real Makefile against a throwaway HOME (44 suites green). Also verified on velox: the whereami and mpd-playlists conflicts noted in this task were already hand-converted 2026-06-29 — =make conflicts hyprland= reports clean live. REMAINING (deferred per Craig's speedrun pre-flight): the waypaper canonical decision (live velox dark-lion.jpg vs repo that-one-up-there.jpg) and the ratio calibre-symlink check (ratio paused).
+From the velox calibre incident (2026-06-27, note in ~/.dotfiles/inbox/processed/): calibre was launched before =make stow= ran, wrote its own default config into =~/.config/calibre/=, and silently blocked its own stow — it ran on factory defaults while the rest of common/ stowed fine. General pattern: any GUI app that auto-creates config on first run, launched before stow, blocks its own stow the same way. Velox was repaired by hand (=ln -srf= symlinks byte-identical to =stow --no-folding= output).
+
+Remaining work (re-graded C 2026-07-02 — the first-launch risk and the Makefile handling shipped in the speedrun; what's left is a paused-machine check):
+- Waypaper canonical decision (Craig): RESOLVED 2026-07-14 — dark-lion.jpg is canonical (dotfiles fea3e93), repo config.ini updated off the that-one-up-there.jpg placeholder.
+- Ratio check: RESOLVED 2026-07-14 — ratio's =~/.config/calibre= is a directory symlink into the repo (stow folded the dir), so the first-launch gap never existed there.
+- When velox is back online: run =make conflicts= / =make reset= there so its old conflict copy clears against the updated repo. (velox carries a separate boot-recovery task; check once it's reachable.)
+** DONE [#A] Hyprlock lockout: AMD-iGPU DPMS invalidates the lock, session wedges :bug:hyprland:installer:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Fixed as archsetup =a9391c9= + dotfiles =3046c9c=, both pushed; applied live to ratio and velox. Reboot ratio to activate the root fix (=amdgpu.runpm=0=); the watchdog covers until then.
+
+WHAT HAPPENED. Ratio's screen idle-locked, then wedged: hyprlock gone, the compositor still holding the ext-session-lock, no password prompt, recoverable only from a console. Recovered live with =hyprctl dispatch exec hyprlock= (=allow_session_lock_restore=true= was already set, so a replacement client adopted the dead lock).
+
+ROOT CAUSE (evidence, not the first guess). My first read was "hyprlock crashed on its screenshot buffer" — WRONG. Coredumps are captured here (two telega SIGSEGVs the same afternoon) and there is NO hyprlock coredump, so it did not segfault; memory was fine, so not OOM. The hyprland log shows the real chain: =Modesetting DP-4= / =Restoring crtc 86= (a display modeset) → =color management protocol is enabled and outputs changed= → =SessionLock.cpp:50 SessionLockSurface object remains but surface is being destroyed=. A display power cycle tore down the lock surface. Online research confirms it's a documented AMD-integrated-Radeon issue (hyprlock#953, Hyprland#5822): the GPU resources the lock client holds become invalid when the display powers down and back up. Ratio is a Strix Halo Radeon 8060S — exactly that hardware, and its cmdline already carried =amdgpu.dcdebugmask=0x10= + =no_vpe_idle_pg=1= display workarounds, a history of the same fragility.
+
+THE FIX, four layers, research-validated:
+1. Root cause: =amdgpu.runpm=0= on the kernel cmdline (AMD only, added in =update_grub_cmdline= behind =detect_gpu_vendors=). Keeps GPU runtime PM from invalidating the resources on a display cycle. Live in ratio's grub.cfg; effective next boot.
+2. Separate crash cause: =configure_hyprlock_pam= writes a complete =/etc/pam.d/hyprlock= (auth/account/session). The package default is =auth include login= only, so pam_end() crashes on uninitialised handles. Applied live to both machines.
+3. Recovery net: the =screen-lock= watchdog (dotfiles) relaunches hyprlock on a non-zero exit; hypridle's =lock_cmd= routes through it. Independently the same shape as the community's watchdog layer.
+4. NOT done, deliberately: the =dpms off= listener stays in the committed hypridle — =runpm=0= makes it safe on AMD, and it's wanted on Intel/velox for idle display-off. Ratio's test rail already removed it as a local choice.
+
+REVERTED a wrong turn: I'd first built a screenshot-to-file change (grim the desktop, point hyprlock at the file) on the theory the live screencopy buffer crashed. The research showed the cause is GPU runtime PM, not the background source, so I dropped it and reverted hyprlock.conf to =path = screenshot=.
+
+PROCESS NOTE — I hit the pathspec-commit trap AGAIN (the one the =Two agent sessions sharing one repo= VERIFY documents). After surgically staging only the =lock_cmd= line via =git update-index=, I ran =git commit <path> -m ...=, which commits the WORKING TREE of that path, not the index — so it committed ratio's test rail (dpms-off removed, timeout 450) with a message claiming dpms-off stays. Caught it before push, =git reset --soft=, re-verified. The rule: after =update-index=, commit with =git commit= (no pathspec), never =git commit <path>=.
+
+Tests: archsetup 372 (test_grub_cmdline AMD-runpm cases + test_hyprlock_pam, both call sites in CALL_SITES); dotfiles 3687 incl. tests/screen-lock. Each guard proven by deletion.
+
+Grading: Critical severity (full session lockout, console-only recovery) x rare edge case (needs an idle lock plus a display modeset on the AMD iGPU) = P2 = [#B by the matrix]. Raised to [#A] here because it stranded a live machine and the root fix needs a reboot to arm — worth Craig seeing at the top until he reboots ratio.
+** DONE [#B] Adversarial review of the sentry run — six fixes reworked :bug:test:tooling:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Craig asked for a skeptical review of every sentry change. Eight agents covered all 23 code commits, each told to disbelieve by default and to answer three questions per commit: does the problem exist and is it reachable, is the fix correct or is there a better one, would each test fail with the fix reverted. Every finding below was re-verified by hand before acting on it.
+
+SIX COMMITS NEEDED WORK, now fixed: archsetup =1207ca5= (wipedisk), =96e12b5= (firmware trim), =560e1dd= (autologin), =3c2155d= (initramfs tabs); dotfiles =ec7229b= (tunnel import), =a81aa0e= (thumbnail sweep), =56807e5= (three residual guards), =c90ee34= (event-log isolation). Both suites green: archsetup 341, dotfiles 3687 on both gates.
+
+THE ONE THAT MATTERED MOST. =wipedisk= ran =blkdiscard -f= BEFORE the busy check. =-f= disables the exclusive open util-linux has used since 2.36, so on the exact case the round-11 commit reasoned about — the user picked the wrong disk — it discarded a live filesystem and only then let sgdisk fail, printing "could not clear the partition table ... run this again". Data gone, user told nothing happened. The ordering predates the sentry commit, but round 11 wrote reasoning about the busy-disk case into the comment and error text while leaving the discard first, which made the misreport worse in the one direction that costs something. Dropping =-f= makes the kernel's own O_EXCL the gate.
+
+THREE PATTERNS WORTH MORE THAN THE INDIVIDUAL FIXES:
+
+1. CALL SITES WENT UNTESTED IN FIVE SUITES. Every helper had thorough tests; not one proved it was called. Deleting the call left everything green — including the guard on a =pacman -Rdd= of twelve firmware packages, whose removal would have run the trim on ratio. Closed with =CALL_SITES= in =test_orchestrators= (nine pairs, static) and a wiring assertion in the settings suite. Static on purpose: the behavioural harness runs un-stubbed bodies for real, which is fine for an orchestrator and not for a leaf that removes packages.
+
+2. A NEW OUTCOME VALUE NEEDS EVERY CONSUMER WALKED, EVERY TIME. Done for the portal enum in round 3, skipped for the tunnel-import one in round 4 — where =import_configs= folded a disarm failure into "none imported (N failed)", the opposite of what happened, in the multi-select flow the GUI actually uses.
+
+3. MY FIXTURES TWICE CLAIMED A FIDELITY THEY DID NOT HAVE. The wipedisk fixture used this machine's real disk names, so five of six tests passed with the seam removed. The mkplaylist fake does a full =cat > /dev/null= drain while its docstring says it "drains stdin exactly when the real one would" — which is what let the wrong failure mode survive.
+
+AND ONE FINDING WAS DISPROVED OUTRIGHT: round 1's =a57c443= claimed ffmpeg drains the read loop so only the first track is processed. Measured under strace and driven end to end with real ffmpeg (three runs of three, four 120s mp3s), the loop never truncates. The hazard is real and =-nostdin= is right; the symptom was reasoned from shellcheck SC2095 and never run. Corrected in =a30741a=, along with the OpenVPN autoconnect claim and the "four consumers" undercount.
+
+ALL THREE NOW CLOSED, in dotfiles =c7cb40d= (pushed). =_restore_dot='s =noop= split into =already-on= and =not-managed=, so the step stops claiming a restore that never happened. =_disable_dot= checks its restart as well as its move, since moving the drop-in aside does nothing until resolved reloads.
+
+The thumbnail one could not be built as described, and that is worth recording. The cache name is a SHA-1 of realpath plus mtime, so no filename says which source it came from; per-source sweeping would mean changing the key format and invalidating every cached thumbnail. Bounding the growth gets the same result for less: a deferred sweep now trims to a 500-file ceiling, oldest first, because eviction is safe exactly where sweeping is not (an evicted thumbnail is rebuilt on the next warm pass, costing one decode and never a file). WHEN A FIX CANNOT BE BUILT AS SPECIFIED, SAY SO AND SOLVE THE ACTUAL HAZARD — the hazard here was unbounded growth, not imprecise attribution.
+** DONE [#D] Repair tiers call an unverifiable service restart a failed one :bug:network:bluetooth:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Fixed as dotfiles =041d6b9= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). 7 new tests across =tests/bt/test_bt.py= and =tests/net/test_net.py=; dotfiles suite 3665 -> 3672, =make test= exit 0 on both gates. Each of the three guards proven a real gate by deleting it and watching the suite go red.
+
+Found in the 2026-07-24 sentry bug-hunt, round 14, on the cross-package =repair.py= diff that rounds 5-13 had left unspent.
+
+=cmd.service_active= is tri-state in both the net and bt packages, and its docstring says so outright: True, False, or None when systemctl itself can't answer (absent binary, or a timeout). Six callers. Three rule on it correctly — =bt/doctor._service_step= branches on None with "systemctl unavailable — can't check the service", and =net/diag= compares =is False= at both its call sites. Three tested it with plain truthiness:
+
+- =bt/repair.py= =repair_service_restart=
+- =net/repair.py= =_service_restart= (the nm-restart and resolved-restart tiers)
+- =net/repair.py= =repair_unmask_nm=
+
+So an unanswerable systemctl was reported as "bluetooth.service is still not active" / "NetworkManager still isn't running after a restart" — a statement about the service made on no evidence at all. Each then pointed the user at =journalctl -u <unit>=, which is the same systemd client stack that had just failed to answer. That last part is round 10's read again: an error message advertising a remedy it cannot honour.
+
+All three now report =warn= on None, with evidence naming the verification rather than the service, and a next action of checking systemd is reachable and re-running the doctor. Control flow is unchanged: =warn= was already a status both packages emit, both CLIs already exit non-zero on anything but =pass=, and =net/doctor= only inspects a repair step's status for the =dns-test= tier — every consumer was checked before the change, not after. (An adversarial re-review counted twelve, not four; all twelve handle =warn= correctly, so the conclusion held while the claim understated the work.)
+
+THE SEAM FOR THE TESTS, worth reusing: both suites already carry an exec-failure harness that plants a non-executable file on an emptied PATH, which is exactly what makes =cmd.run= return None. So the None case is reachable through the real code path with no mocking at all. Each test class asserts that premise first (=service_active= really is None in the sandbox) rather than assuming it.
+
+Grading: Minor severity (the claim is wrong but errs pessimistic — it says a repair failed when it may have worked, rather than falsely reassuring; nothing is damaged) x rare edge case = P4 = [#D]. Fixed rather than filed because the change is three branches and it completes a class — leaving two of three sites collapsed is the failure mode the round-6 =c2eb3e1= commit exists to remember.
+
+NOT PART OF THIS CLASS, checked and left alone: =settings/toggles.dim_state= is the only other genuine True/False/None helper in the tree, and both its callers pass the value through to the viewmodel rather than collapsing it. Every other "or None" in the packages is two-state (a value or nothing), where falsy handling is correct.
+** DONE [#B] Firmware trim gated on a DMI field that never carries the vendor :bug:tooling:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Fixed as archsetup =2e228f7= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). New =tests/installer-steps/test_framework_firmware_trim.py=, 12 tests carrying the real DMI strings off both daily drivers. Each of the three conditions proven load-bearing by deleting it and watching the suite go red, and the old gate proven wrong by restoring it (4 failures).
+
+Found in the 2026-07-24 sentry bug-hunt, round 13, reading archsetup's remaining state-mutating steps. =trim_firmware= gated on =grep -qi "framework" /sys/class/dmi/id/product_name= and no Framework machine has "framework" in =product_name= — it lives in =sys_vendor=. Read live: velox is =Framework= / ="Laptop (13th Gen Intel Core)"=, ratio is =Framework= / ="Desktop (AMD Ryzen AI Max 300 Series)"=. The gate returns false on both, so the step has been a silent no-op on the exact hardware it was written for. velox IS trimmed today (=linux-firmware-{atheros,intel,realtek,whence}= and nothing else) but not by this code path.
+
+THE REPAIR IS WHERE THE DANGER IS, which is why this is worth reading twice. Swapping =product_name= for =sys_vendor= is the obvious one-word fix and it is wrong: ratio is a Framework Desktop, and =trim_firmware= runs =pacman -Rdd linux-firmware-amdgpu=, which takes the firmware its Ryzen AI Max iGPU needs to bring up a display. Today only the =grep -qi intel /proc/cpuinfo= second gate stands between ratio and that. So =is_framework_intel_laptop= wants three DMI facts — vendor Framework, and a model naming both Laptop and Intel — and the cpuinfo read stays as an independent second gate rather than the only one.
+
+Verified live after the change: velox TRIM=yes, ratio TRIM=no, where the old gate said no to both.
+
+Grading: Minor severity (the trim never happens; nothing breaks, the machine just carries ~550MB it was meant to shed) x every user, every time (every Framework Intel install, which is the whole population the step targets) = P2 = [#B]. The AMD-firmware removal is not graded separately because it never shipped — it is the hazard the fix is shaped to avoid.
+** DONE [#B] Fresh install leaves the dotfiles repo permanently dirty :bug:tooling:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Fixed as archsetup =c3b3617= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). New =tests/installer-steps/test_mark_volatile_configs.py=, 8 tests against a fixture git repo with =sudo= stubbed on PATH. Every guard proven a real gate by deletion. A note went to =~/.dotfiles/inbox/= because =skip-volatile= now has an outside caller.
+
+Found in the 2026-07-24 sentry bug-hunt, round 13, diffing archsetup's =stow_dotfiles= against the dotfiles Makefile's =stow= target — two implementations of one operation, which is round 5's read applied across repos rather than across packages.
+
+The Makefile's =stow= target ends with =$(MAKE) skip-volatile=, setting git's skip-worktree bit on the four configs their apps rewrite in place (=btop=, =qalculate=, =calibre=, =waypaper=; the list is =volatile-configs=). archsetup stows inline with raw =stow= calls and never ran that step. So a machine archsetup installed goes dirty the first time one of those apps writes its config, and every later =git pull --ff-only= trips over paths the user never edited. Confirmed by grep: archsetup contains no =skip-volatile=, no =volatile=, and no =make stow= — yet both daily drivers carry the bits, so they came from a hand-run =make stow=, not the installer. ratio in fact carries seven, three more than =volatile-configs= lists, which is evidence the churn is real and ongoing.
+
+The fix calls the dotfiles target rather than copying its logic, so the volatile list stays single-source. Two details that are load-bearing: it runs *after* =git restore .= so the bit lands on a pristine tree, and it runs as the user, because root writing =.git/index= leaves it root-owned and the user's next git command then cannot update the index at all. A checkout with no Makefile is a quiet no-op — nothing to delegate to is not an error.
+
+DELIBERATELY NOT DONE: replacing the whole inline stow with =make -C "$dotfiles_dir" stow "$desktop_env"=. The Makefile stows =--target=$(HOME)=, which during an install is root's home, and it carries interactive conflict handling; archsetup stows =--target=/home/$username --adopt= as root on purpose. =skip-volatile= is the one target with no such coupling — it works on the repo through =git -C= and never reads HOME.
+
+Grading: Minor severity (a repo that reads dirty forever and pulls that need a stash; the workaround is one command) x every user, every time (every fresh install that stows dotfiles) = P2 = [#B].
+** DONE [#C] Unattended install blocks on an interactive prompt :bug:tooling:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Fixed as archsetup =cbcb53f= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). New =tests/installer-steps/test_configure_autologin.py= (11) and =tests/installer-steps/test_select_locale.py= (11). Every guard proven a real gate by breaking it and watching the suite go red: dropping the autologin unattended branch fails 1 (on the leftover-stdin assertion, which is the real gate — the drop-in still gets written because the read swallows the sentinel and treats it as "yes"); dropping the locale unattended branch fails 1; breaking either precedence rule fails 2.
+
+Found in the 2026-07-24 sentry bug-hunt, round 12, continuing through archsetup's own installer. Two members of one class, which is the point: round 10 fixed the third member and left these.
+
+THE CLASS: an advisory prompt — one that carries its own default — still reading stdin under =--config-file=, the documented unattended mode. Round 10 ruled on it for =nvidia_preflight='s rc-10 prompt. Two sites never got the ruling.
+
+1. =configure_autologin=. When =enable_autologin= is unset (=AUTOLOGIN= is optional, and =archsetup.conf.example= line 31 ships it commented out) and the root is encrypted, it prompted =Enable automatic console login for $username? [Y/n]= on a bare =read=. It runs from =configure_encrypted_autologin=, inside =boot_ux=, the last entry in =STEPS= — so an unattended install of an encrypted machine works for 40-60 minutes and then sits at a prompt nobody is watching. Under =curl | bash= it is worse: stdin is the script itself, so the read eats a line of source.
+
+2. =select_locale= (extracted from =preflight_checks= by this commit). The =Choice [1]:= menu fired whenever =/etc/locale.conf= carried no =LANG== and =LOCALE= was unset — also commented out in the example config. archsetup does not require an archangel install, and =configure_build_environment='s own "no LANG=" branch is proof it expects that state.
+
+Both now take the prompt's own default under =--config-file= and print an =[OK] ... (unattended, --config-file)= line saying so. An explicit =AUTOLOGIN=yes/no= or =LOCALE== still wins; the default only answers a question nobody can.
+
+WHAT MADE THEM TESTABLE, which is round 10's read (d) applied again: =configure_autologin= hardcoded =/etc/systemd/system/getty@tty1.service.d= and =select_locale= hardcoded =/etc/locale.conf=, so neither could run against a fixture — while their siblings =replace_sudoers_pacnew= and =ensure_nvme_early_module= both take a defaulted path argument for exactly that reason. Both now do. Zero shellcheck delta against HEAD; =make test-unit= 276 -> 298, exit 0.
+
+Grading: Major severity (unattended installation, a documented feature, does not complete; recoverable by pressing a key, no data loss) x some users, sometimes (needs unattended mode plus an omitted key) = P3 = [#C].
+
+THE PROMPTS DELIBERATELY LEFT ALONE, because the class is "prompts with a default", not "all prompts": username (line 636) and password (648/650) have no default to take — there is no sane fallback for either, and =archsetup.conf.example= documents both as "If not set, you will be prompted". They also fire in =preflight_checks=, in the first second of the run, where a blocked prompt is visible rather than silent. The "Enter locale" sub-prompt is reachable only from menu choice 9, which unattended never picks.
+** DONE [#C] wipedisk says "Disk erased." when it erased nothing :bug:tooling:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Fixed as archsetup =HEAD= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). New =tests/wipedisk/test_wipedisk.py=, 6 tests running the real script against a fixture device directory with fake blkdiscard/sgdisk on PATH. All four guards proven real by deleting each and watching the suite go red.
+
+Found in the 2026-07-24 sentry bug-hunt, round 11, reading =scripts/= — 30 lines, no tests, and the most destructive script in the repo. Not installed by the installer; it is run by hand from the checkout, which is why the frequency axis stays low.
+
+Three defects, all of which make the script's final word untrue:
+
+1. =sgdisk --zap-all= had its result discarded, and "Disk erased." printed unconditionally. sgdisk refuses a busy device — a mounted filesystem or a live md/LVM/ZFS holder — which is exactly what a user hits after picking the wrong disk. So the tool announced an erase it had not performed and exited 0.
+
+2. "Disk erased." overstates what the tool does even on success. =sgdisk --zap-all= destroys partition tables, not data, and =blkdiscard -f ... || true= deliberately tolerates a device that cannot discard. On a disk without discard support the script cleared the partition table and left every byte readable, while telling the user the disk was erased. That is the one path where the wrong belief has a privacy consequence — someone trusting the message before disposing of a drive.
+
+3. The prompt says "Select the disk id to use" and then listed every entry in =/dev/disk/by-id=. On this machine that is 18 entries of which 12 are =-partN= partitions (verified by listing it). The menu promised disks and offered partitions.
+
+Fix: whole disks only (globbed rather than =ls | grep=, so a name with whitespace cannot split into two menu entries); the zap's result is checked and a failure exits 1 naming the busy-device cause; the closing message reports what actually happened, and when discard was unsupported it says the data is still recoverable and points at =nvme format= / =hdparm= for a disposal-grade wipe.
+
+Grading: Major severity (the tool reports an outcome it did not achieve; in the disposal case that is a data-exposure consequence) × rare edge case (a hand-run helper the installer does not install, and defect 1 additionally needs sgdisk to fail) = P3 = [#C].
+
+Worth recording about the tests rather than the code: two of the six passed against the unmodified script for the wrong reason. Without the =WIPEDISK_BY_ID= override the script read the real =/dev/disk/by-id=, so the harness was driving a menu of this machine's actual disks (harmless — the fake blkdiscard/sgdisk shadowed the real ones on PATH — but it was not testing the fixture). And =test_empty_by_id_directory= was not a gate at first: with the guard deleted the empty select menu still falls through to the confirm prompt, reads EOF and declines, so exit code and call log alone pass either way. It now asserts the message.
+** DONE [#B] zfs-replicate reports success when every backup failed :bug:backup:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Fixed as archsetup =HEAD= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). Diagnostics moved to stderr; the loop counts failures and exits 1 when any dataset failed. New =tests/zfs-replicate/test_zfs_replicate.py=, 9 tests driving the real script with a fake syncoid and a fake ping on PATH (the =tests/zfs-pre-snapshot/fake-zfs= pattern). Both fixes proven real gates by reverting them: dropping the counter fails 3, putting =error()= back on stdout fails 1.
+
+Found in the 2026-07-24 sentry bug-hunt, round 11, reading =scripts/= — 73 lines with no test file, installed by =configure_zfs_snapshots= as =/usr/local/bin/zfs-replicate= and run by =zfs-replicate.service=, a =Type=oneshot= on a nightly timer. Its exit code and its journal output are the only signals anyone ever sees.
+
+Two defects, both verified by running the script rather than argued:
+
+1. The full-replication loop caught each =syncoid= failure, warned, carried on, then printed "Replication complete." and exited 0 regardless. Driven with a fake syncoid failing all four datasets: four =[WARN] Failed= lines, then "Replication complete.", exit code 0. systemd records =Result=success=. A backup that has not run for months is indistinguishable from a working one — and the whole point of the tool is to have a copy when the primary is gone.
+
+2. =determine_host= runs inside a command substitution (=TRUENAS_HOST=$(determine_host)=) and its =error()= wrote to stdout. On an unreachable TrueNAS the message was captured into =TRUENAS_HOST= and discarded, and =set -e= then killed the script. Driven with both hosts unreachable: exit 1 and completely empty output. A nightly service failing with nothing in the journal to say why.
+
+Same class as three bugs already fixed this session — =_restore_dot= claiming "DNS-over-TLS restored" without checking, =portal_restore_watch= discarding its outcome, =import_config= returning ok on an unchecked modify. A mutating operation that reports a success it did not get.
+
+Grading: Critical severity (a backup system that reports success while backing nothing up; the failure surfaces only when the backup is needed — graded on the harm once in the failure state, not on how rarely it is entered) × rare edge case (needs a ZFS root, a reachable TrueNAS, and the user enabling the timer by hand — archsetup deliberately does not enable it, and =findmnt -n -o FSTYPE /= on this machine says btrfs, so it is latent here) = P2 = [#B].
+
+Left alone: =BACKUP_PATH="backups" # TODO: Configure actual path= is still an unresolved TODO in the destination, and single-dataset mode relies on =set -e= to propagate a syncoid failure rather than reporting it. Neither is a defect in the sense above; the TODO is Craig's call.
+** DONE [#D] Wireless regdom is silently unset for a three-letter-language locale :bug:installer:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Fixed as archsetup =249bb93=. =locale_country= matches the =_CC= group instead of counting characters, and =set_wireless_regdom= verifies the substitution landed rather than trusting sed's exit code. 16 tests.
+=configure_networking= derives the wireless regulatory domain by fixed offset: =wireless_region="${current_lang:3:2}"=, with a comment reading "extract country code (positions 3-4)". That is correct only for a two-letter language code.
+
+=validate_config= accepts =^[a-z]{2,3}(_[A-Z]{2})?...=, so a three-letter language is a legal =LOCALE=, and glibc ships 75 of them (=agr_PE=, =ast_ES=, =ber_DZ=, =ayc_PE=, ...). Verified by running the expansion: =ber_DZ.UTF-8= yields =_D=, =ayc_PE.UTF-8= yields =_P=, =C= yields the empty string, =POSIX= yields =IX=.
+
+The sed that follows only uncomments an existing =#WIRELESS_REGDOM="XX"= line in =/etc/conf.d/wireless-regdom= (176 of them, owned by wireless-regdb). A garbage region matches nothing, sed exits 0, and the =|| error_warn= never fires — so the regdom is never set and nothing says so. The task line does print the garbage region ("configuring wireless regulatory domain (_D)"), so it is visible in the log rather than fully silent.
+
+Confirmed the mechanism itself works for the normal case: line 168 of this machine's =/etc/conf.d/wireless-regdom= reads =WIRELESS_REGDOM="US"= uncommented, which is archsetup's own edit.
+
+Grading: Minor severity (WiFi falls back to the conservative "00" regdomain — fewer channels and lower tx power, but WiFi works) × rare edge case (one of 75 three-letter-language locales, or a =LOCALE= with no country) = P4 = [#D].
+
+Fix when it comes up: derive the country from the =_CC= group by pattern rather than by offset, and warn when it cannot be derived or when the sed changed nothing. Worth doing together with the sibling gap — nothing in the installer verifies that a =sed -i= uncomment actually matched, so a distro reshuffling one of these config files would fail the same silent way. All 22 =sed -i= sites share that stance, so it is a uniform design choice rather than an odd one out.
+** DONE [#B] Initramfs hook swap can leave a LUKS machine unbootable :bug:installer:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Fixed as archsetup =HEAD= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). The swap moved into =switch_udev_hook_to_systemd=, which declines when =hooks_need_busybox_init= sees a standalone =encrypt= token, and the caller now rebuilds the initramfs only when the conf actually changed. New =tests/installer-steps/test_switch_udev_hook.py=, 10 tests; both guards proven real by breaking them (removing the refusal: 4 failures; loosening the token match to a bare =encrypt= substring: 1 failure).
+
+Found in the 2026-07-24 sentry bug-hunt, round 10. =configure_initramfs_hook= ran =sed -i '/^HOOKS=/ s/\budev\b/systemd/'= on any non-ZFS root, then =mkinitcpio -P=. Its only guard was =is_zfs_root=.
+
+Why that breaks a LUKS machine, verified against the installed mkinitcpio rather than argued:
+- =/usr/lib/initcpio/install/systemd= line 70 is =add_symlink /init usr/lib/systemd/systemd=, so the systemd hook replaces the busybox init outright.
+- =/usr/lib/initcpio/hooks/encrypt= is an =#!/usr/bin/ash= script whose entire body is a =run_hook()= function — the busybox init's mechanism. Under systemd init nothing calls it.
+- =mkinitcpio= carries no conflict check for the pairing (grepped; nothing), so the rebuild succeeds and archsetup reports success.
+- This machine's own =/etc/mkinitcpio.conf= documents the two valid pairings as separate examples: =udev= + =encrypt= (line 45) and =systemd= + =sd-encrypt= (line 51). The sed converted half of the first pairing and produced neither.
+
+Effect: on a LUKS root using the standard busybox =encrypt= hook, archsetup rewrites HOOKS to =systemd= while leaving =encrypt= behind, rebuilds the initramfs, and exits cleanly. At the next boot the root is never unlocked. The machine needs live media and manual mkinitcpio surgery to recover.
+
+The sibling asymmetry: =is_encrypted_root()= already exists in this script and =configure_autologin= uses it to branch on exactly this condition. The initramfs step consulted neither it nor HOOKS. =merge_grub_cmdline='s own comment names =cryptdevice== as a boot-critical parameter to preserve — and =cryptdevice== is read only by the =encrypt= hook, so archsetup explicitly anticipates the configuration that another of its steps then breaks.
+
+Grading: Critical severity (the machine will not boot and recovery needs external media — graded on the harm once in the failure state, not on how rarely it is entered) × some users, sometimes (LUKS-encrypted non-ZFS root using the busybox =encrypt= hook; deterministic for those machines, absent everywhere else) = P2 = [#B].
+
+Deliberately not attempted: migrating =encrypt= to =sd-encrypt=. That means rewriting the kernel cmdline from =cryptdevice== to =rd.luks.name== against the volume's UUID, which is a real migration and not a mechanical edit. Refusing the cosmetic swap keeps a working machine working, which is the right trade against quieter fsck output.
+** DONE [#D] keymap and consolefont hooks are inert under the systemd initramfs :bug:installer:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Fixed as archsetup =249bb93=. The swap rewrites both to =sd-vconsole=, collapsing them into one entry and never duplicating an existing one. The open question is answered: this machine is KEYMAP=us with no encrypt hook, but the function runs on LUKS machines where a non-US layout at the passphrase prompt is exactly what sd-vconsole restores. 7 tests.
+Same class as the =encrypt= bug above, but cosmetic rather than boot-critical, so it was filed rather than bundled into that fix.
+
+Enumerating the busybox-only hooks on this machine (every hook under =/usr/lib/initcpio/hooks/= defining =run_hook=/=run_earlyhook=/=run_latehook=) gives: btrfs, consolefont, encrypt, grub-btrfs-overlayfs, keymap, memdisk, resume, sleep, udev, usr. All go inert once =/init= is systemd. Of those, =encrypt= is the only boot-critical one — =resume= is handled natively by systemd's hibernate-resume generator, and =btrfs= by udev rules (this machine runs =btrfs= alongside =systemd= and boots fine).
+
+=keymap= and =consolefont= are the live leftovers. Run =grep '^HOOKS=' /etc/mkinitcpio.conf= on this machine: the line carries =systemd= plus =keymap consolefont= and no =udev=, so archsetup's swap has already run here and both hooks are installed into the image and never executed. The systemd equivalent is the single =sd-vconsole= hook, which is what the distro's own systemd example on line 51 of =/etc/mkinitcpio.conf= uses.
+
+Effect: the early-boot console keeps the default font and keymap until =systemd-vconsole-setup= runs in the real root. =add_nvme_early_module= sets =FONT=ter-132n= in =/etc/vconsole.conf= expecting it to apply at that stage, so the configured font is briefly not what archsetup asked for.
+
+Grading: Cosmetic severity (a few seconds of default console font on a machine that boots normally) × some users, sometimes = P4 = [#D].
+
+Fix when it comes up: have =switch_udev_hook_to_systemd= also rewrite =keymap consolefont= to =sd-vconsole= when it performs the swap, and add the fixture cases to =tests/installer-steps/test_switch_udev_hook.py=. Worth confirming first whether a non-US keymap is ever needed at the initramfs prompt on a machine that reaches this path.
+** DONE [#B] NVIDIA Wayland preflight blocks dwm and headless installs :bug:installer:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Fixed as archsetup =HEAD= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). The NVIDIA block moved out of =preflight_checks= into a new =nvidia_preflight= function that returns early unless =desktop_env= is =hyprland= and archsetup is the one installing drivers. New =tests/nvidia-preflight/test_nvidia_preflight_gate.py=, 11 tests; each of the three guards was proven a real gate by deleting it and watching the suite go red (3, 1, and 1 failures respectively).
+
+Found in the 2026-07-24 sentry bug-hunt, round 10, reading archsetup's own installer. =preflight_checks= called =nvidia_preflight_report= unconditionally and exited 1 on rc 11 (repo driver below the 535 Wayland floor, or =pacman -Si nvidia-utils= unable to answer). The check is Wayland-specific — every line it prints names Wayland/Hyprland — but it ran before any =desktop_env= branch and consulted neither =desktop_env= nor =skip_gpu_drivers=.
+
+Effect, proven empirically rather than argued (three scenarios driven against the extracted block): =DESKTOP_ENV=dwm= plus =--no-gpu-drivers= on an NVIDIA machine with an old repo driver aborts the install; so does =DESKTOP_ENV=none=. Neither install ever runs a compositor, and =--no-gpu-drivers= means the user installs the driver themselves. Worse, the abort's own fix hint reads "install with DESKTOP_ENV=dwm (X11) instead" — the one remedy it prints is the one it refuses to honor, so the user has no working workaround short of editing the script.
+
+The sibling asymmetry that makes it an oversight rather than a decision: =install_gpu_drivers= returns early on =skip_gpu_drivers=, and =display_server= / =window_manager= both branch on =desktop_env= with a =none= arm that skips outright. The preflight gate applied neither ruling.
+
+Second defect at the same site, fixed in the same commit: the rc-10 path (card detected, driver fine) prompts with a bare =read=. =--config-file= is documented as "unattended installation", and =aur_install= already rules that a prompt not covered by =--noconfirm= "blocks forever waiting for input" on a headless install. The rc-10 prompt is advisory, so it now answers itself with its own =[Y/n]= default when a config file was supplied. rc 11 stays a hard stop either way.
+
+Grading: Critical severity (archsetup cannot be run at all on that machine, and the printed workaround does not work — graded on the harm once in the failure state, not on how rarely it is entered) × rare edge case (needs an NVIDIA card, a repo driver below the floor or an unsynced pacman db, and a non-hyprland =desktop_env=; hyprland is the default and Craig's own machines are AMD and Intel) = P2 = [#B].
+
+Noted, not fixed: =display_server= and =window_manager= both point their unknown-value hint at a =--desktop-env= flag that the argument parser does not implement. Both arms are unreachable today (=validate_config= rejects a bad =DESKTOP_ENV=, and without a config file the value is always the default), so it is a stale string rather than a live defect.
+** DONE [#B] mkplaylist retags only the first file :bug:music:quick:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Fixed as dotfiles =a57c443= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). =ffmpeg -nostdin= on the conversion call. New =tests/mkplaylist= suite, 12 tests; removing the flag turns the suite red (verified by reverting: 5 failures, green on restore). NOTE: the fake ffmpeg does a full =cat > /dev/null= drain, which the real one does not do — so the suite gates the flag's presence, not the production failure mode. The docstring claiming the fake "drains stdin exactly when the real one would" is false and should be corrected.
+Found in the 2026-07-24 sentry bug-hunt (shellcheck SC2095). =common/.local/bin/mkplaylist=: =generate_music_m3u= pipes the file list into =tag_music_file= (line 130), which consumes it with =while IFS= read -r file=. Inside that loop, =ffmpeg -i "$file" -vn -c:a flac "$outputfile"= (line 46) reads stdin by default for its interactive keyboard controls, so it consumes bytes the loop is relying on.
+
+CORRECTION (2026-07-24, from an adversarial re-review): the failure mode stated above — "the loop sees EOF and exits after the first file" — is WRONG, and this task originally asserted it. Measured under strace, ffmpeg polls fd 0 and reads roughly one byte per half-second of transcode wall time; flac encoding runs about 2000x realtime, so a ten-minute mp3 converts in ~0.28s and yields zero or one stolen byte, never a drain. Driven end to end with real ffmpeg against four 120s mp3s, three runs of three: all four were converted and retagged every time. The loop never truncated.
+
+What is real is the hazard, not the observed symptom: one stolen byte mangles a path, which makes mid3v2/metaflac fail and =set -e= abort the run loudly. =-nostdin= is still the right fix and the commit still stands. The original finding came from shellcheck SC2095 plus reasoning, and was never run — which is exactly what "verify before filing" exists to prevent.
+
+Effect: on a directory of non-flac audio, only the first file is converted and retagged. Files 2..N are silently skipped — no error, no output, and the playlist itself still generates (a separate =find=), so nothing signals that the retagging stopped.
+
+Grading: Major severity (the retagging feature is broken past the first file, and it fails silently) × most users frequently (the script exists to batch-process a directory, so more than one non-flac file is the normal case) = P2 = [#B].
+
+Fix: =ffmpeg -nostdin= (or =< /dev/null= on the call). Verifiable with a fake =ffmpeg= on PATH asserting it is invoked once per input file.
+** DONE [#C] timezone-change prints command-not-found instead of its help :bug:tooling:quick:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Fixed as dotfiles =15d2b63= (committed locally, deliberately NOT pushed — held for Craig's morning review), together with the Portugal-zone defect below. New =tests/timezone-change= suite, 12 tests.
+Found in the 2026-07-24 sentry bug-hunt (shellcheck SC2288). =common/.local/bin/timezone-change=, default =*)= case (lines 63-67): =echo= sits alone on its own line, so the following quoted string runs as a *command* rather than as its argument.
+
+#+begin_src sh
+*)
+ echo
+ "Invalid option chosen."
+ echo
+ "Some valid options are: eastern, central, pacific, rome, london, st_lucia, italy, france, spain ."
+ ;;
+#+end_src
+
+The user gets two blank lines and two =command not found= errors; the list of valid options never prints. The timezone is correctly left unchanged, so this is an output defect only.
+
+Grading: Minor severity (wrong output on an error path, nothing corrupted) × some users sometimes (only on an unrecognized option) = P3 = [#C].
+
+Fix: fold each string into its =echo=. Verifiable by running the script with a bogus argument and asserting the option list appears on stdout.
+** DONE [#C] Thumbnail sweep wipes the whole cache when a wallpaper source is unreadable :bug:settings:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Fixed as dotfiles =0bd8c67= (committed locally, deliberately NOT pushed — held for Craig's morning review). 8 new tests.
+Found in the 2026-07-24 sentry bug-hunt, reviewing the orphan sweep shipped the night before (dotfiles =e752a16=). =os.walk= stays silent about a directory it cannot enter, so =wallpaper.scan_sources= returns =[]= for a source that is missing, renamed, or permission-denied — the same answer it gives for a gallery the user emptied on purpose. =settings/cli.py= tick then hands that empty list to =thumbstore.sweep_orphans=, =live_names= comes back empty, and every cache-shaped file is classified an orphan.
+
+Proven empirically rather than reasoned: seeding three well-formed thumbnails plus a stray README, then sweeping against a nonexistent source directory, deleted all three (the README survived, so the cache-name regex guard works — it just doesn't help here).
+
+Effect once entered: the entire persistent thumbnail cache is deleted, so the next wallpaper-view open pays the cold-decode cost the cache was built to remove (measured at 3.7s for a viewport of Craig's largest 8, which is what tripped the compositor's kill prompt), and the tick needs roughly ten idle beats — about twenty minutes — to rewarm at =WARM_PER_BEAT= 8.
+
+Grading: Major severity (grading the being-in-it, per the don't-double-count-rarity rule: the cache is gone, the original freeze returns, and recovery is unattended and slow) × rare edge case (both configured sources — =~/videos/wallpaper= and =~/pictures/wallpaper= — are local directories, so this needs one deleted, renamed, or made unreadable while a beat fires; a removable or network source would hit it routinely) = P3 = [#C].
+
+Fixed in this session: new =wallpaper.sources_available(sources)= tells "readable and empty" apart from "could not read", and =sweep_orphans= grew a =sources_ok= parameter that declines to sweep when it is False. Deferring a sweep costs only some stale files; sweeping wrongly costs the whole cache.
+** DONE [#C] timezone-change sets a nonexistent zone for Portugal :bug:tooling:quick:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Fixed as dotfiles =15d2b63= (committed locally, deliberately NOT pushed — held for Craig's morning review). =Europe/Lisbon=. The suite also pins the general invariant: every zone the script can emit must exist in tzdata, so a future bad entry fails at test time rather than in Craig's hands.
+Found in the 2026-07-24 sentry bug-hunt, validating every zone the script sets against =/usr/share/zoneinfo=. =common/.local/bin/timezone-change= line 39 maps =portugal= / =lisbon= to =Europe/Portugal=, which is not a tzdata identifier — the real one is =Europe/Lisbon= (a bare =Portugal= legacy alias also exists at the top level, but not under =Europe/=). =timedatectl set-timezone "Europe/Portugal"= fails, so the timezone is never changed.
+
+The other 17 zones the script sets all resolve correctly, so this is the single bad entry.
+
+Grading: Major severity (the option is wholly broken — the zone is not set and the command errors) × rare edge case (one option of eighteen, hit only when actually switching to Portugal) = P3 = [#C].
+
+Fix: =Europe/Lisbon=. Verifiable by asserting the argument handed to a fake =timedatectl=, plus a suite-wide check that every zone the script names exists in the tzdata database.
+** DONE [#C] settings-project stop() can SIGTERM an unrelated process :bug:settings:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Found in the 2026-07-24 sentry bug-hunt, round 2, reviewing =settings/src/settings/project.py=. =stop()= read a pid out of =$XDG_RUNTIME_DIR/settings-project.pid= and SIGTERMed it with no check that the pid still belonged to the projection. A projection that dies without running =stop()= (crash, OOM, a failed =execvpe= on the clock path — that last one was already noted as tolerated residue) leaves the file behind, so once the kernel wraps its pid counter that pid can name something else entirely, and the next =start= or =stop= kills it.
+
+This is a hazard the codebase had already ruled on elsewhere and simply hadn't applied here: =maint/src/maint/doctor.py= revalidates =/proc/<pid>/comm= against the expected name before its KILL remedy fires, explicitly to refuse recycled pids.
+
+Grading: Major severity (grading the being-in-it — an arbitrary user process takes a SIGTERM, and an editor with unsaved work is a plausible victim) × rare edge case (needs an unclean exit *and* pid reuse; =pid_max= here is 4194304, so wrap-around takes a very long time) = P3 = [#C].
+
+Fixed as dotfiles =722994e= (committed locally, deliberately NOT pushed — held for Craig's morning review). The pidfile now records the process start time from =/proc/<pid>/stat= next to the pid, and =stop()= fires only when the recorded value still matches the live process. Start time is the right token rather than =comm=: it is mode-independent (the clock channel execs into =python3=, so comm changes while comm-matching would have needed per-mode knowledge) and it is exec-stable, verified directly — pid and start time were identical either side of an =execvpe=. A recycled pid cannot reproduce it. Legacy bare-pid pidfiles keep the old unconditional behavior so the upgrade never strands a live projection.
+** DONE [#C] wtimer alarms fire an hour off on the eve of a DST change :bug:timer:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Found in the 2026-07-24 sentry bug-hunt, round 3, reading =timer/src/timer/engine.py=. =parse_alarm= resolves a bare wall-clock time ("07:00") to its next occurrence: it builds today's instant, and when that is already past it rolled forward with =epoch += 86400=. A DST day is 23 or 25 hours long, so a fixed 86400 lands on the wrong wall time whenever tomorrow crosses a transition.
+
+Reproduced against America/Chicago and the two 2026 US transitions. Asking for =07:00= at 08:00 on Sat 2026-03-07 (spring forward that Sunday) gave 08:00 Sunday — an hour late. Asking for =07:00= at 08:00 on Sat 2026-10-31 (fall back that Sunday) gave 06:00 Sunday — an hour early.
+
+The recurring path was never affected, which is what makes this an oversight rather than a design choice: =next_alarm= walks candidate days and rebuilds =datetime(y, m, d, hh, mm)= per day, so it is already DST-correct. Only the one-shot rollover took the shortcut. Both were pinned by the new tests.
+
+Grading: Major severity (grading the being-in-it — an alarm that fires an hour off has wholly failed at the one thing an alarm does, and the fall-back direction wakes you early while the spring-forward direction lets you oversleep) × rare edge case (two nights a year, and only when the requested wall time has already passed today) = P3 = [#C].
+
+Fixed as dotfiles =9b6c2c9= (committed locally, deliberately NOT pushed — held for Craig's morning review). The rollover now rebuilds the local time on tomorrow's calendar date, the same construction =next_alarm= uses. Eight tests pin =TZ=America/Chicago= (saved and restored around each case), covering both transitions, the twelve-hour form, an ordinary-day control, a DST eve where the requested time is still ahead, and two characterization cases asserting the recurring path stays DST-safe.
+** DONE [#B] net portal-restore claims encrypted DNS is back without checking :bug:net:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Found in the 2026-07-24 sentry bug-hunt, round 3, reading =net/src/net/repair.py=. A captive-portal login moves the DNS-over-TLS drop-in aside so plain DNS can reach the venue's login page, and =_restore_dot()= moves it back afterwards. It fired both privileged steps — the =mv= and the =systemctl restart systemd-resolved= — and returned ="restored"= without reading either result. =repair_portal_restore()= then rendered a pass step reading "DNS-over-TLS restored".
+
+So a declined or failed =sudo -n mv= left DNS-over-TLS off while the tool told the user it was back on. The same for a resolved restart that fails: the drop-in is on disk but the running resolver is still serving plain DNS.
+
+The asymmetry is what makes it an oversight rather than a decision. The sibling =_disable_dot()=, twenty lines up, checks its own move with =_ok()= and returns False rather than claiming a success it did not get. The restore half simply never got the same treatment, and it is the half where the failure is silent — the disable path's failure is visible immediately because the portal page won't load.
+
+Grading: graded on severity alone under the privacy carve-out. DNS queries continue in cleartext to the venue resolver on an untrusted network, and the affirmative "restored" message is what removes the user's reason to check. Bounded by =net diagnose='s =encrypted-dns= step, which exists precisely to catch a portal run that never restored, so the exposure ends at the next diagnose rather than persisting unseen forever. Major severity = P2 = [#B].
+
+Fixed as dotfiles =018c0c5= (committed locally, deliberately NOT pushed — held for Craig's morning review). Both privileged steps are now checked, with two new outcomes: ="failed"= when the move back fails (encrypted DNS still off, rendered as a fail step) and ="unapplied"= when the drop-in is back but resolved would not restart (rendered as a warn step). Each names the command to run by hand. Four tests cover both failures at the =_restore_dot()= and step levels, mirroring the existing declined-move test on the disable side.
+** DONE [#B] the portal restore watcher fails silently, so DNS stays in the clear :bug:net:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Found in the 2026-07-24 sentry bug-hunt, round 4, reading the rest of =net/src/net/repair.py= after the round-3 fix above. =portal_restore_watch()= polls until the link comes back online, calls =_restore_dot()=, and discards the outcome entirely.
+
+Three things compound into a silent failure. The watcher is spawned detached with =stdin=, =stdout=, and =stderr= all on =/dev/null=, so nothing it could print reaches anyone. It runs outside the =repair()= dispatch, so unlike every other mutating tier it never wrote an event-log line either. And =repair_portal_login= tells the user "encrypted DNS restores itself once you're online", which is precisely what removes their reason to check. A ="failed"=, ="unapplied"=, or ="ambiguous"= restore therefore left the machine on plain DNS on a venue network with no signal at any level.
+
+This is the round-3 finding one layer out, and the asymmetry is the tell: =018c0c5= taught =repair_portal_restore()= — the *manual fallback* — to stop claiming a success it did not get, while the *automatic* path, the one that actually runs in the normal flow, kept dropping the same result on the floor. Fixing the fallback and leaving the primary silent is a worse split than the original bug.
+
+Grading: graded on severity alone under the privacy carve-out, exactly as the round-3 sibling. Same exposure (cleartext DNS to an untrusted venue resolver), same bound (=net diagnose='s =encrypted-dns= step catches the stranded state), and the same affirmative promise removing the reason to look. Major severity = P2 = [#B].
+
+Fixed as dotfiles =601c5b4= (committed locally, deliberately NOT pushed — held for Craig's morning review). The watcher now returns the outcome, appends a =portal-restore-watch= event with it, and fires a persistent =notify security= alert on each of the three failing outcomes, each naming the command to run by hand. A clean restore stays silent. Five tests: one per failing outcome, one pinning the silence on a clean restore, and one on the event-log line. The whole =TestPortalLogin= class now shadows =notify= with a logging fake, so no future watcher test can fire a real desktop notification mid-suite.
+** DONE [#D] dns-override failure path says "reverted" without checking :bug:net:quick:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Fixed as dotfiles =2cf3fb3=. The revert is checked; a declined one now says 1.1.1.1 is still set and names =resolvectl revert <iface>=.
+Found in the 2026-07-24 sentry bug-hunt, round 3, sweeping for siblings of the portal-restore finding above. =net/src/net/repair.py=, =repair_dns_override()= failure path: when the 1.1.1.1 override doesn't restore resolution, it calls =priv.run("dns-revert", iface)=, discards the result, and returns evidence reading "override didn't restore resolution — reverted". A failed revert leaves 1.1.1.1 set on the link while the step says it was removed.
+
+Same defect class as the portal-restore bug, three hundred lines up in the same file, and it survived the sweep only because the consequence is much smaller. Every other mutating repair in this file verifies by re-measuring afterwards rather than by reading an exit code, which is the stronger pattern and is why the sweep otherwise came back dry.
+
+Grading: Minor severity (a stale per-link override sends DNS to Cloudflare instead of the venue resolver, it dies on the next reconnect, and =net diagnose='s =dns-override-present= step exists specifically to catch it) × rare edge case (needs the override to fail *and* the revert to fail) = P4 = [#D].
+
+Fix: the same idiom the portal-restore fix now uses. Wrap the revert in =_ok()= and drop the "— reverted" claim (or say the revert failed and name =resolvectl revert <iface>=) when it returns False. The existing =RepairHarness= makes the privileged call fail with =NET_SUDO="false"=, so the test is a near-copy of =test_restore_reports_failure_when_the_move_back_is_declined=.
+** DONE [#B] a timezone-less Date header crashes the whole net diagnose run :bug:net:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Found in the 2026-07-24 sentry bug-hunt, round 4, reading =net/src/net/diag.py=. =_clock_skew_s()= fetches the probe server's =Date= header with =curl -sI=, parses it with =parsedate_to_datetime=, and subtracts it from a timezone-aware =datetime.now(timezone.utc)=. RFC 5322 allows a =Date= to carry =-0000=, which means UTC while explicitly claiming no local zone, and a =Date= with no zone at all parses leniently as well. Both come back *naive*, and subtracting a naive datetime from an aware one raises =TypeError=.
+
+The =try= wraps only the =parsedate_to_datetime= call, so the =TypeError= from the line below it is uncaught. It escapes =_clock_skew_s=, escapes =_steps_egress_edges=, and takes down the entire =diagnose()= run — no report, no steps, a Python traceback. =net doctor= runs diagnose first, so the panel's doctor button dies with it.
+
+Verified against Python 3.14.6 before writing the fix: =parsedate_to_datetime("Thu, 01 Jan 2020 00:00:00 -0000")= returns =tzinfo=None=, and the subtraction raises. The zoneless form behaves the same. Only the =GMT= form (which the well-behaved probe host sends) comes back aware, which is why this never showed up in normal use.
+
+What makes it more than a curiosity is *when* the code runs. =_steps_egress_edges= fires only after the http-probe has already failed, so the server answering that =HEAD= is frequently a captive portal's interception appliance rather than the real probe host — and a minimal embedded HTTP stack is exactly the kind that emits a non-GMT =Date=. The one path guaranteed to be talking to a non-standard server is the one that can't survive a non-standard header.
+
+Grading: Major severity (grading the being-in-it — the diagnostic tool produces no report at all, and =net doctor= goes with it, on precisely the broken network it exists to diagnose) × rare edge case (needs a failing probe *and* a portal appliance that omits a numeric offset) = P2 = [#B].
+
+Fixed as dotfiles =8933500= (committed locally, deliberately NOT pushed — held for Craig's morning review). A naive parse is now read as UTC, which is what =-0000= means. Two tests, and the second is the one that matters: it drives a *current* =-0000= timestamp and asserts no clock row, so a lazy "catch =TypeError= and return None" fix would fail it while the correct reading passes.
+** DONE [#C] a tunnel import that can't be disarmed still reports success :bug:net:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Found in the 2026-07-24 sentry bug-hunt, round 4, reading =net/src/net/manage.py=. =import_config()= imports a WireGuard or OpenVPN config as an NM profile, then fires =nmcli connection modify <uuid> connection.id <name> connection.autoconnect no= — and discarded the result, returning =ok=True= regardless.
+
+That modify is the whole safety of the feature, and the module's own docstring says so: =nmcli connection import= *auto-activates* the profile it creates, "which nobody asked for by picking a file", so "every import here ends with the profile deactivated and autoconnect off". A failed modify inverts that. For WireGuard — a device-type connection — autoconnect stays on, so the tunnel re-arms itself at the next boot and takes the default route with it, and the profile keeps the transient staged interface name (=wgpvpn=) while the envelope reports the config's real name, so the panel names a profile that isn't there.
+
+CORRECTION (2026-07-24, from an adversarial re-review): the blanket claim originally written here — that a failed disarm re-arms the tunnel at boot — is wrong for OpenVPN. =man 5 nm-settings-nmcli= states autoconnect is not implemented for VPN profiles, and an OpenVPN import is an NM VPN profile, so the modify is near-cosmetic on that half. The bug is real and security-relevant for WireGuard, which is the primary case; the severity as stated overreached to cover both.
+
+The asymmetry, again the tell: =_nmcli_import()=, twenty lines up in the same file, checks its own =returncode= and raises rather than return a UUID it did not get. The modify below it never got the same treatment.
+
+Grading: Major severity (grading the being-in-it — a full-tunnel VPN the user never asked to connect arms on every boot and carries all their egress, it persists across reboots rather than self-healing, and the affirmative "imported X" is what removes the reason to check) × rare edge case (needs the modify to fail after the import succeeded) = P3 = [#C].
+
+Fixed as dotfiles =e0d4d8a= (committed locally, deliberately NOT pushed — held for Craig's morning review). New =_disarm()= returns whether the modify took. On failure the profile is still deactivated first — the import already brought it up, and the verdict shouldn't decide whether it keeps running — and then a =disarm-failed= envelope names the UUID and the exact command to finish the job. Three tests: the failing verdict, =import_configs= counting it as failed rather than imported, and a characterization test pinning that the deactivate still runs on the failure path.
+** DONE [#C] a binary that can't be exec'd crashes the panels instead of degrading :bug:net:bluetooth:audio:maint:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Found in the 2026-07-24 sentry bug-hunt, round 5, comparing the four panel packages' subprocess wrappers against each other.
+
+Every wrapper in the panels states the same contract: an unusable tool becomes a degraded result, never an exception. =cmd.run= returns None; =nmcli.run=, =btctl.run= and =pactl.run= raise their own domain error, which every caller already guards on; =speedtest.run_speedtest= returns an error envelope. All of them caught only =FileNotFoundError=, so they kept the contract for a tool that is *absent* and broke it for a tool that is *present but unusable*.
+
+Verified against Python 3.14.6 rather than argued. =subprocess.run= raises =PermissionError= for a file without its execute bit, =OSError= (ENOEXEC, "Exec format error") for an executable file that is neither a binary nor a script with a shebang, =NotADirectoryError= when a path component is a plain file, and =OSError= when a fork is refused under memory or PID pressure. None of the four is =FileNotFoundError=, so each escapes the guard: waybar's net/bt/audio modules die rather than dimming, and a maint probe takes the whole envelope with it — in exactly the machine state maint exists to report on.
+
+The asymmetry, and this codebase had already ruled on it three separate times: =net/iw.py='s =signal_dbm= and =settings/spawn.py='s =detached= both catch =(OSError, subprocess.TimeoutExpired)=, and =audio/cmd.py='s doctor-tier =probe()= enumerates =FileNotFoundError=, =NotADirectoryError= and =PermissionError= as "absent" under a docstring promising it never raises. Its sibling =run()=, twenty lines up in the same file, kept the narrow catch — as did all five copies of =run()= and all three tool wrappers. =audio/status.py='s docstring records that this same class already bit once ("the bar's audio module died rather than dimming"); that fix widened the guard's *scope* and left its *exception set* alone.
+
+Grading: Major severity (grading the being-in-it — the status surface is dead while the condition holds, and for maint the tool that reports the fault is the one that dies of it; no data loss, and it clears when the tool or the pressure does) × rare edge case (needs a binary with wrong permissions, a lost shebang, or a fork refused under pressure) = P3 = [#C].
+
+Fixed as dotfiles =44fdae1= (committed locally, deliberately NOT pushed — held for Craig's morning review). Widened to =OSError= across net, bt, audio, maint and panelkit — five =cmd.run= helpers, the three tool wrappers, =probe._curl= and =speedtest.run_speedtest=. The domain-error wrappers keep their "<tool> not found" message for a genuinely absent binary and add a second arm naming the errno for an unusable one, so the report can still tell the two apart. 28 tests, one class per package, driving all three exec failures against real files on a temp PATH; each was watched failing against unmodified production code first (27 red). Audio's class carries a characterization case pinning =cmd.probe='s existing behavior, so the sibling that got this right can't regress into the one that didn't.
+** DONE [#C] a failed pty-backed spawn strands both ends of the pty :bug:net:bluetooth:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Found in the 2026-07-24 sentry bug-hunt, round 6, auditing the =subprocess.Popen= sites the round-5 fix didn't reach.
+
+Two spawns open a pty before launching and catch only =FileNotFoundError= around the =Popen=: =bt/pairing.py='s =pair_interactive= (bluetoothctl under a pty so the passkey agent is interactive) and =net/speedtest.py='s =run_speedtest_stream= (speedtest-go under a pty because it buffers everything to exit when piped). Both are the same exec-failure class as =44fdae1= — a binary present but not executable raises =PermissionError=, a lost shebang raises =OSError= — and neither is =FileNotFoundError=.
+
+What makes these worse than the =run= wrappers is where the cleanup lives. =os.close(master)= and =os.close(slave)= sit *inside* the =FileNotFoundError= arm, so an escaping =OSError= skips them: every failed attempt strands two descriptors. Both call sites are buttons in a long-lived panel process — the pairing flow and the console's SPEED key — and a user who gets no feedback presses again, so the leak accumulates under exactly the conditions that caused it.
+
+Grading: Major severity (grading the being-in-it — a descriptor leak in a process meant to run for days, on a path the user retries, plus the exception escaping a documented "(ok, detail)" / error-envelope contract) × rare edge case (needs an unusable bluetoothctl or speedtest-go) = P3 = [#C].
+
+Fixed as dotfiles =c2eb3e1= (committed locally, deliberately NOT pushed — held for Craig's morning review). An =OSError= arm on each closes both ends and returns the module's own failure shape, naming the errno. Four tests: two pin the return contract, two count =/proc/self/fd= across three attempts — the fd count is what actually fails against unmodified code, and it was watched failing before the fix.
+
+The wider sweep this came from is recorded so it isn't repeated: every =except FileNotFoundError= in production was enumerated. The other exec sites were already correct (=maint/gui.py= x3, =net/kick.py=, =timer/engine.py= x2, =timer/gui.py=, =net/repair.py= x2, =audio/peak.py= all catch =OSError=), and the remaining hits are file-open catches, not exec. =clock/__main__.py='s =toggle()= has no guard at all but spawns =sys.executable=, which is by definition runnable; not filed.
+** DONE [#C] one impatient client kills the clock panel's toggle listener for good :bug:clock:waybar:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Found in the 2026-07-24 sentry bug-hunt, round 6, sweeping every acquired resource (pty, socket, mkstemp, tempdir) for cleanup that isn't in a =finally=.
+
+=clock/src/clock/app.py='s =_listen()= guards =accept()= with =except OSError: return= and leaves the request body — =recv=, =runtime_log=, =sendall= — outside any guard. =send_toggle()= in =__main__.py= gives the panel 0.25s to acknowledge, then closes. An ack later than that hits a dead peer and raises =BrokenPipeError=, which escapes the =while= loop and ends the listener thread.
+
+Verified empirically, not argued: a client that connects, sends, and gives up after 250ms makes the server's =sendall= raise =BrokenPipeError= (errno 32) and the listener thread exits.
+
+What makes it Major rather than a nuisance is that it neither self-heals nor announces itself. The socket file stays bound, so every later =clock toggle= still *connects* — then stalls the full 250ms, gets no reply, and falls through to spawning =clock serve=. GTK's single-instance forwarding turns that into =do_activate= on the running service, and =do_activate= calls =show_clock()=, not =toggle()=. So from the first bad client onward, clicking the waybar time module opens the panel every time and never closes it; the only ways out are the right-click dismiss inside the panel or restarting the service. Nothing logs it.
+
+Grading: Major severity (grading the being-in-it — the toggle is one-way from then on, it persists for the life of the service, and there is no signal it happened) × rare edge case (needs a reply to miss the 250ms budget: a busy main loop mid-redraw, a slow runtime-log write, or an interrupted =clock toggle=) = P3 = [#C].
+
+Fixed as dotfiles =7c02614= (committed locally, deliberately NOT pushed — held for Craig's morning review). An =OSError= arm around the request body scopes a dead peer to its own request, mirroring the guard =accept()= already had. =GLib.idle_add= runs before the ack, so the user's click still takes effect — only the acknowledgement is lost. New =tests/clock/test_socket.py=, 3 tests driving the real =_listen= against a stand-in owner (it touches only =self._socket= and =self.toggle=, so no Gtk.Application is needed). The gate is the second toggle after an impatient first: it times out on unmodified code because no listener is left. The other two pin what the fix must preserve — the toggle fires even when the ack can't be delivered, and an unknown command is still answered without toggling.
+
+Left alone deliberately: =do_activate= calling =show_clock()= rather than =toggle()=. Changing it would alter what a cold =clock toggle= does on first launch, which is a design call for Craig rather than part of this defect. Worth raising if he ever wants the spawn path to toggle too.
+** DONE [#B] fuzzel breaks the pinentry protocol loop on every passphrase :bug:security:gpg:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Found in the 2026-07-24 sentry bug-hunt, round 7 — from the live journal rather than from reading. Grepping this boot for tracebacks turned up four instances of =pinentry-fuzzel: line 36: read: 0: read error: Resource temporarily unavailable=, and every one sits 4-7 seconds after a =GETPIN= (the time it takes to type a passphrase). The =BYE= handler's log line never appears once.
+
+=hyprland/.local/bin/pinentry-fuzzel= speaks the Assuan pinentry protocol on a pipe gpg-agent keeps open, reading one command per iteration of =while read cmd rest=. The =GETPIN= arm shells out to fuzzel, which *inherits that pipe as its stdin*. fuzzel runs an event loop over its own input, so it sets =O_NONBLOCK= on fd 0 — and =--dmenu= would read the pipe as menu items besides. The flag lands on the shared open file description and outlives fuzzel, so the shell's next =read= fails with =EAGAIN= and the loop ends mid-protocol.
+
+Grading: Minor severity (the passphrase is delivered *before* the break, so decrypts still succeed and nothing is corrupted — what's lost is everything after: =BYE= is never acknowledged, and gpg-agent's same-connection retry after a wrong passphrase, =SETERROR= then =GETPIN= again, can't be served; that retry is what the script's "reenter" label exists for, and it has never once been reachable) × every user, every time (four for four in the journal, and the test reproduces it deterministically) = P2 = [#B].
+
+Fixed as dotfiles =e727dcd= (committed locally, deliberately NOT pushed — held for Craig's morning review). =< /dev/null= on the fuzzel call, so the non-blocking flag lands somewhere harmless; =--lines 0= was already there, so no menu input was ever wanted. =ENABLE_LOGGING= became env-overridable as a test seam — the script logs through an absolute =/usr/bin/logger= that PATH can't shadow, so without it every test run would write ten lines into the real journal.
+
+New =tests/pinentry-fuzzel/=, 8 tests driving the real script over a live pipe the way gpg-agent does. The fake fuzzel sets =O_NONBLOCK= on whatever fd 0 it is handed, exactly as the real one does, which is what makes them a gate rather than a restatement of the fix. Four fail against unmodified code — one reproducing the journal's message verbatim — and one records the fd fuzzel was given, pinning the cause rather than the symptom.
+
+THE CALIBRATION NOTE, and it is about my own earlier sweep. This is the same shape as round 1's =a57c443= (ffmpeg draining the pipe a =while read= loop was consuming). Round 1 swept both repos for siblings of that bug and came back empty — because it searched for the *mechanism* (a child that drains stdin) rather than the *shape* (a child that inherits stdin at all inside a read loop). Two different mechanisms, one shape, and the narrower search missed a live daily-use instance. Scope a class sweep by shape, not by the mechanism of the first instance found.
+** DONE [#C] a truncated webcam record strands every camera off :bug:settings:privacy:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Found in the 2026-07-24 sentry bug-hunt, round 8, sweeping production for non-atomic file writes.
+
+=settings/src/settings/webcam.py='s =_record()= wrote =~/.local/state/settings/webcam.json= with a plain truncate-in-place =open(path, "w")=. That record is the only route back on, and the module docstring says so: deauthorizing a camera removes its video4linux nodes, so =usb_devices()= returns nothing afterward and =_recorded()= becomes the sole source of the paths to re-authorize. A write that truncated and then failed left an empty file; =_recorded()= caught the resulting =JSONDecodeError= and returned =[]=; =_known_devices()= then had nothing; and =set_power(True)= returned None without re-authorizing anything. Every camera stranded off, with no way back through the panel until a replug or a reboot.
+
+The asymmetry, seventh instance of this read: six other state writers in the tree already write through a temp file and a rename — =maint/cache=, =net/cache=, =audio/ptt=, =timer/engine=, =settings/store=, =maint/curation=. The one whose loss is most expensive was the one that didn't.
+
+Grading: Major severity (grading the being-in-it — the privacy switch becomes one-way, the panel offers no route back, and the user has to know to replug the camera or write sysfs by hand; bounded by the fact that a reboot re-enumerates USB and restores authorized=1) × rare edge case (needs a crash or ENOSPC inside a microsecond-wide write window) = P3 = [#C].
+
+Fixed as dotfiles =8b40b79= (committed locally, deliberately NOT pushed — held for Craig's morning review). =_record= now mirrors =store.save=: =mkstemp= in the target directory, write, =os.replace=, unlink the temp on any failure. Four tests; the gate is a =_record= whose =json.dump= raises, after which the previous record must still be readable — it isn't on the old code. The other three pin what the fix must preserve: no temp-file residue, the =_recorded()= round trip, and the end-to-end power-off/power-on with the class symlinks removed, which is the scenario the record exists for.
+
+HOW IT WAS FOUND, and it confirms round 7's lesson twice over. Round 4 ran an atomic-write sweep and reported "nine sites, six unique-per-writer, three sharing a fixed =.tmp=" — it enumerated the writers that *were* atomic and compared their temp-file naming, and never asked which state writers aren't atomic at all. Same narrowing that made round 1's stdin sweep miss the pinentry bug: the sweep was scoped to a property of the instances already found rather than to the shape of the hazard.
+** DONE [#D] a failed wallpaper apply reports "nothing to apply" :bug:settings:quick:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Fixed as dotfiles =2cf3fb3=. The decision moved to =gui.wallpaper_apply_toast=, a module-level pure helper, because the callback lives inside a GTK widget where no test can reach it. 5 tests.
+Found in the 2026-07-24 sentry bug-hunt, round 7, sweeping the settings panel's worker callbacks.
+
+=settings/gui.py='s =_async= passes an exception through as the *result* rather than as a separate error argument, so every =done= callback has to test =isinstance(res, Exception)=. Five do — =_mx_pin=, =_mx_letter=, =_after_matrix=, =_set_pointer=, the drum/dial/gallery/refresh callbacks. =_wp_apply= is the one that doesn't:
+
+#+begin_src python
+def _wp_apply(self, note="Wallpaper set"):
+ self._async(lambda: panel.wallpaper_apply(self.state),
+ lambda ok: self._toast(
+ note if ok is True else "nothing to apply",
+ good=ok is True))
+#+end_src
+
+=panel.wallpaper_apply= calls =store.save=, which can raise =OSError= (disk full, a permissions change on the config dir). The exception then arrives as =ok=, =ok is True= is False, and the toast reads "nothing to apply" — describing a no-op when the apply actually failed. The toast is at least marked =good=False= (red), so the user gets a negative signal; what's lost is the reason, which every sibling callback surfaces via =str(res)=.
+
+Grading: Minor severity (wrong text on an error path, correctly marked as a failure, nothing corrupted) × rare edge case (needs =store.save= or =wallpaper.apply= to raise rather than return False) = P4 = [#D].
+
+Fix: give it the same =isinstance(res, Exception)= arm its five siblings have — toast =str(res)= on an exception, keep the current two-way message otherwise. One callback, three lines.
+** DONE [#D] two manage.py nmcli reads sit outside their own error conversion :bug:net:quick:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Fixed as dotfiles =2cf3fb3=. =_key_mgmt= converts both nmcli exceptions to "", which both call sites already treat as neither wpa-eap nor sae. 2 tests, including one driving =_classify_up_failure= end to end.
+Found in the 2026-07-24 sentry bug-hunt, round 4, reading =net/src/net/manage.py=. =nmcli.run()= raises =NmcliTimeout= on timeout and =NmcliError= on a missing binary, and every mutation in this module is written to convert both into a result envelope. Two calls escape that conversion because they run through =_key_mgmt()=, which wraps =nmcli.get_value= and catches nothing:
+
+- =edit()= line 243 calls =_key_mgmt(uuid)= for the enterprise-profile refusal *before* its own =try=, while the next four lines catch exactly those two exceptions around =nmcli.run=.
+- =_classify_up_failure()= calls it on =up()='s failure path, so a slow =connection show= turns a classifiable activation failure into an exception.
+
+Consequence is a leaked exception where the caller expected an envelope. The panel absorbs it — =gui.bg()= catches =Exception= and renders =str(e)= — so there it degrades to a worse message rather than a crash. =net edit= from the CLI has no such catch and prints a traceback.
+
+Grading: Minor severity (the operation fails either way; what's lost is the classified message, and only the CLI path shows a traceback) × rare edge case (=connection show= has a 2s timeout and nmcli's presence is already established by the time either site runs) = P4 = [#D].
+
+Fix: give =_key_mgmt= the same conversion its callers use — catch =(nmcli.NmcliError, nmcli.NmcliTimeout)= and return "", which both call sites already handle correctly (neither "wpa-eap" nor "sae"). One =try= in one helper covers both sites.
+** DONE [#D] three atomic writers share one fixed .tmp name :bug:quick:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Fixed as dotfiles =2cf3fb3=. All three carry =.tmp.$(getpid)=, matching the six writers that already did. 6 tests across audio and maint.
+Found in the 2026-07-24 sentry bug-hunt, round 4, sweeping both repos for the temp-file half of the atomic-write idiom. The tree writes state atomically in nine places, and six of them make the temp path unique per writer: =net/cache.py= and =timer/engine.py= both use =f"{path}.tmp.{os.getpid()}"=, and =settings/store.py=, =settings/idle.py=, =bt/repair.py=, =net/probe.py= all use =tempfile.mkstemp=/=NamedTemporaryFile=. Three use a bare =path + ".tmp"=:
+
+- =audio/src/audio/ptt.py= =write_state= (the lead carried over from round 3's Next Steps)
+- =maint/src/maint/cache.py= =put=
+- =maint/src/maint/curation.py= =_write_user=
+
+=os.replace= makes the *rename* atomic, but a shared temp name is not: two writers open the same path, the second truncates under the first, and the file that gets renamed into place is a blend of both. The loser's own =os.replace= then raises =FileNotFoundError=, because the winner already renamed the name out from under it.
+
+Real concurrent-writer pairs exist for two of the three. =maint/cache.py= =updates_repo= is written by =maint-net-scan.timer= hourly and again by =doctor._fresh_pending()= at UPDATE fire time. =audio/ptt.py= has three writers by design (the CLI toggle bound to a key, the waybar right-click, and the GTK panel) — its module docstring says so. =curation.py= is written by panel key presses and CLI verbs.
+
+Grading: Minor severity (every reader degrades rather than crashes — =cache.get= catches =ValueError= and reports no data, =read_state= reads a torn file as disarmed, and both recover on the next write; the sharpest edge is the loser's =FileNotFoundError= aborting the rest of =scan_net=, which the next hourly run repairs) × rare edge case (the write window is a millisecond or two, and the overlapping writers are an hourly timer against a human keypress) = P4 = [#D].
+
+Fix: give all three the =f"{path}.tmp.{os.getpid()}"= form the two careful siblings already use. It is three one-line changes and needs no new abstraction. Note this closes the torn-file half only — the read-modify-write in =ptt.toggle_plan= and =curation.set_preference= can still lose an update between two writers, which wants a lock rather than a temp-name change and should stay a separate decision.
+** DONE [#C] dmenuexitmenu word-splits its menu so no entry matches :bug:dwm:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+Found in the 2026-07-24 sentry bug-hunt (shellcheck SC2128). =dwm/.local/bin/dmenuexitmenu= line 4 expands the menu unquoted: =choice=$(echo -e $menuitems | dmenu ...)=. Word-splitting collapses the runs of spaces the labels carry, so dmenu shows =Lock= where the =case= arm expects =Lock = (two spaces) and =Logout 󰩈= where the arm expects a trailing space. No arm matches, so choosing an entry does nothing at all.
+
+CORRECTION (2026-07-24): THE BUG AS FILED DOES NOT EXIST. Ran it. =echo= rejoins the words split off the unquoted expansion with single spaces, and no label carries two spaces, so quoted and unquoted produce byte-identical output — verified against the exact literals from git rather than a retyped copy. Every =case= arm matches and every menu action works.
+
+What is real is latent. An unquoted expansion collapses a double space and glob-expands a =*=; the second was demonstrated turning a label into a directory listing. No current label triggers either.
+
+Hardened anyway in dotfiles =2cf3fb3= as robustness, not as a bug fix: the expansion is quoted and the bogus one-element array is now a plain string. Output confirmed unchanged byte-for-byte. New =tests/dmenuexitmenu/= (10 tests) pins the working behaviour, and shellcheck on the file drops from three findings to one.
+
+SECOND SENTRY FILING DISPROVED BY RUNNING IT, after =a57c443= (mkplaylist). Both came from a shellcheck hit plus reasoning, neither was executed. A static-analysis finding says a construct is unsafe, not that it currently misbehaves, and both filings treated the first as the second.
+** DONE [#C] Timer module hero hierarchy :feature:waybar:timer:quick:solo:
+CLOSED: [2026-07-24 Fri]
+From the roam inbox (Craig, claimed 2026-07-22). Which display ("hero") wins the waybar timer module when several timer modes run simultaneously: pomodoro wins over everything (the user is actively working; it's likely their main focus). The rest rank in chronological order of when they would ring. Worked example: with a just-started 15-min timer, a 1-hr timer at 10 minutes left, a pomodoro, and an alarm ringing in 12 minutes — show the pomodoro; when it completes, the 1-hr timer (rings first), then the alarm, then the 15-min timer. Feeds the timer-panel spec (docs/specs/2026-07-02-timer-panel-spec.org).
+
+Shipped as dotfiles =9eedb39=. Pomodoro wins the hero, then soonest-to-ring, in both selectors (=engine.select_primary= for the bar, =panel.primary_id= for the GTK hero). Craig's worked example is a test. FLAGGED FOR CRAIG: the two selectors diverge on a *ringing* alarm (the bar excludes it, the panel gives it the hero) and I left that as-is rather than reverse a deliberate choice. Whether to unify them is your call.
+** DONE [#C] Timer module: drop RING message, persistent notifications :bug:waybar:timer:quick:solo:
+CLOSED: [2026-07-24 Fri]
+From the roam inbox (Craig, claimed 2026-07-22). Remove the RING message from the timer module display; verify all timer and alarm notifications are persistent; the icon returns to normal once the notification has fired. Rationale: keeps timers and pomodoros from interfering with one another's displays (pairs with the hero-hierarchy task above).
+
+Shipped as dotfiles =9eedb39=. The tooltip no longer prints RING or a (ringing) suffix; a fired alarm shows its clock time and its persistent notification carries the alert. Verified the timer and alarm completion notes already set persist=True.
+** DONE [#C] PTT icon outline removal :bug:waybar:quick:solo:
+CLOSED: [2026-07-24 Fri]
+From the roam inbox (Craig, claimed 2026-07-22): the waybar PTT icon should not have an outline. Cosmetic × every-glance = P3 = [#C].
+
+Shipped as dotfiles =e63c0cf= (live style.css + dupre theme source). Removed the amber/green text-shadow glow from the armed/talk states, the only outline-like effect on the icon. FLAGGED FOR CRAIG: this is my read of "outline" (the glow). If you meant the glyph shape itself, it's a one-line revert. Confirm live by pressing PTT.
+** DONE [#C] Video wallpapers don't fit the desktop :bug:dotfiles:solo:
+CLOSED: [2026-07-24 Fri]
+From the roam inbox (Craig, claimed 2026-07-23): videos don't fit the desktop in desktop-settings. The video channel drives mpvpaper (=settings/src/settings/wallpaper.py=); mpvpaper passes options through to mpv, so the fit is a =--panscan=/=--video-unscaled=/keepaspect question rather than a layout one. Reproduce with a video whose aspect differs from the output, pick the mode that fills without distorting (cover, matching how the image channels behave), and cover it in the wallpaper tests. Minor severity × whenever the video channel is selected = P3 = [#C].
+
+Shipped as dotfiles =04d1489=. =set_video= now passes =panscan=1.0=, so mpvpaper fills the output and crops the overflow instead of letterboxing; keepaspect stays on so nothing stretches. Tested against the mpvpaper arg log.
+** DONE [#C] World-clock wallpaper arrangement :feature:dotfiles:
+CLOSED: [2026-07-24 Fri]
+Shipped 2026-07-24 as dotfiles =6afbe09=, iterated live with Craig. The grid of boxed mini-clocks became a centered vertical clock line: cities down a spine, west (Honolulu) top to east (Wellington) bottom, labels alternating both sides, no boxes. Each shows city / time (12h) / day+date / timezone region name ("US Central"). Day/night dimming + amber home carried over, title dropped, cursor restored over the desktop. Prototypes archived in archsetup 40216e7. The face is parameterized (=?layout=vertical|horizontal=, =?hour12=1|0=) so the panel pickers below can drive it.
+** DONE [#C] Floating layout — should we? :feature:hyprland:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+From the roam inbox (Craig, claimed 2026-07-23): consider whether Hyprland should offer a floating layout — how it would work, the benefits, and the complexity. A brainstorm/spike, not a build: the deliverable is an assessment Craig reads and decides on, not a shipped layout. Not :solo:. When picked up, run it as a brainstorm — how a floating mode coexists with the current tiling binds (toggle keybind, per-workspace vs global, window-rule interactions), what it buys over the existing =togglefloating=, and the config/muscle-memory cost — then bring Craig the recommendation.
+
+CONCRETE PROPOSAL from a second roam item (Craig, 2026-07-24 via work) — "floating mode as the easiest mode":
+- Can't select floating until at least one window is displayed.
+- Entering floating freezes each window's position and floats it exactly where it is.
+- During floating, drag windows with mod+mouse-drag.
+- Exiting floating switches to tiling or monocle and lets that layout take over.
+Craig's note: "simple, could be useful for different reasons." This is the design the brainstorm should evaluate first — assess feasibility against Hyprland's actual float/tile transitions (does freezing current geometry survive the tiling↔floating switch, does re-tiling on exit reflow cleanly) before recommending.
+
+ASSESSED, dotfiles =8cf4728=: =docs/2026-07-24-floating-layout-assessment.org=. Verdict: buildable and worth building on a capture-then-restore of window geometry (=hyprctl clients -j= gives at/size), which is a real gesture plain =togglefloating= can't express. Craig's four-rule proposal is folded in and each rule assessed. One taste call flagged (exit to previous layout vs always monocle). Ready to file a build task on Craig's go.
+** DONE [#C] World clock wallpaper: bold the city names :feature:dotfiles:quick:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+From the roam inbox (Craig, 2026-07-24 via work): bold the city names on the world-clock wallpaper face (=settings/faces/world.html=, shipped =6afbe09=). Cosmetic × every glance at the world face = P3 = [#C]. Solo — a CSS weight change, screenshot-verifiable — but it's a visual call, so build it and show the render rather than close off a green suite. Pairs with the open world-face picker task.
+
+Shipped as dotfiles =e63c0cf=. =.lbl .city= is now =font-weight:700=. Rendered offscreen and confirmed the bold reads well over the time/zone lines; home city stays amber. Comparison render was on ws5 for Craig.
+** DONE [#C] Floating clock toggles on control+mod+c :feature:dotfiles:hyprland:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+From the roam inbox (Craig, 2026-07-24 via work): a control+mod+c keychord should toggle the floating clock, the same as clicking the time waybar module.
+
+This answers the design question the round-6 clock-toggle fix deliberately left open (see the =clock toggle listener= DONE task above): =do_activate= calls =show_clock()= rather than =toggle()=, and the note there flagged "worth raising if he ever wants the spawn path to toggle too." He does. Build: a hyprland keybind bound to =clock toggle=, and confirm the toggle path (not show-only) fires whether the service is cold or warm. Solo — buildable and locally verifiable.
+
+Shipped as dotfiles =e73a70e=. =bind = $mod CONTROL, C, exec, clock-panel toggle= reuses the exact command the time module's click runs, so it toggles identically. Registered clean on reload. Live keypress is Craig's to confirm.
+** DONE [#C] Calculator scratchpad won't toggle closed on mod+x :bug:hyprland:solo:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+From the roam inbox (Craig, 2026-07-24 via work): =mod+x= opens the calculator scratchpad but doesn't close it — Craig has to kill the window by hand. A second =mod+x= should toggle it shut. Almost certainly a =togglespecialworkspace= vs plain =exec= binding in the hyprland config, or a scratchpad window-rule mismatch. Minor severity (a workaround exists: kill the window) × every time the calc scratchpad is used = P3 = [#C]. Solo — a keybind/window-rule fix, locally verifiable.
+
+Shipped as dotfiles =e73a70e=. New =calc-toggle= script (mirrors fuzzel-toggle: pgrep -x, pkill or launch), and =mod+X= now points at it, so a second press closes the calculator. 3 tests in tests/calc-toggle.
+** DONE [#C] Saving and recalling window configurations :feature:hyprland:
+CLOSED: [2026-07-24 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+From the roam inbox (Craig, 2026-07-24 via work), a research idea: Craig wants to save a specific window+app arrangement and have it reappear on demand. What has to be known and built to make that happen — is there prior art (another WM or OS that does session/layout save-restore), what information do those need (app identity, geometry, workspace, launch command), and what are their rules. Explore how far Hyprland can get (hyprctl clients + dispatch, exec rules, window rules by class/title), document thoroughly, and review with Craig next time. Not :solo: — the deliverable is an assessment he reads and decides on, and it may spawn a build task once the shape is clear. Offer to file the build separately if part of it turns out urgent.
+
+RESEARCHED, dotfiles =8cf4728=: =docs/2026-07-24-window-config-save-recall-assessment.org=. Prior art surveyed (i3/sway =append_layout= swallow, KDE window rules, macOS Moom). Three tiers from cheapest: (1) reposition open windows — buildable + testable now; (2) relaunch + place by class rule; (3) full swallow-by-title, which hits the same-class ambiguity every tool hands back to the user. Recommends shipping tier 1; tiers 2-3 need Craig's call on how much manual disambiguation he'll accept.
+** DONE [#B] Weather tooltip caching :feature:waybar:weather:solo:
+CLOSED: [2026-07-25 Sat 10:53]
+From the roam inbox (Craig, claimed 2026-07-22): retrieve the weather tooltip data once per hour and cache it. If the network is unavailable, display the cached tooltip with explanatory text saying so. Dotfiles-side work (archsetup owns the lifecycle); touches common/.local/bin/weather.
+Verified complete in the 2026-07-25 batch: the weather CLI already had the hourly default TTL, fresh-cache no-fetch path, stale fallback, and explicit offline footer. Its 33-test suite and the full dotfiles suite pass.
+** DONE [#B] Settings gear becomes four device toggles :feature:waybar:dotfiles:solo:
+CLOSED: [2026-07-25 Sat 10:53]
+From the roam inbox (Craig, claimed 2026-07-23): the waybar gear should become four icons — touchpad, mouse, webcam, and a notification bubble. Clicking each toggles that setting directly. The first three turn red when disabled; the bubble turns red when DND is enabled.
+
+Today =custom/settings= (=hyprland/.config/waybar/config=) is one gear glyph (󰒓) whose only job is =on-click: settings-panel=. The toggles themselves already exist and are tested — the settings package owns touchpad, mouse, and webcam (=webcam.py= is the USB-authorized kill switch from 2026-07-22), so this is a bar-side surface over existing backends rather than new capability.
+
+Note the state-polarity split when wiring the colors: three read "red = off" and DND reads "red = on". That asymmetry is deliberate (red means "something is disabled that normally isn't, or suppressed that normally isn't"), so encode it per-icon rather than deriving one rule.
+
+Decided 2026-07-23 (Craig): the gear STAYS alongside the four toggles as the panel launcher. So the bar's right side grows from 12 modules to 16 — the four toggles are net-new, the gear keeps its =on-click: settings-panel=. Open sub-question for build time, not blocking: whether the four toggles are four separate waybar modules or one custom module rendering four glyphs (fewer layout entries, one exec). Pick at build; the four-module shape is simplest and matches how mic/net already sit as individual modules.
+Shipped in the 2026-07-25 batch as four independent JSON modules over the existing verified settings backends. Touchpad, mouse, and webcam turn terracotta when disabled; DND uses the deliberate inverse polarity; unavailable hardware dims. The gear remains the panel launcher. The live and Dupre theme CSS copies stay byte-identical.
+** DONE [#C] Wallpaper panel selection and scroll state :feature:dotfiles:solo:
+CLOSED: [2026-07-25 Sat 10:53]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-25
+:END:
+From the roam inbox (Craig, 2026-07-25). Screenshot: =~/pictures/screenshots/2026-07-25_013041.png=. Three related behaviors in the settings wallpaper panel (=settings/src/settings/wallpaper.py=):
+1. Open at the wallpaper currently displayed, not the top of the list.
+2. Highlight that wallpaper as selected in the scrollable pane while it shows in the preview.
+3. Keep the scroll position when a picture is selected. Today selecting a picture snaps the scroll back to the top, which is the bug half of this.
+Grade: minor scroll-reset defect x every panel selection = P3 = [#C]; the open-at-current and select-current behaviors are enhancements at the same level. One type tag, so filed =:feature:= with the scroll-reset called out as the bug. Solo: buildable in the settings GTK panel, agent-verifiable via headless capture plus the wallpaper.py tests, no design call — swww query gives the current wallpaper, and scroll-position preservation and row selection are standard GTK.
+Shipped in the 2026-07-25 batch. The panel queries =awww query= off the UI thread, prefers the actually displayed image over stale stored state, highlights it, scrolls it into view on first open, and remembers the horizontal adjustment across selection-triggered rebuilds.
+** DONE [#C] Net tooltip IPs and line order :feature:waybar:network:solo:
+CLOSED: [2026-07-25 Sat 10:53]
+From the roam inbox (Craig, claimed 2026-07-23): in the wifi hover, add the internal IP, external IP, and gateway IP just below the Interface line; move the Signal line to just above the keyboard-shortcuts line. Design constraint: the bar's hot path does no network I/O (status.py deliberately skips _address_facts on the 2s beat) — internal IP + gateway can ride cheap local reads, but the external IP must come from a cache the connectivity probe refreshes, never a live lookup in waybar-net.
+Shipped in the 2026-07-25 batch. The slow connectivity probe caches local addressing and a validated external IP with the network identity; the Waybar hot path only reads that valid cache. Tooltip order is Interface, internal/external/gateway IPs, connectivity detail, throughput, Signal, shortcut.
+** DONE [#B] Dupre Kit merge — casting additions :feature:tooling:solo:
+CLOSED: [2026-07-25 Sat 10:53]
+Fold docs/prototypes/dupre-kit-additions.js back into the kit proper: detentFader (NEW — multi-detent slide attenuator with speedbump drag physics: magnet + escape hysteresis, parked tick glow) and the drumRoller redefinition (UPGRADE — 1..N channels and min/max range; stock hardcodes two drums and throws on one, defaults reproduce stock exactly) and the guardedToggle redefinition (UPGRADE — lever throws with rotateX so it flips toward the viewer instead of the stock 180° planar spin that sweeps sideways mid-transition; contract unchanged). Merge means: builders into widgets.js, the additions CSS into DUPRE_CSS, additions-scoped gradients into the shared defs plate, gallery cards for both in panel-widget-gallery.html, and POLICY entries. Origin: the desktop-settings casting sitting 2026-07-21 — Craig's direction is that components get finished by being needed ("the ones needed most will have had the most attention"), so more additions may accrue here before the merge; batch them.
+Shipped in the 2026-07-25 batch. =widgets.js= now owns all three builders, shared gradients/CSS, contracts, and policy records; additions no longer redefines them when older casting pages load it. The gallery has a three-detent fader card and a three-channel 0–12 drum demonstration (112 cards total). Static ownership tests, JS syntax checks, and the complete headless interaction probe pass.
+** DONE [#C] Maint live-refresh hairline replacement :feature:maint:solo:
+CLOSED: [2026-07-25 Sat 10:53]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-14
+:END:
+From the roam inbox (routed 2026-07-13): the memory-killer section seemed to update too often, and "it's a bit unclear what the line is doing; consider something else." Diagnosis (2026-07-14): the data cadence is already the requested 3s (gui live tier, _LIVE_SECONDS); the perceived churn is the live-refresh hairline — the 2px bar under the live sections that drains full-to-empty over each 3s window, redrawn at 150ms (gui._hair_tick, viewmodel.refresh_fraction). It exists to tell a stale board from a frozen one (2026-07-09), but it reads as constant unexplained motion. Design call for Craig: replace the draining line with something whose meaning is legible — candidates: a dot that blinks once per refresh, a "3s" age caption that only appears when refresh is overdue, slowing the drain redraw, or dropping the indicator on live tiers and keeping it only when data goes stale. Keep the stale-vs-frozen distinguishability that motivated the hairline.
+*** 2026-07-21 Tue @ 08:35:00 -0500 Decided (Craig): silent-until-stale age caption
+Replace the draining 2px hairline with an age caption that shows ONLY when refresh is overdue (e.g. "3s", "8s" once past the expected window) and shows nothing while the board is healthy. This keeps the stale-vs-frozen signal — a frozen board surfaces a growing age number, a live one stays clean — while removing the constant motion the hairline created. Implementation (dotfiles, archsetup-owned): drop =gui._hair_tick= / the hairline draw, add an overdue-age caption driven off =viewmodel.refresh_fraction= (or the last-refresh timestamp) rendered only past the live window. Now unblocked; needs a live visual check on the panel after.
+Shipped in the 2026-07-25 batch. The animated draw area and 150ms timer are gone; the memory section header stays silent through the healthy three-second window, then shows a once-per-second growing age caption. Pure boundary tests and the full maintenance suite pass.
+** DONE [#D] Test-framework + prototype refactor cluster :refactor:solo:
+CLOSED: [2026-07-25 Sat 10:53]
+Grading: no behavior change; parking lot. Refactors from the S5-S7 audit, distinct from the installer refactor rollup above.
+scripts/testing/run-test.sh + run-test-baremetal.sh duplicate the run/poll/report skeleton and have drifted (VM uses setsid + copy helpers, baremetal uses nohup + hand-rolled sshpass scp) — extract the shared core so baremetal inherits the sturdier paths; run-maint-nspawn.sh:66 + run-maint-scenarios.sh:78 duplicate the transport-independent _scenario_var/_validate_scenario/run_scenario (a sourced lib/maint-scenario.sh); run-test.sh:251,265 uses two different mechanisms (pgrep vs ps|grep) for the same liveness check; docs/prototypes/gen_tokens.py:78 repeats the section-iteration skeleton across four emitters; gallery-widget.el:95,136 hardcodes SVG arc/hub path strings that duplicate the cx/cy/radius geometry (dial desyncs silently on a constant change); gallery-widget.el:72,84 leans on the private svg--append. See findings doc (S5, S6, S7).
+Completed test-first in the 2026-07-25 batch. QEMU and bare-metal runners share liveness/report helpers; maintenance transports share scenario validation/execution; token emitters share ordered section traversal; and the Emacs SVG gauge shares semicircle geometry and uses the public DOM append API. Every fast Python/ERT suite passes.
+** DONE [#B] Two agent sessions sharing one git repo :chore:tooling:
+CLOSED: [2026-07-26 Sun]
+Craig approved the shared-rules-layer solution on 2026-07-26.
+
+Use one repository-scoped publish lock for every session and worktree sharing a clone. Derive the lock name from the real Git common-directory path; hold it across reconcile, stage, staged review, and commit; track the owning session and reviewed staged-tree fingerprint; refresh it after conversational waits; and repeat the staged review if ownership or the fingerprint changed. Ordinary working-tree edits remain concurrent.
+
+An approval waiver never waives the staged review, because that review is the gate that reads the actual hunks entering the commit. Rulesets owns the implementation in =commits.md=, =agent-lock=, and its Bats coverage; archsetup sent the approved implementation package through the rulesets inbox.
+** DONE [#A] Reboot ratio to activate amdgpu.runpm=0 :bug:hyprland:ratio:
+CLOSED: [2026-07-28 Tue] DEADLINE: <2026-07-28 Tue>
+:PROPERTIES:
+:CREATED: [2026-07-28 Tue]
+:LAST_REVIEWED: 2026-07-28
+:END:
+Craig's plan: close everything down, run topgrade, then reboot. Alarm set for 08:00 (=at= job 56, persistent desktop notify).
+
+=amdgpu.runpm=0= sits in =/etc/default/grub= and in the generated =/boot/grub/grub.cfg= (5 occurrences, so the reboot will actually apply it) but is absent from =/proc/cmdline=. The box has been up since 2026-07-22 21:10 and the fix landed 2026-07-24, so the running kernel predates it. The GPU is AMD Strix Halo (Radeon 8060S, =1002:1586=), exactly what the parameter targets: runtime power management invalidates the GPU resources hyprlock holds across a display power-cycle, so hyprlock exits without unlocking.
+
+That is the root cause under the 2026-07-27 lockdead screen. The screen-lock flock fix (dotfiles =ec18fd7=) stops one dead client from becoming a lockdead screen, but it treats the symptom -- this reboot treats the cause.
+
+Not :solo: — Craig closes his own session and runs topgrade first.
+
+Rebooted 2026-07-28 08:59. =amdgpu.runpm=0= confirmed present in =/proc/cmdline= afterward, so the parameter is finally live.
+
+Correction, 2026-07-29: the claim above and in the body that this is "the root cause under the 2026-07-27 lockdead screen" is wrong, and superseded. hyprlock was never crashing. Every logged exit is =rc=143=, SIGTERM, from =settings-watch= killing it by design. See =[#B] Night watch and the lock watchdog fight each other=. The reboot was still worth doing (the parameter is a genuine mitigation for a real AMD defect) but it did not fix this, and the lockdead screens continued after it.
+** DONE [#B] Caffeine state is unreadable on both surfaces :bug:dotfiles:design:solo:
+CLOSED: [2026-07-28 Tue]
+:PROPERTIES:
+:CREATED: [2026-07-28 Tue]
+:LAST_REVIEWED: 2026-07-28
+:END:
+Neither surface that reports caffeine tells the truth reliably, so there is no way to know at a glance whether the screen will lock. Found while investigating the 2026-07-27 lockout, where Craig believed caffeine was on and the screen locked anyway.
+
+Defect 1 — the settings panel shows a frozen value. =gui.py= calls =_refresh_async()= once during window construction (line 454) and again only after the user's own actions (=_after_matrix=, line 663). The only two =GLib.timeout_add= calls are one-shots (the 2400ms toast hide and a 350ms fire), so nothing re-reads state on a timer. An open panel therefore displays the caffeine value from the moment it opened, forever. Any external flip -- the waybar click, Super+I, the =caffeine-toggle= script -- leaves it stale with no self-correction. The panel is the only surface in the repo carrying a caffeine control (=panel.py:25=); maint has none and does not embed these toggles, so this is the display Craig read.
+
+Defect 2 — there is no caffeine indicator on the bar at all. =custom/caffeine= appears in neither the stowed =hyprland/.config/waybar/config= nor the live generated =/run/user/1000/waybar/config=, and no =custom/caffeine= block is defined anywhere in the waybar config dir. The =waybar-caffeine= script exists, works, and has its own passing test suite, but nothing displays it. So the bar has never been a source of caffeine state, and the keybind and script have been signalling (=pkill -RTMIN+8 waybar=) a module that isn't there.
+
+(An earlier read of this task said the bar showed two near-identical glyphs. That was wrong: the module is absent, not merely unstyled. The script's class names are still backwards -- =active= when caffeine is OFF, =inhibited= when ON -- and neither class is styled, but both points are moot until the module is actually in the bar.)
+
+Grading: Major severity (the panel reports state wrongly while it is open, and the only other surface does not exist, so there is no reliable source for a setting Craig actively manages) x most-of-the-time (any external toggle while the panel is open; the bar never shows it) = P2 = [#B].
+
+Fix all three. Wire =custom/caffeine= into the bar, rename its classes so they describe caffeine rather than idle, and style them from the existing palette. Give the panel's toggle row a re-read on a timer or on focus-in. Solo -- buildable and testable, and the direction is settled by the defects rather than a taste call, though the bar color is worth a glance from Craig once it renders.
+
+All three shipped as dotfiles =033076c=, pushed. =custom/caffeine= now sits in the bar between DND and settings on =interval: 2=; classes renamed =on=/=off= and both styled, caffeine-ON in the theme's gold =#dab53d=; the panel re-reads live state every 3s while visible. Verified live in the stowed config and the generated =/run/user/1000/waybar/config=. The full suite caught a theme-copy regression (=themes/dupre/waybar.css= out of sync with =waybar/style.css=) that the focused suites missed.
+** DONE [#B] hyprlock still exits mid-lock; the watchdog relaunch is silent :bug:hyprland:dotfiles:
+CLOSED: [2026-07-29 Wed]
+:PROPERTIES:
+:CREATED: [2026-07-28 Tue]
+:LAST_REVIEWED: 2026-07-28
+:END:
+Craig, 2026-07-28 ~15:00: saw the Hyprland lockdead/error text blurred *behind* a working lock screen; it vanished when he authenticated.
+
+That ordering is the diagnosis. hyprlock's blur samples what the compositor is currently rendering, so the compositor was already showing lockdead when the new hyprlock attached. Sequence: hyprlock exits non-zero (no coredump, so it exits rather than crashing), Hyprland renders lockdead because the client is gone while the session stays locked, =screen-lock='s watchdog relaunches within =LOCK_RELAUNCH_DELAY= (0.5s), and the new client draws over the lockdead frame and blurs it.
+
+*The recovery worked.* On 2026-07-27 this same hyprlock exit produced two contending clients and a session recoverable only from another console. It now self-heals in half a second, and the residue is cosmetic. Both the flock guard (dotfiles =ec18fd7=) and the watchdog did their jobs — verified in this session's compositor log, where all four lock events created exactly one =sessionLock= and one =sessionLockSurface= each, against two of each on 2026-07-27.
+
+Two things remain.
+
+*Why hyprlock exits.* The wrapper's header blames GPU-resource invalidation across a display power-cycle (hyprlock#953), which =amdgpu.runpm=0= targets — and that parameter is live as of the 2026-07-28 08:59 reboot, confirmed in =/proc/cmdline=. There is also no DPMS idle rule any more (=e900903=), so idling never power-cycles the display. Yet hyprlock still exited. Strongest untested candidate: a screen recording (=wf-recorder= into =~/sync/recordings/2026-07-28-12-53-57.mkv=, running 12:53 until Craig killed it) held screencopy sessions on DP-4 across the lock. The compositor log carries 2454 screenshare sessions and a =CScreencopyProtocol= bind in the window between the last two locks. A screencopy client churning dmabufs alongside hyprlock's own is a plausible way to invalidate them, and it was the one large new variable that day.
+
+*The relaunch is silent.* The watchdog loop re-runs hyprlock and logs nothing, so there is no record of how often this fires, when, or with what exit code — which is exactly why the frequency couldn't be established from the logs. Log the exit code and a timestamp on each relaunch.
+
+Grading: Major severity (the lock client dies mid-lock, and the pre-fix version of this wedged a session unrecoverably) x most users frequently (twice in three days, and this is a single-user machine, so every occurrence lands on the only user) = P2 = [#B]. Downgraded from the 2026-07-27 [#A] because the wedge is fixed and the failure now self-heals.
+
+An earlier draft of this grading said "some users sometimes", which the matrix maps to P3 = [#C], not the [#B] written beside it. The frequency row was the wrong input rather than the letter: on a one-user machine a fault hitting twice in three days is frequent, not occasional. Corrected the input per the rule that a disputed grade is fixed at its inputs.
+
+Solo for the instrumentation half only: adding the relaunch logging is buildable, testable against the existing =tests/screen-lock= suite, and needs no decision. Diagnosing the exit is not solo — it needs a reproduction, and the likely trigger is Craig recording his screen.
+
+Next step when picked up: land the relaunch logging first so the next occurrence produces evidence, then try to reproduce by locking with =wf-recorder= running.
+
+Superseded 2026-07-29 by =[#A] Night watch and the lock watchdog fight each other=. The logging landed (dotfiles =5bbe2c3=) and answered it within hours: three =rc=143= entries, SIGTERM, from =settings-watch= killing hyprlock by design. Nothing was crashing, so both the AMD-iGPU and the screen-recorder hypotheses in this task are wrong. Kept closed rather than deleted because the reasoning that led here is worth the record.
+** DONE [#A] Idle commits silently drop the screen-lock wrapper :bug:hyprland:dotfiles:security:
+CLOSED: [2026-08-04 Tue] DEADLINE: <2026-07-29 Wed>
+:PROPERTIES:
+:CREATED: [2026-07-29 Wed]
+:LAST_REVIEWED: 2026-07-29
+:END:
+Caught live 2026-07-29 05:30, seconds after it happened, while verifying that Craig's watch-stage change had landed.
+
+=idle.py= renders the *whole* hypridle.conf, including a hardcoded =GENERAL= block. That block said =lock_cmd = pidof hyprlock || hyprlock=. The live config said =|| screen-lock=. So every idle-stage commit through the panel rewrote =lock_cmd= and dropped the wrapper out of the chain.
+
+The wrapper is not incidental. It carries the flock duplicate guard (the fix for the 2026-07-27 unrecoverable wedge), the crash-relaunch watchdog, and the relaunch log. Parking one stage removed all three in a single write, and nothing said so.
+
+=tests/settings/test_settings.py:562= asserted the bare =|| hyprlock= form, so the suite *enforced* the regression. That is why 3845 tests stayed green through a day of work on exactly this subsystem. A test can pin the bug as readily as the fix.
+
+The false-negative this sets up is worth naming: with the wrapper gone the relaunch log stops receiving entries, and an empty log reads as "the problem is fixed" when it means "the instrument was removed". The =screen-lock= header already warns that an empty file is not proof; this is the mechanism that would have produced one.
+
+Fixed by dotfiles =ab059fb= (2026-07-29 05:59). Verified 2026-08-04 against the tree rather than the commit message: =idle.py:47= renders =lock_cmd = pidof hyprlock || screen-lock || hyprlock=, the live =hypridle.conf= matches, and =test_settings.py= now asserts the wrapper is in the chain plus a second test for the bare-hyprlock fallback. The test that used to pin the bug now pins the fix.
+
+The evidence that matters is the one this task named: =~/.local/var/log/screen-lock.log= is *receiving entries*, so the instrument is present. An empty log was the false negative to fear, and it did not happen.
+
+FIXED here, TDD, in the working tree pending commit:
+- =idle.py= =GENERAL= now names =screen-lock=, with a comment saying why the line is load-bearing.
+- The test now pins the wrapper form. Red first against the old template.
+- Live config rewritten through the panel's own path and hypridle restarted; =lock_cmd= confirmed back to =screen-lock=, one hypridle running.
+
+Grading: Critical severity (=write_conf= truncates, so any hypridle key the renderer does not model is silently deleted rather than preserved — that is configuration data loss, and the =lock_cmd= case proved it happens in the field) x some users sometimes (only when an idle stage is committed, which is rare) = P2 = [#B].
+
+An earlier draft graded this [#A] on a "security carve-out". That was wrong: disarming the guard is an availability problem, not a leak, and the carve-out is for privacy, security, compliance and safety. The severity band is what carries the weight here, and silent deletion of configuration is the =Critical= band's data-loss case.
+
+Two further fixes came out of an independent review of the first one:
+
+- *Fail-open restored.* =pidof hyprlock || screen-lock= made the wrapper the end of the chain, and =screen-lock= is a stow symlink in =~/.local/bin=, not a system binary. An unstowed tree, or a hypridle started without =~/.local/bin= on PATH, resolves it to 127 — so the screen would never lock *at all*. That is worse than the duplicate client the wrapper prevents. The chain now ends =|| hyprlock=, matching the wrapper's own fail-open discipline.
+- *The file now says it is generated.* Three comment lines at the top of the rendered output name the renderer and warn that edits are overwritten. The absence of that header is how the divergence survived unnoticed.
+
+Still open, and why this stays a task rather than closing with the fixes: the header warns, but nothing *prevents* the next divergence, and the exposure is wider than =lock_cmd= alone. The review enumerated it:
+
+- =before_sleep_cmd= and =after_sleep_cmd= sit in the same hardcoded block, at identical risk.
+- Every stage command is hardcoded in =_stage_commands= (brightness level, lock, watch, dpms, suspend), same one-way overwrite.
+- =write_conf= *truncates* rather than merges, so any hypridle key the renderer does not know about (=ignore_dbus_inhibit=, =ignore_systemd_inhibit=, =inhibit_sleep=, =on-lock=, =on-unlock=) is deleted rather than preserved. That is the largest hole: a key nobody has added yet would vanish the first time a stage is parked.
+
+Options: have the renderer preserve the existing general block and unknown keys instead of emitting its own, or accept the template as the single source and move every hypridle setting into the panel. A design call for Craig, and the truncation half is the part that will bite next.
+** DONE [#A] Comet KVM setup for truenas :feature:infra:truenas:
+CLOSED: [2026-08-08 Sat]
+:PROPERTIES:
+:CREATED: [2026-07-27 Mon]
+:LAST_REVIEWED: 2026-07-27
+:END:
+Resolved: Craig wired up and configured the Comet himself, confirmed working
+2026-08-08. The ATX power-board follow-up (hard power-cycle for a truly wedged
+box) remains unfiled — raise it if the next outage shows the KVM alone isn't
+enough.
+Wire up the GL.iNet Comet (GL-RM1) IP KVM against truenas. It was bought 2026-01-14 for exactly this job and its KB node still reads "Arrived, not yet set up."
+
+Why now: truenas went dark 2026-07-24 and stayed unreachable. Diagnosis from ratio on 2026-07-27 — no tailnet contact for 3 days, 100% packet loss on 192.168.86.5, ARP entry FAILED (nothing answers ARP for the address, so the NIC is down at layer 2), every service port closed, while the gateway and a dozen other LAN hosts stayed reachable. Wake-on-LAN to 70:85:c2:db:9d:94 drew no response. With no console and no out-of-band power control there was no remote remedy at all, so recovery needed hands on the box. The Comet closes exactly that gap: BIOS/UEFI console, Wake-on-LAN, and browser access over its native Tailscale integration.
+
+Not :solo: — the physical cabling is Craig's, and the Tailscale enrollment needs his account.
+
+Steps, from the KB node ([[id:67bc5994-a763-48e2-926f-4ac0d1bad3db][GL.iNet Comet (GL-RM1) - KVM]]):
+1. HDMI from truenas video out to the Comet's HD IN.
+2. USB-A-to-USB-C from the Comet to a truenas USB port (keyboard/mouse emulation).
+3. Ethernet to the network.
+4. Power via USB-C (5V/2A).
+5. Reach the web interface and enroll it in Tailscale, so it's usable when the LAN side of truenas is the thing that's broken.
+
+Then verify while truenas is healthy, rather than discovering the gaps during the next outage: confirm the console shows POST and the BIOS, that keyboard input reaches the box, and that Wake-on-LAN from the Comet actually powers it on. Enable WOL in the truenas BIOS if that last check fails — this outage never established whether it was on.
+
+Worth considering as a follow-up: the ATX power board accessory gives hard power-cycle control for a truly wedged box, which the KVM alone can't do.
+** DONE [#A] Review post-archsetup laptop setup steps (velox 2026-04-10)
+CLOSED: [2026-08-08 Sat]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-08-08
+:END:
+Closed at the 2026-08-08 session: every open item got its automate-vs-document
+call and the work landed the same night (tests green, committed). Residual:
+velox itself still needs the new tlp.d radio line and a dotfiles pull — folded
+into the [#A] sleep/suspend task, which works the same files on velox anyway.
+Items discovered during velox setup that needed manual intervention after archsetup.
+Decide which should be automated in archsetup vs documented as post-install steps.
+
+*** 2026-08-08 Sat @ 04:43:42 -0500 Automated radio enable via TLP (rfkill boot soft-block)
+Root cause sharpened during triage: archsetup masks systemd-rfkill on laptops
+(it fights TLP), so nothing restored radio state at boot — the "unblock once
+should stick" premise was wrong under the mask. Fix in the TLP custom conf:
+=DEVICES_TO_ENABLE_ON_STARTUP="bluetooth wifi"=, the TLP-native mechanism.
+configure_tlp_power parametrized for tests; covered by
+tests/installer-steps/test_configure_tlp_power.py.
+
+*** 2026-07-04 Sat @ 11:48:24 -0500 Automated /efi restrictive mount permissions in fstab generation
+archsetup:2827-2836 now rewrites the /efi fstab line to =fmask=0177,dmask=0077= (idempotent), so fresh installs no longer land the world-accessible =fmask=0022,dmask=0022= default. Confirmed via the 2026-07-04 task audit. (Original velox note: default vfat mount had =fmask=0022,dmask=0022=, hand-fixed to restrictive; bootctl warned about a world-accessible random-seed file.)
+
+*** 2026-08-08 Sat @ 04:43:42 -0500 Automated tmp.mount mask for ZFS /tmp
+New mask_tmp_mount_for_zfs, called from configure_snapshots' ZFS branch:
+masks tmp.mount only when the pool actually carries a dataset mounted at
+/tmp (exact match), silent no-op without zfs or without the dataset. Covered
+by tests/installer-steps/test_mask_tmp_mount_for_zfs.py; the orchestrator
+dispatch pin updated.
+
+*** 2026-08-08 Sat @ 04:43:42 -0500 Automated CPU microcode install by vendor
+New install_cpu_microcode, first in boot_ux so grub-mkconfig and mkinitcpio's
+microcode hook both see the installed /boot/<vendor>-ucode.img: vendor_id from
+/proc/cpuinfo → intel-ucode / amd-ucode, error_warn on unknown vendor.
+Covered by tests/installer-steps/test_install_cpu_microcode.py; boot_ux
+sequence pin updated.
+
+*** 2026-07-04 Sat @ 11:48:24 -0500 Automated syncthing user-service enable in archsetup
+archsetup:2263-2271 now installs syncthing and enables the user service (via symlink), so fresh installs no longer leave it installed-but-disabled. Confirmed via the 2026-07-04 task audit. (Original velox note: package installed but service not enabled; hand-fixed with =systemctl enable --now syncthing@cjennings=.)
+
+*** 2026-08-08 Sat @ 04:43:42 -0500 Closed the awww-daemon crash watch — no recurrence
+The April boot crash never recurred across four months of daily use on both
+machines (and the wallpaper stack has since been reworked). Reopen as its own
+bug with fresh evidence if it ever comes back.
+
+*** 2026-08-08 Sat @ 04:43:42 -0500 Automated touchpad device detection in the pointer scripts
+The scripts were already in stowed dotfiles with binds — the open half was the
+hardcoded Framework device name. Both touchpad-auto and toggle-touchpad now
+auto-detect the touchpad (first pointer named *touchpad*, pixa fallback) and
+derive the internal-pointer exclusion set from the detected name, so they
+agree on any machine. Test seams added (--detect / --has-external-mouse);
+tests/touchpad-auto/ new, toggle-touchpad suite still green. Dotfiles commit;
+velox picks it up on its next pull.
+
+*** 2026-08-08 Sat @ 04:43:42 -0500 Documented bluetooth pairing in the post-install checklist
+Inherently interactive, so it can't ride the installer. Documented in the new
+[[file:docs/post-install-checklist.org][docs/post-install-checklist.org]] along
+with the Proton Bridge steps — the standing home for manual post-install work.
+Consider: document as post-install step. No automation possible.
+
+*** 2026-05-26 Tue @ 13:32:31 -0500 pocketbook install concern moot — pulled from publication, folded in-tree
+Resolved by removing pocketbook from archsetup's provisioning entirely. It's nowhere near ready, so the github mirror + cjennings.net repo were deleted and the project was folded into the archsetup tree at =pocketbook/=. Dropped the =gtk4-layer-shell= dep + =pip_install= from =archsetup= and the clone from =scripts/post-install.sh=. No fresh install pulls pocketbook now, so "not installed on velox" no longer applies. Re-wiring the install is tracked in the new pocketbook development backlog.
+
+*** TODO Review: Tailscale needs login after install
+~tailscaled~ service was enabled but needed ~tailscale up~ for interactive auth.
+Old machine entry needed cleanup in admin console.
+Consider: document as post-install step.
+
+*** TODO Review: docs/ directories need manual sync from existing machine
+docs/ dirs (gitignored) for ~/code and ~/projects repos needed scp/rsync from ratio.
+Same for ~/.emacs.d/docs/. Not in git, so not available after clone.
+Consider: document as post-install step or create a sync script.
+** DONE [#C] Waybar modules run together — need subtle separators :bug:dotfiles:waybar:
+CLOSED: [2026-08-08 Sat]
+Closed at the 2026-08-08 task review: Craig confirms the separator work landed
+a while back and the bar reads correctly now.
+Craig misreads where one module ends and the next begins — the wind (weather) value runs straight into the date with no visual stop, so he reads the wind figure as the start of the date. Add a light, subtle separator or spacing between adjacent Waybar modules.
+Grading: Minor severity (legibility, nothing broken) x frequent (every glance at the bar) = P3 = [#C].
+Not fully :solo: — needs Craig's eye on the result (separator style is a taste call, plus a live visual check). Prior work added a date-facing divider (dotfiles 103cccb); evidently not enough, so revisit the whole inter-module treatment rather than just the weather/date seam. From .emacs.d handoff 2026-07-20-1114 (roam capture; waybar is archsetup-owned per the dotfiles standing rule).
+** CANCELLED [#C] Add a whole-display dim mode :feature:hyprland:
+CLOSED: [2026-08-08 Sat]
+Killed at the 2026-08-08 task review: the July auto-dim work covers the actual
+need; no separate dim-everything mode wanted.
+Extend auto-dim with an explicit “dim everything” setting for bright
+non-dark-mode contexts, with a security/usability review of its scope.
+** DONE [#C] Fix install errors surfaced by the 2026-05-11 VM test run
+CLOSED: [2026-08-08 Sat]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-06
+:END:
+Closed at the 2026-08-08 task review: every archsetup-attributable error was
+fixed and verified (fontconfig, dconf x2, emacs-stow, AUR exit-0 logging at
+the root); the residual four reproduce unchanged and are diagnosed
+environment/non-critical, with two 2026-06-28 full runs attributing zero
+issues to archsetup. Residual thread: confirm the firewall nf_tables pair on
+bare metal at the next real install — no container task needed to carry it.
+*** 2026-06-28 Sun @ 13:29:29 -0400 Audit reconcile: 2026-06-28 btrfs+zfs runs reproduce the same residual set
+Newer full runs landed since the 2026-06-11 reconcile below: the 2026-06-25 zfs run (Testinfra 96/0) and the 2026-06-28 btrfs+zfs runs (97/0, "zero attributed issues"). The residual four were NOT fixed and reproduce unchanged: =enabling firewall= (archsetup:1496-1498, carries a VM-kernel note), =enabling gamemode for user= (archsetup:2221, non-critical), and =tidaler (AUR)=. Zero archsetup-attributed Testinfra issues across both profiles confirms these are environment / non-critical, not archsetup bugs. Bare-metal confirmation of the firewall pair is still the open thread.
+
+*** 2026-06-15 Mon @ 23:53:21 -0500 Audit reconcile: latest VM run (2026-06-11) confirms the surviving error set
+The most recent VM run (=test-results/20260611-113904/=) carries four error-summary entries: =enabling firewall= + =verifying firewall is active= (the iptables/nf_tables "Could not fetch rule set generation id" pair, still unconfirmed on bare metal), =enabling gamemode for user= (non-critical), and =tidaler (AUR)=. The earlier fontconfig/dconf fixes held — none reappear. So the count is down from the 7→6 anchor below to four, all of them the known-residual items already itemized.
+Errors logged during the VM install. Status as of the 2026-05-11 18:36 run (=test-results/20260511-183643/archsetup-output.log=) after the =48c9439= fontconfig/dconf fix: 7 → 6.
+- refreshing font cache — RESOLVED in =48c9439= (now installs =fontconfig= before calling =fc-cache=).
+- configuring GTK file chooser — RESOLVED in =ecab29f= (switched to a system-wide dconf db at =/etc/dconf/db/site.d/=; needs no session bus during install).
+- configuring GNOME interface settings in dconf — RESOLVED in =ecab29f= (same fix as the GTK file chooser above).
+- enabling firewall — exit 1: =iptables v1.8.13 (nf_tables): Could not fetch rule set generation id: Invalid argument=. Still present in the 18:36 run; likely a VM-kernel/nf_tables artifact — confirm on bare metal before treating as an archsetup bug.
+- verifying firewall is active — exit 1 (follow-on from the firewall-enable error).
+- enabling gamemode for user — exit 1 → step "gaming" FAILED — non-critical.
+- tidaler (AUR) — logged in the error summary with exit code 0 (odd; logging quirk or transient AUR build noise?).
+Also seen in the 18:36 run's log-diff (post-install systemd noise, probably VM-environment): =pam_systemd … CreateSession failed= / =logind: Failed to start session scope … Permission denied=, and =Failed to start Proton VPN Daemon= (no VPN config in the test VM).
+
+*** 2026-05-19 Tue @ 13:18:56 -0500 Fixed AUR exit-0 logging bug at the root
+Root cause was in =retry_install=: =last_exit_code=$?= ran AFTER =if eval ...; then return 0; fi=. Bash defines an if-compound's exit status as zero when no condition tested true, so a failing eval's exit code got overwritten with 0 before reaching =error_warn=. Fix in =8221c54=: capture =$?= from =eval= directly into a local var, then compare against the captured value in the if. VM-verified in =test-results/20260519-115318/=: =mkinitcpio-firmware (AUR)= and =tidaler (AUR)= now report =error code: 1= (yay's actual exit) instead of the misleading =error code: 0=. The same packages still appear in the summary because yay returns non-zero when sub-deps fail to build (e.g. =aic94xx-firmware=), but the codes are accurate now. If the underlying sub-dep failures stay noisy, that's a separate concern — open a new task.
+
+*** 2026-05-16 Sat @ 09:00:41 -0500 AI Response: Surfaced the expanded AUR-exit-0 pattern
+2026-05-16 07:40 VM run passed (52/0/5) with the same warning profile as the 2026-05-11 18:36 run. Error count went 7 → 13: 5 fixed/unchanged, +5 new AUR-exit-0 entries (broadens the existing tidaler item into the dedicated =[#B]= subtask above), +1 genuinely new error in =setting up emacs configuration files= (=git pull= ran in =~/.emacs.d= which existed from stow but had no =.git=). Patched =archsetup:1932-1945= with a three-branch check: clone if missing/empty, pull if =.git= exists, =git init=/=fetch=/=checkout= in place if the dir came from stow.
+
+*** 2026-05-19 Tue @ 01:25:26 -0500 Verified the b9907c7 emacs-stow fix end-to-end
+=make test= 21:44 → 22:29 (42 min), =test-results/20260518-214516/=. 52/0/5, =ArchSetup Exit Code: 0=. The third-branch path fired correctly — install log =archsetup-2026-05-18-21-45-46.log:14358-14365= shows =From https://git.cjennings.net/dotemacs= → =[new branch] main -> origin/main= → =Reset branch 'main'= → =branch 'main' set up to track 'origin/main'=. No exit-128, no =fatal: not a git repository=. Error Summary down to 7 (was 13 on 2026-05-16); the emacs entry is gone. AUR exit-0 logging triggered for 2 packages this run (mkinitcpio-firmware, tidaler) vs 6 on 2026-05-16 — same bug class, fewer triggers, still tracked under =[#B] AUR exit-0 logged as error=. Issue Attribution: 1 ARCHSETUP entry (Proton VPN Daemon failed — known VM-no-VPN-config artifact). Cleanup ran clean via the normal path.
+** CANCELLED [#C] Review current tool pain points annually
+CLOSED: [2026-08-08 Sat]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-06
+:END:
+Killed at the 2026-08-08 task review: an undated annual intention that never
+fired — pain points get surfaced organically as they bite.
+Once-yearly systematic inventory of known deficiencies and friction points in current toolset
+** DONE [#B] Podman API socket and camera-passthrough udev rule :feature:solo:
+CLOSED: [2026-08-09 Sun]
+:PROPERTIES:
+:CREATED: [2026-08-07 Fri]
+:LAST_REVIEWED: 2026-08-07
+:END:
+Shipped 2026-08-09: the installer enables the rootless podman socket at
+install time (enable_user_service grew a wants-target arg so socket units
+land in sockets.target.wants) and ships
+=72-usb-passthrough-cameras.rules= — numbered below 73 per the winvm
+rule-ordering correction, GROUP/MODE as the verified grant, uaccess tag kept.
+Applied live on ratio (socket enabled+active, 99- file retired, udev
+reloaded); velox apply rides the velox-return riders on the sleep/suspend
+task. The uaccess-alone hypothesis stays untested until a camera is attached.
+From winvm 2026-08-07 (ratio). Two one-time machine-level setups, both live on
+ratio and absent on velox; full evidence and rationale in
+[[file:docs/design/2026-08-07-podman-socket-and-camera-udev.md]].
+
+- Enable the rootless podman socket at install time
+ (=systemctl --user enable --now podman.socket=). Socket-activated, zero idle
+ cost; every podman GUI/API client needs it, and its absence fails silently
+ (Pods opens to an empty window). The installer already carries the
+ "=systemctl --user enable= fails during install" workaround pattern
+ (=archsetup:1270=, =:2722=) — use it.
+- Ship a udev rule granting GROUP="video", MODE="0660" on the OBSBOT
+ (3564:ff02) and BRIO (046d:085e) USB nodes so =usbredirect= can claim them
+ for VM passthrough. CORRECTED (winvm, 2026-08-08): the original "uaccess
+ can't ACL raw USB nodes" claim was wrong — the mechanism is rule ordering.
+ The ACL is applied by =73-seat-late.rules=, so a =99-= rule adds the tag
+ after that already ran; distro rules that add the tag all sort at or below
+ 70. [@70] So number our file below 73 (e.g. =72-usb-passthrough-cameras.rules=),
+ keep the verified GROUP/MODE grant, and keep the tag — correctly ordered it
+ may make uaccess work on its own (untested hypothesis; a tighter grant if
+ it holds, needs the camera plugged in to verify). Reconcile ratio's
+ existing =99-= file (winvm installed it) when the installer version lands.
+
+Scope: installer step + rule file + tests per existing shapes, and apply both
+live to velox over tailscale (daily-driver sync — neither exists there today).
+** DONE [#B] Velox touchpad interrupt line is dead — needs a part or a BIOS fix :bug:velox:hardware:
+CLOSED: [2026-08-15 Sat]
+:PROPERTIES:
+:CREATED: [2026-08-15 Sat]
+:LAST_REVIEWED: 2026-08-15
+:END:
+*Fixed 2026-08-15 23:05 by reseating the correct connector* — a seating fault
+all along, no part needed. Verified at the kernel level on the 23:05 boot: the
+=did not ack reset within 1000 ms= message is gone (clean handshake), and the
+interrupt count went 0 → 1795. Power-key events also zero, so both faults from
+the mainboard swap are closed.
+
+What made this take three attempts is worth keeping: two of the connectors on
+that board were decoys. The input-cover ribbon looked like the obvious suspect
+and fixing it *did* resolve the power button, which made it look like the whole
+answer. Then the 4-pin connector next to the printed =TOUCHPAD= label looked
+like the touchpad's own — and its cable is silkscreened =PIN 1-2 - GND /
+PIN 3-4 - VCC=, four contacts of pure power, incapable of carrying i2c or an
+interrupt. Reading that silkscreen off the photo is what ruled it out and sent
+the search to the ribbon that actually crosses to the mainboard.
+
+The ordered touchpad becomes a spare, which is what Craig wanted from it anyway.
+The diagnostic path below is left intact — it is the reusable part: =dmesg=
+for the i2c-HID reset message and the interrupt count in =/proc/interrupts=
+together separate "device absent" from "device present but its interrupt line is
+open", and a live USB separates hardware from software in two minutes.
+Split from the ribbon-reseat task 2026-08-15 once the reseat fixed the power
+button and left this untouched — they are two faults, not one.
+
+*Diagnosed to the interrupt line specifically, with software eliminated.*
+- The i2c *data* path works. =i2c_hid_acpi= read the HID descriptor, returned
+ the right product ID (=093A:0274=), =hid-multitouch= bound, and input6/7/8/9
+ were created. A descriptor read is a real bus transaction, so the device is
+ electrically present and answering.
+- The *interrupt* path never fires. IRQ 81, =amd_gpio= hwirq 8, level-triggered,
+ =actions=PIXA3854:00= — the handler is correctly registered on the pin the
+ firmware names. Count is 0 across all 24 CPUs, including during active
+ swiping.
+- =dmesg=: =i2c_hid_acpi i2c-PIXA3854:00: device did not ack reset within 1000 ms=.
+ The i2c-HID reset handshake is acknowledged *by the device asserting the
+ interrupt*, so the first operation needing that line already failed at boot,
+ before anything touched the pad. That is why the fault reproduces on any boot
+ in ten seconds.
+- *Software ruled out by live USB.* Same "did not ack reset" message and no
+ pointer movement under Ubuntu's kernel (2026-08-15). Not a driver, not
+ libinput, not Hyprland, not this install.
+
+Three candidates remain, all needing a part or firmware:
+1. Open conductor on the touchpad's own cable or a bad contact at either end.
+ Framework sells "Touchpad Cable" as a discrete spare, so it is separately
+ replaceable — and the input-cover ribbon reseat would not have touched it.
+2. The touchpad module's interrupt output is dead while its i2c slave still
+ answers. Indistinguishable from 1 without swapping parts.
+3. Firmware naming the wrong GPIO. The DSDT says =amd_gpio= pin 8; if this
+ board revision routes the interrupt elsewhere, the kernel watches a pin that
+ never toggles. Plausible because the mainboard is days old to this machine
+ and its firmware already needed the PSR workaround. BIOS is 03.05
+ (2025-10-30); kernel 6.18.44-1-lts.
+
+*The connector that was reseated is NOT the touchpad's — confirmed from the
+board photo.* Craig reseated the 4-pin connector near the printed word
+=TOUCHPAD=. Its cable is silkscreened =PIN 1-2 - GND / PIN 3-4 - VCC= — four
+contacts, all of them power. No clock, no data, no interrupt; almost certainly
+the keyboard backlight feed. An i2c-HID touchpad cannot run through it, so that
+reseat could never have fixed this, and *the free retry remains untried*.
+Photo: [[file:working/velox-touchpad-interrupt/touchpad-module-underside-2026-08-15.jpg][working/velox-touchpad-interrupt/touchpad-module-underside-2026-08-15.jpg]].
+
+Visible on that board: the controller IC marked =PCT3854= (matching the kernel's
+=PIXA3854=), a larger =CON3= carrying a blue-backed ribbon with "26" marked
+beside it, a white ZIF past the Framework QR label, and a further connector at
+the board's end. The one that matters is whichever ribbon physically *leaves the
+input cover and reaches the mainboard* — that is the touchpad cable, and its far
+end is the press-fit connector at the board. Reseat both ends of that one before
+fitting any new part.
+
+*BIOS 04.02 exists but does not look relevant.* Checked 2026-08-15 with velox
+on AC at 90%: fwupd offers 0.0.3.5 → 0.0.4.2. Read the changelog — the only
+touchpad line is haptic-touchpad support for the Laptop 13 *Pro* chassis, and
+this machine has a conventional PixArt =PIXA3854=. The rest is BIOS Setup
+layout, option naming, TPM behavior, iGPU defaults, PMF slider. Nothing about
+GPIO routing or interrupt configuration. So candidate 3's cheap test is weaker
+than it looked when it was filed sight-unseen; still worth doing (unlisted
+fixes happen, and ACPI tables change), just no longer the front-runner.
+Deliberately deferred past the flight — a cleared NVRAM is the failure that
+started this whole rebuild. Boot-entry recovery reference captured at
+[[file:docs/2026-08-15-velox-uefi-boot-entry-reference.org][docs/2026-08-15-velox-uefi-boot-entry-reference.org]].
+
+Order of attack on return, cheapest first: reseat the touchpad's *own* press
+connector at the mainboard (free, untried) → BIOS 04.02 → fit the replacement
+touchpad. Craig's call 2026-08-15: order the parts now anyway, since they are
+worth holding as spares regardless of which candidate wins.
+
+*What to order.* The replacement *Touchpad* ships with the Touchpad Cable
+pre-installed, so that single part covers candidates 1 and 2 together — no need
+to buy both to cover both. A bare Touchpad Cable is worth adding only as a cheap
+spare. The *Input Cover* is a different and more expensive part, and nothing
+points at it: the keyboard works, so the input-cover ribbon is carrying signal.
+Framework's marketplace renders its catalogue in JavaScript, so prices could not
+be read programmatically — search "Touchpad" under Laptop 13 parts.
+
+*Also worth a Framework support ticket* — the touchpad died coincident with
+their mainboard swap, which may put it inside whatever recourse that carries.
+
+Grading: Major severity (a laptop's built-in pointer is entirely dead — the
+counter-argument is that an external mouse is a complete workaround, which
+would make it Minor; I took Major because losing the integrated pointer degrades
+the machine's portability, which is the whole point of the laptop) x every user,
+every time = P1 = [#A]. Filed [#B] rather than [#A] only because an [#A] must
+carry a date and Craig's return date isn't known yet — date it and raise it to
+[#A] when it is.
+
+Workaround in the meantime: Bluetooth mouse, already in use.
+** DONE [#C] hypridle.conf is generated per-machine but tracked :refactor:dotfiles:
+CLOSED: [2026-08-14 Fri]
+:PROPERTIES:
+:CREATED: [2026-08-14 Fri]
+:LAST_REVIEWED: 2026-08-14
+:END:
+Fixed in dotfiles 83ae7aa. The render is untracked and gitignored; the
+store is the only source of truth; hypridle-start renders at session start
+and owns the fallback ordering. Both machines reconciled and re-stowed:
+velox's tree is clean for the first time today and still carries its own
+policy (dim 5, lock 10, suspend-then-hibernate 30), ratio's config is
+byte-unchanged.
+=hyprland/.config/hypr/hypridle.conf= is rendered by the settings panel
+from each machine's own stage config, and it is also a tracked file stowed
+to every machine. So a machine whose idle policy differs from the
+committed default carries permanent working-tree dirt, and every pull
+there needs a stash/pop dance (velox, twice on 2026-08-14). Worse, the
+committed copy is whichever machine last committed it, which is how a
+desktop ended up tracking a laptop's suspend-then-hibernate line.
+Options to weigh: gitignore the rendered file and track only a template or
+the stage defaults; render to a non-stowed path and have hypridle read
+that; or keep it tracked but commit a machine-neutral render. The first
+looks right — the store already holds the real source of truth, and the
+rendered file is a build artifact.
+** DONE [#A] Reseat velox input-cover ribbon — phantom power button :bug:velox:hardware:
+CLOSED: [2026-08-26 Wed] DEADLINE: <2026-08-26 Wed>
+:PROPERTIES:
+:CREATED: [2026-08-13 Thu]
+:LAST_REVIEWED: 2026-08-13
+:END:
+Machine off, lift the input cover (Framework QR-guided procedure, 5
+fasteners), reseat its ribbon connector to the mainboard — disturbed in the
+2026-08-13 board swap. Root cause of every "mystery reboot" that day:
+chassis flex (flash-drive touch, ethernet bump, lid partially lowered)
+fired phantom power-button presses — journalctl -b -1 showed "Power key
+pressed short." → orderly logind poweroff, then the glitching button
+powered it back on. While in there, reseat the USB expansion cards too —
+the flaky slot (two hard resets, one no-enumeration) is likely the same
+flex problem.
+THIRD SYMPTOM (2026-08-13 evening): touchpad delivers ZERO input events —
+15s synchronized libinput debug-events capture while swiping caught
+nothing, though i2c enumeration and a driver rebind handshake are clean.
+Signature of a dead interrupt line on the same ribbon. Keyboard + power
+LED lines work; BT mouse is the interim pointer.
+ESCALATED 2026-08-13 21:00: a fourth event killed the machine THROUGH the
+shield. Previous boot's journal ends mid-line (tailscaled chatter) with no
+shutdown sequence at all — a hard power cut, not logind acting. So the
+glitch now reaches the EC/hardware power path, which no software setting
+can intercept. The reseat is the only fix, and this is a
+lose-work-without-warning failure mode, not an inconvenience.
+Interim shield (already live): /etc/systemd/logind.conf.d/powerkey.conf
+sets HandlePowerKey=ignore — phantom presses log but do nothing; EC-level
+10s hold still force-cuts. Consider keeping it even after the repair.
+Verify after reseat: flex the chassis edges + partially lower the lid, then
+grep the journal for new "Power key pressed" lines — zero means fixed.
+Must be done before the Sunday flight — a phantom press mid-travel with the
+shield on is survivable, but the connector should not be trusted at 30,000
+feet on the loose setting.
+
+*** 2026-08-19 Wed @ 14:40:00 -0700 Retracted: the RTC reset is not this task's, and I should not have filed it here
+I attributed the 2026-08-19 network outage to this ribbon earlier today. Craig
+pushed back — he reseated it before the trip to get the touchpad working — and
+he is right. The evidence does not support the attribution and some of it points
+the other way.
+
+What actually holds. Boot -3 ended at 01:33:18 with no shutdown sequence: no
+power-off target, no unmounting. The next boot's kernel line reads =rtc_cmos
+00:01: setting system clock to 2025-01-01T00:00:16 UTC=, a firmware default, so
+the RTC was reset rather than drifted. No firmware update was applied
+(=fwupdmgr get-history= is empty) and the battery is fine.
+
+What refutes the ribbon. This boot logged *zero* =Power key pressed= events, and
+so did the four boots before it. The phantom-press symptom had genuinely stopped
+after 08-15, exactly as the 08-16 session recorded. The earlier events logged a
+power-key press and an orderly poweroff; this logged neither, which makes it a
+different signature, not a worse version of the same one.
+
+What I got wrong methodologically: I anchored on the most salient open hardware
+task and read association as evidence. I even wrote "I can't prove it is the
+same connector" and then filed it here anyway, which is the tell.
+
+Two things I checked and can rule out. There were no OOM kills — the 3,433
+matching lines are a systemd unit named "Periodically re-score Claude Code
+processes for the OOM-killer" firing on a timer, not memory pressure, and there
+is not a single "Killed process" line. Thermal is clean; the only mentions are
+boot-time zone registration at 34C and 45C.
+
+One real thing the same window did surface, tracked separately: a python3 crash
+loop, 251 core dumps in the final ten minutes, SIGABRT with =XFreeThreads= and
+=PyEval_RestoreThread= in the trace. It does not explain the RTC, because
+software cannot clear it, but it is its own problem.
+
+The open question that would settle the RTC is for Craig, not the journal: a
+long power-button hold on a Framework triggers an EC-level reset that clears the
+RTC, which fits a wedged machine being forced off. A 4-second hold would not.
+
+*** 2026-08-17 Mon @ 19:57:42 -0700 Not done, and the two symptoms now disagree
+The reseat did not happen before the flight, and velox is travelling. The
+deadline blew past on 08-14.
+
+The two symptoms have separated, which is worth recording because it changes
+what the evidence proves. The phantom presses have stopped: fifteen "Power key
+pressed" entries between 08-14 04:29 and 08-15 20:04, then nothing at all
+across five boots including today's. The touchpad has not — there is still no
+touchpad node under =/dev/input/by-path/=, which is the same dead interrupt
+line the body describes.
+
+So the quiet power button is not evidence the connector reseated itself. The
+interrupt line is the symptom that cannot be masked in software, and it is
+still dead, so the ribbon is still unseated. The most likely reason the
+presses stopped is that the machine has been sitting on hotel surfaces instead
+of being carried and flexed.
+
+The interim shield is still live (=HandlePowerKey=ignore=), and the escalation
+note stands: an EC-level glitch cuts power below systemd regardless of it.
+*** 2026-08-15 Sat @ 23:05:00 -0500 The reseat did happen, and the touchpad came back — this contradicts the 08-17 read
+Recording this because a parallel session concluded on 08-17 that the reseat had
+not happened and the touchpad was still dead. Both halves were done and verified
+that night, so the two accounts disagree and the disagreement should be visible
+rather than silently resolved by whichever session committed last.
+
+What was done: the input-cover ribbon was reseated first, which fixed the
+phantom power button — the 22:09 boot logged zero =Power key pressed= lines
+after Craig flexed the chassis, against nine on the boot before. The touchpad
+did not change, because the input-cover ribbon is not its connector. The 4-pin
+connector beside the printed =TOUCHPAD= label is silkscreened =PIN 1-2 GND /
+PIN 3-4 VCC= — pure power, so it cannot carry i2c or an interrupt. Reseating the
+ribbon that actually crosses to the mainboard fixed it.
+
+Measured, not assumed: the touchpad interrupt (=amd_gpio= pin 8) went from 0
+counts across all 24 CPUs to 1795, and =i2c_hid_acpi ... did not ack reset
+within 1000 ms= disappeared from the boot log. Craig confirmed the pointer moved.
+
+*Why the 08-17 probe likely misread it:* it checked for a node under
+=/dev/input/by-path/=. i2c-HID touchpads frequently get no =by-path= symlink
+even when fully working, so its absence is not evidence of a dead interrupt
+line. The falsifiable check is the interrupt count in =/proc/interrupts= while
+the pad is being touched, or the reset message in =dmesg=.
+
+*Left open rather than closed* — velox was refusing ssh at merge time on 08-20,
+so the current state could not be re-verified, and a later regression cannot be
+ruled out. One second of Craig's time settles it: move the pointer. If it works,
+close this; if it does not, the interrupt line went back down and that is new
+information.
+
+*** 2026-08-26 Wed @ 22:30:46 -0600 Closed: the reseat was done on 08-15 and the task was never marked
+I reseated the ribbon on 2026-08-15 and never closed this. The 08-15 entry
+above already records the verification: zero =Power key pressed= lines on the
+22:09 boot after flexing the chassis, the touchpad interrupt count back up
+once the right connector was reseated. This boot shows zero presses as well.
+The interim shield (=HandlePowerKey=ignore= in
+=/etc/systemd/logind.conf.d/powerkey.conf=) is still live; I'm leaving it in
+place, since a phantom press with it on costs nothing and without it costs
+the session.
+** CANCELLED [#B] agent-text relay reports success for a message that went nowhere :bug:
+CLOSED: [2026-08-19 Wed]
+:PROPERTIES:
+:CREATED: [2026-08-19 Wed]
+:LAST_REVIEWED: 2026-08-19
+:END:
+
+Not a defect. rulesets refuted it with measurements and I reproduced theirs
+before accepting: on velox, whose account store is empty,
+=signal-cli -a +15550000000 send= exits 1 with "User +15550000000 is not
+registered", and =ssh 100.71.182.1 'exit 7'= returns 7, so a non-zero code
+propagates faithfully back through the relay. The loop's
+=[ "$rc" -eq 0 ] && break= therefore advances to the next host exactly as
+intended. signal-cli fails closed.
+
+I filed this off a conditional in their handoff — ".emacs.d raised a case
+neither of you tested ... *if* signal-cli send exits zero against an empty
+account store" — and turned the "if" into a graded [#B] with a =:blocked:= tag
+on another project, without running the one command that settles it. The
+machine that proves it was in front of me the whole time. Their ask is fair and
+I am recording it rather than the outcome alone: verify before filing a defect
+against someone else's work, especially one carrying a blocking tag.
+** DONE [#B] Clock/DNS bootstrap deadlock — recovery needs a second device :bug:velox:
+CLOSED: [2026-08-19 Wed]
+:PROPERTIES:
+:CREATED: [2026-08-19 Wed]
+:LAST_REVIEWED: 2026-08-19
+:END:
+
+The installer wrote both halves of a deadlock. =configure_dns= pins
+=DNSOverTLS=yes= with =DNSSEC=yes=, and both validate against the wall clock;
+the chrony step enables chronyd without writing a config, so the machine runs
+Arch's stock one whose only source is =pool 2.arch.pool.ntp.org= — a hostname.
+Boot with a wrong clock and DoT certificate validation fails, so nothing
+resolves; chrony then cannot resolve its pool, so the clock stays wrong.
+Neither side moves. It caught velox on the road 2026-08-19 and had to be
+diagnosed from a phone.
+
+Fixed at the root: the installer now writes
+=/etc/chrony.d/10-bootstrap-ip-ntp.conf= with two IP-addressed Cloudflare
+sources and points stock chrony.conf at the drop-in. An address needs no DNS
+and carries no certificate, so the escape hatch holds whatever broke the clock.
+velox has the same drop-in applied live, verified with =chronyc -n sources=
+(=162.159.200.1= selected) and =timedatectl= reporting synchronized.
+
+What is left here is the part I could not verify: the decisive test is a full
+power-down and cold boot, confirming the clock corrects itself untouched. See
+the manual-testing entry. Until that runs, the fix is sound by construction
+rather than demonstrated.
+
+Grading: Critical severity (total loss of network — no DNS means no egress, and
+recovery needs a second device) x some users sometimes (only machines that boot
+with a wrong clock, which is any RTC fault, BIOS reset, or drained cell) = P2 =
+[#B]. Graded on the being-in-it, not the getting-into-it: once the machine is in
+this state it is fully offline with no local path out.
+
+*** 2026-08-19 Wed @ 12:25:00 -0700 Reproduced it, and the mechanism was not what either of us said
+I wound velox's clock back 27 days with chronyd stopped and watched it fail.
+Resolution died outright, and plain UDP/53 to 1.1.1.1 kept answering throughout
+— the discriminator the doctor keys on, confirmed live rather than reasoned.
+
+The cause is DNSSEC, not DNS-over-TLS. resolved logged =signature-expired=
+against the root DNSKEY and every DS beneath it. The DoT handshake to
+=1.1.1.1:853= verified clean at that same clock, and the Cloudflare certificate
+runs Dec 2025 to Dec 2026, so it was never outside its window. An RRSIG window
+is days to weeks and a certificate is good for a year, so a skew that breaks
+DNSSEC normally leaves DoT untouched. The phone session blamed the certificate
+and I carried that forward into the first commit; both were wrong.
+
+=DNSSEC=allow-downgrade= does not rescue it either, which matters because it is
+the obvious reach and it is what ratio runs. resolved downgrades when a server
+lacks DNSSEC support, and a signature-window failure is a validation failure, so
+no downgrade fires. Six retries over eighteen seconds plus
+=resolvectl reset-server-features=, all dead. I briefly believed otherwise off a
+test whose success was a cache hit (=Data from: cache network=).
+
+So ratio was exposed after all, and I have given it the same drop-in. Its
+=162.159.200.1= is selected and its clock is synchronized.
+
+The fix itself is verified end to end: with the clock wound back and no DNS at
+all, chronyd reached the IP-addressed source and stepped the clock from
+2026-07-23 straight back to 2026-08-19. That is the whole claim, demonstrated
+rather than argued.
+
+Also settled: the clock landed on 2026-07-23 because that is systemd 261.2's
+build date to the minute (=/usr/lib/systemd/systemd=, 10:43:59), and systemd
+advances a garbage RTC to its own build epoch at boot. Not timesyncd's
+last-good-sync timestamp, which cannot be it — timesyncd is disabled here. That
+also confirms the RTC really was reading earlier than that, so the coin cell
+stays the prime suspect.
+
+*** 2026-08-19 Wed @ 10:12:00 -0700 Root fix, doctor verdict, and taxonomy entry landed
+The installer carries the drop-in; =post-rebuild-check= grew a sixth check that
+fails a machine whose every NTP source is a hostname; the net failure taxonomy
+gained the mode in its DNS layer plus a cluster 5 triage line, and its existing
+egress-layer clock entry now says outright that its remedy does not apply when
+DoT or DNSSEC is on.
+
+The doctor half is in dotfiles: =classify.py= reached "DNS not resolving → net
+repair dns-test" here, which cannot help, because every public resolver fails
+the same clock-sensitive validation — so the doctor sent you round a loop. It
+now emits a =clock-dns= row ahead of the generic DNS verdict. Detection is
+deliberately DNS-free: a local =timedatectl= read for sync state, and a bypass
+query addressed by IP over plain UDP/53 to tell "resolved is refusing to
+validate" apart from "DNS is genuinely dead".
+** DONE [#C] DNSSEC strictness on the travelling laptop :velox:
+CLOSED: [2026-08-19 Wed]
+:PROPERTIES:
+:CREATED: [2026-08-19 Wed]
+:LAST_REVIEWED: 2026-08-19
+:END:
+
+Craig chose =allow-downgrade= everywhere. Applied to velox, ratio, and the
+installer, and ratio's =DNSOverTLS= tightened from =opportunistic= to =yes= in
+the same pass, so all three now agree: encrypted DNS always, validation
+best-effort.
+
+The reasoning that settled it: the deadlock is fixed by the IP-addressed NTP
+source, and =allow-downgrade= was measured not to help with it at all. What
+=allow-downgrade= does buy is the venue-resolver case the taxonomy documents,
+where =yes= turns a resolver that mangles DNSSEC records into no answer at all.
+That is a hotel and airport problem, so it is velox's problem, and the
+encryption is the half worth being strict about.
+** CANCELLED [#C] Branch network policy on laptop vs desktop in the installer :feature:
+CLOSED: [2026-08-19 Wed]
+:PROPERTIES:
+:CREATED: [2026-08-19 Wed]
+:LAST_REVIEWED: 2026-08-19
+:END:
+
+Cancelled because the decision above emptied it. All three motivating cases now
+want the same value on every machine: =DNSSEC=allow-downgrade=, a stable
+per-network wifi MAC, and an IP-addressed NTP source. A branch with nothing to
+put on either side is machinery built for a divergence that does not exist, and
+it would be the kind of scaffolding that rots unread.
+
+Worth keeping the observation, which is the part with a shelf life: when a
+network default does need to differ by machine class, the test already exists.
+=ls /sys/class/power_supply/BAT*= is what =prune_waybar_battery=, the ppd mask,
+and the TLP config all key on. Reopen this then rather than building it now.
+** DONE [#C] Automate the clock/DNS deadlock repair in the net doctor :feature:
+CLOSED: [2026-08-19 Wed]
+:PROPERTIES:
+:CREATED: [2026-08-19 Wed]
+:LAST_REVIEWED: 2026-08-19
+:END:
+
+Shipped as the =clock-ip-ntp= repair, and the verdict is =fixable= rather than
+terminal. Both open questions got answered by driving a real deadlock instead of
+reasoning about it: =chronyc add server= returns =200 OK= against a running
+chronyd, and =makestep= needs a sample to land, so it took four calls and about
+eight seconds rather than working on the first. The repair retries accordingly.
+
+Verified end to end on velox against a genuine deadlock (wrong clock, chronyd
+running with only an unresolvable hostname source, DNS dead): the repair
+corrected the clock in 6.1 seconds and DNS came back.
+
+The live run also caught a defect no unit test would have. The doctor reported
+"Saved password for SpectrumSetup-3C was rejected" — because
+=_recent_auth_failure= greps =journalctl --since -5min=, and a clock weeks off
+windows onto a different incident's entries. It would have sent Craig to
+re-enter a password that was never wrong. Fixed twice over: the journal half is
+now skipped when the clock is untrustworthy, and the clock verdict is ordered
+above the auth verdict, since everything below it reasons over timestamps that
+only mean something once the clock is right. Airplane mode and hard rfkill stay
+above, being physical states the clock has no bearing on.
+** DONE [#D] net-scenarios harness times out under back-to-back suite runs :test:tooling:
+CLOSED: [2026-08-19 Wed]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-24
+:END:
+=tests/net-scenarios/test_run_net_scenarios.py= errored on all 5 tests twice during round 12, each time =subprocess.TimeoutExpired= after its 20s budget on =scripts/testing/run-net-scenarios.sh --target root@fake-vm=. Both occurrences were in =make test-unit= runs launched immediately after a previous full run. It then passed 6 runs in a row (3 on a pristine tree, 3 with the round-12 change), and standalone it finishes in 0.09s, so this is not a regression from any code change.
+
+The harness stubs =ssh=, =rsync= and =jq= onto =PATH=, so nothing should touch the network at all — which is what makes a 20s timeout suspicious rather than merely slow. Worth reproducing under load before deciding whether the fix is a larger timeout or a real hang in the script. Evidence logs from the round: =/tmp/tu.log= and =/tmp/tu2.log= (tmpfs, gone after reboot).
+
+Not graded on the bug matrix: it is test infrastructure, not the shipped codebase.
+
+*** 2026-08-08 Sat @ 05:05:00 -0500 Recurred under concurrent load, same signature
+All 5 tests hit the 20s TimeoutExpired again during a =make test-unit= run
+that overlapped two review subagents running their own suites on the box.
+Standalone immediately after: 0.095s, all pass; the following quiet-machine
+full run was clean. Confirms the load-sensitivity read — reproduce under
+deliberate load before choosing between a bigger budget and a real hang.
+
+
+*** 2026-08-19 Wed @ 14:50:00 -0700 Root-caused and fixed: inherited stdin, not load
+Not load, and not the network. The harness stubs ssh as =cat >/dev/null=, which
+drains stdin to EOF. With no explicit stdin the stub inherits whatever the test
+runner had, so it returned instantly when stdin was redirected and blocked
+forever when it was a terminal or a live pipe. All five tests then burned their
+20-second budget.
+
+That is why it looked like a load effect: a run launched immediately after
+another inherited a different stdin than a standalone invocation. A/B measured
+today — =make test-unit </dev/null= exits 0, the same target with an open pipe
+on stdin hangs on all five. The note above guessed at "a larger timeout or a
+real hang in the script" and it was neither.
+
+Fixed by pinning =stdin=subprocess.DEVNULL= in =run_script=. Verified both ways:
+the previously-failing open-pipe case and the redirected case both pass in
+0.08s, and a full =make test-unit= under a live pipe is clean across 50 suites.
+** DONE [#A] powerprofilesctl crashes on a loop since ppd was masked :bug:velox:dotfiles:
+CLOSED: [2026-08-21 Fri]
+:PROPERTIES:
+:CREATED: [2026-08-17 Mon]
+:LAST_REVIEWED: 2026-08-17
+:END:
+Something polls power state every 10-30 seconds, and each poll runs
+=powerprofilesctl get=, which SIGABRTs. 47 coredumps on velox on 2026-08-17
+alone, the earliest at 08:34, four in one minute while I was watching.
+
+Cause is the 2026-08-16 fix that masked =power-profiles-daemon= so TLP
+survives on laptops. That fix is right and stays. What it did not account for
+is the settings module's power backing
+(=~/.dotfiles/settings/src/settings/power.py=), which shells out to
+=powerprofilesctl=. Against a masked unit the D-Bus activation fails with
+=NameHasNoOwner ... unit is masked=, and the caller aborts rather than
+degrading.
+
+Run by hand the same command exits 0 and prints the error, so the abort is
+context-dependent and the caller needs finding before the fix is written.
+Ratio does not mask ppd, which is why this is velox-only and why it appeared
+the day after the masking.
+
+Costs: journal spam, coredump disk churn, and repeated failed D-Bus
+activations on a travelling laptop's battery. It is also the leading suspect
+for the wedged user manager filed below.
+
+Fix shape: =power.py= should treat a masked or unavailable ppd as a
+first-class "no profile control here" state rather than an error path, and
+the poller should stop retrying a unit it has been told is masked. The
+machine-level half is already correct.
+
+Grading: Major severity (a crash loop burning battery and filling the
+journal, silently) x every user every time on any laptop with the TLP fix
+applied = P1 = [#A].
+
+*** 2026-08-17 Mon @ 19:57:42 -0700 The loop stopped at the reboot; the defect did not
+velox rebooted at 16:04 and there have been zero coredumps since, against 47
+in the twelve hours before it. So the loop is not currently burning anything.
+
+That is not a fix, and the distinction matters for whoever picks this up.
+=powerprofilesctl get= still fails exactly as recorded — =NameHasNoOwner ...
+unit is masked= — so every precondition for the loop is intact and it returns
+whenever the caller next polls. What the reboot cleared is the caller's state,
+not the bug.
+
+Narrowed the search the body asks for: =power.py= is the *only* file in
+dotfiles that shells out to =powerprofilesctl= (=SETTINGS_POWERPROFILESCTL=,
+line 14), so the caller is inside the settings module rather than waybar or a
+timer. Worth knowing that the coredumps are =powerprofilesctl= itself aborting
+— it is a python script, which is why they log as =/usr/bin/python3.14=
+SIGABRT rather than under its own name.
+
+Grade unchanged. The matrix inputs did not move: the severity is what happens
+while the machine is in that state, and the frequency row is every laptop
+carrying the TLP fix. A quiet interval since a reboot is not a frequency
+change.
+
+Fixed in dotfiles =e89d9db=. The caller was =waybar.py=, using =panel.read_state()= (the full snapshot of every control) to read one boolean, four bar modules deep on a 2-second interval. Two fixes, each needed alone: =panel.read_control()= reads a single control's backing, and =power.masked()= checks the mask symlink before shelling out. Verified with a logging stub: full snapshot unmasked calls powerprofilesctl once, masked calls it zero, and a waybar poll calls it zero even unmasked.
+** DONE [#A] The installer clones my two working repos shallow and read-only :bug:velox:
+CLOSED: [2026-08-21 Fri]
+:PROPERTIES:
+:CREATED: [2026-08-17 Mon]
+:LAST_REVIEWED: 2026-08-17
+:END:
+=archsetup:1432= clones the user's archsetup repo and =archsetup:1445= clones
+dotfiles, both with =--depth 1=. Those are not build directories. They are the
+two repos I actively develop in, and on velox they came back from the
+2026-08-13 rebuild with 7 commits of history each instead of 851.
+
+Found 2026-08-17, and found the worst way: I ran the credential-file history
+check that the GitHub-release task asks for, and it reported all five files
+absent from history with a clean exit. The real answer is that this clone
+cannot see the history those files live in. A shallow clone does not error on
+=git log -- <path>=, it answers "no commits" — so a security question came back
+falsely clean, and nothing about the output said otherwise.
+
+Everything else it breaks is quieter: =git log=, =blame=, =bisect=, and any
+archaeology past the boundary. The tree looks completely normal, which is why
+this survived four days on the machine.
+
+The right shape is already in the codebase. =scripts/post-install.sh:42-51=
+takes depth as a per-repo argument and defaults to a full clone, so wallpaper
+gets =--depth 1= and org does not. The AUR build clones (=archsetup:855=,
+=:1673=, =:1677=) are correctly shallow and stay that way. Only the two
+user-repo sites change.
+
+*Second defect, same two lines, found 2026-08-17 while pushing:* the dotfiles
+clone could not push at all. =archsetup:245= defaults =dotfiles_repo= to
+=https://git.cjennings.net/dotfiles.git=, the public read-only endpoint, so
+=git push= returned 403. Ratio uses =git@cjennings.net:dotfiles.git= and
+archsetup's own clone uses the matching ssh form, so velox was the odd one out
+purely because it was the machine rebuilt by the installer. Repointed velox's
+remote and pushed.
+
+That half needs a decision rather than a fix, which is why this task is no
+longer =:solo:=. The https default is *correct for a stranger* installing
+archsetup, who has no ssh key on the server, and this repo is being prepared
+for public release. It is wrong for my own machines, which need to push. The
+override already exists (=DOTFILES_REPO=, documented in
+=archsetup.conf.example=), so the question is only where my personal value
+lives: a config the personal ISO bakes in, a post-install step, or a detection
+that prefers ssh when a key is present. Craig's call.
+
+*Decided 2026-08-19: the ISO bakes the value, and a check nets the rest.*
+=archsetup:240= has the identical default for =archsetup_repo=, so this was
+always two repos rather than one. I ruled out detection — archsetup never
+restores =~/.ssh=, so key-presence at clone time depends on ordering it
+doesn't control, and "any key means ssh" would break a stranger who has an
+unrelated one. I ruled out a bare post-install step for the reason this whole
+class of bug exists: manual steps don't get run, which is why this sat four
+days. So the personal ISO carries =ARCHSETUP_REPO= / =DOTFILES_REPO= in the
+ssh form (noted on the secrets/ISO task), and =post-rebuild-check= check 8
+flags any working repo still on the read-only endpoint — covering curl|bash
+and stock-ISO installs, which the ISO value cannot reach.
+
+Repair on a machine already built: =git fetch --unshallow= in each repo, and
+=git remote set-url origin git@cjennings.net:<repo>.git= for dotfiles.
+
+Grading: Major severity (two working repos silently missing their history on
+the machine I develop on, and it returns confidently wrong answers to history
+questions rather than failing) x every user every time (every fresh install,
+both daily drivers) = P1 = [#A].
+
+Not :solo:. The depth half is (two lines plus tests in the existing
+=tests/installer-steps/= shape, verifiable by asserting the clone command
+carries no =--depth= for these two repos). The remote-URL half needs the
+decision above, so the task as a whole waits on it. Split it in two if the
+depth fix is wanted sooner.
+*** 2026-08-19 Wed @ 23:05:00 -0700 Dropped --depth from both user-repo clones
+=archsetup:1462= and =:1475= now clone full history;
+=tests/installer-steps/test_clone_user_repos.py= covers it with 8 cases, and
+one of them asserts the AUR build clones still carry =--depth 1= so the fix
+can't be over-applied by a careless repo-wide sed. Both my repos on velox were
+already unshallowed by hand last session, so this is prevention rather than
+repair.
+*** 2026-08-19 Wed @ 23:05:00 -0700 Settled the remote-URL half and netted it
+See the decision recorded above. The ISO half is a note on the secrets/ISO
+task; the net is =post-rebuild-check= check 8, which ships now.
+
+Both halves resolved. Depth: =9f3ab5d= drops =--depth 1= from both user-repo clones, with 8 tests including one asserting the AUR build clones stay shallow. Remote URL: decided 2026-08-19 (see above) — the personal ISO carries the ssh form, and =post-rebuild-check= check 8 (=3397463=) flags any working repo still on the read-only endpoint, covering the install paths the ISO cannot reach.
+** DONE [#D] Worldclock tooltip blanks on one bad timezone row :bug:dotfiles:waybar:quick:solo:
+CLOSED: [2026-08-21 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-25
+:END:
+Found by sentry (2026-07-25), verified by exercising. =hyprland/.local/bin/waybar-worldclock= builds each zone with =ZoneInfo(tz)= inside the loop (line ~99) with no guard, so a single malformed timezone row in =worldclock.conf= raises =ZoneInfoNotFoundError= and crashes the whole python pass. The tooltip then renders empty and *every* zone is lost, not just the bad row; the traceback only reaches stderr, where waybar never surfaces it.
+Repro: a conf with =America/Chicago|Home=, =Not/AZone|Bad=, =Europe/London|London= renders =tooltip: ""= (Home and London gone too).
+Grade: minor severity (one module's tooltip blanks, no data loss) x rare edge case (a malformed conf row) = P4 = [#D].
+Fix: wrap the per-row =ZoneInfo=/=datetime= in a try/except and =continue=, so a typo drops only that row and the valid zones still render. Solo + quick: the script already has an env-override test harness (=WAYBAR_TIME_EPOCH=, =WAYBAR_WORLDCLOCK_CONF=), so a red-first test is cheap.
+
+Fixed in dotfiles =8f692f5=. The per-row =ZoneInfo= is guarded, so a malformed row drops itself and the valid zones still render. Five cases, including a bad row first — the ordering that looks least like one typo and most like the module being broken. Caught the broad =except Exception= rather than =ZoneInfoNotFoundError=, because the row also parses floats and calls strftime and the contract wanted is "a bad row costs only itself".
+** DONE [#C] obsbot-wb-guard polls forever on machines with no OBSBOT :bug:dotfiles:quick:solo:
+CLOSED: [2026-08-21 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-08-16
+:END:
+=obsbot-wb-guard.service= is =WantedBy=graphical-session.target= and lives in the shared =common/= stow tier, so it starts on every machine. Its main path is =while :; do check_once; sleep 2; done=, and =check_once= returns early when the camera node is absent. On a machine with no OBSBOT attached that is a process waking every two seconds forever to do nothing, which on a laptop is battery spend for zero benefit. No restart loop, though: the loop never exits, so =Restart=on-failure= never fires.
+
+Found 2026-08-16 on velox, after enabling it to match ratio and then having to disable it again by hand. A per-machine disable is the wrong shape, because it drifts velox from ratio permanently and a re-stow or a future audit will just put it back.
+
+Fix: give the unit =ConditionPathExists= on the camera node (=/dev/v4l/by-id/usb-Remo_Tech_Co.__Ltd._OBSBOT_PW106-video-index0=, the same default the script uses) so systemd skips it on any machine without the camera and starts it normally on ratio. Then re-enable it on velox, where it will simply be skipped. Note the limit: a camera plugged in later will not start it until the next login, which is the right trade against a permanent poll.
+
+Careful when disabling by hand in the meantime: =systemctl --user disable= on a *linked* unit deletes the unit symlink, and that symlink is stow-managed, so a bare disable silently removes a file from the dotfiles stow tree. Restore the link afterward or re-stow.
+
+Grade: minor severity (wasted wakeups and battery, no data loss, no failure) x every boot on any machine without the camera = P3 = [#C].
+
+Solo: buildable here (archsetup owns dotfiles end-to-end), verifiable by the agent (assert the unit is skipped on velox and still active on ratio), and no design call left open.
+
+Fixed in dotfiles =566dd14=. =ConditionPathExists= on the camera node, so systemd skips the unit where the camera is absent. velox is now =enabled= like ratio and reports =ConditionResult=no=; the stow symlink is untouched. Found while doing it: ratio has a Logitech BRIO and no OBSBOT on USB at all, so the 2-second poll was pointless on the desktop too, not merely costing laptop battery. A test asserts the unit's condition path and the script's =OBSBOT_WB_DEVICE= default stay equal, since drift there is invisible in both directions.
+** DONE [#C] Spine face tests decay against the wall clock :bug:test:dotfiles:solo:
+CLOSED: [2026-08-21 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-08-02
+:END:
+=settings/faces/timeline-face-spine.test.mjs= has thirteen =SP.spineRows(g, h)= calls that omit the third argument, so =ref= falls back to its =new Date()= default while the file's events fixture is pinned to =JUL= (2026-07-31 18:30 UTC). Any assertion that depends on how much room the day needs is then measured against today's clock, and rots as the fixture recedes.
+
+One of them, "spacing is uniform everywhere except the gap home opens", had already rotted: green on 07-31 because that was the fixture's own date, red by 08-02. Fixed in place on 2026-08-02 by pinning =JUL=; the remaining thirteen pass today by luck. The measurement, for whoever picks this up — with =ref=now= the even step is 85.21 and home's gaps are 129.10 / 65.40 (the lower one collapses below a plain gap); with =ref=JUL= the step is 78.54 and the gaps are 129.10 / 145.46. Only the lower gap moves, because =up= does not depend on events and =down= does.
+
+Six other calls in the same file already pass =JUL= explicitly, so the convention exists and this is a miss, not a gap in the design. Fix: pass =JUL= at every call whose assertion reads geometry. Leave the call around line 747 alone — it sweeps =new Date(t0)= deliberately.
+
+Grade: minor severity (dev-facing only; no product behavior is wrong, the face itself is fine) x some users, sometimes (each call rots independently, whenever the fixture drifts far enough) = P3 = [#C]. Not merely cosmetic though: a suite that goes red for no real reason is how a genuine regression gets waved through.
+
+Solo — mechanical, an existing convention to copy, and verifiable by running the suite plus re-running it under a faked clock to prove the determinism actually holds.
+
+
+Fixed in dotfiles =c96a216=. All thirteen bare calls now pass =JUL=. The task's "line 747" was stale (the deliberate =t0= sweep is at 893 and already passed its own ref, so it was never at risk), and the continuation-form call closes its arguments on the next line, which is why a naive grep counts fourteen. Added a guard that reads the file and fails with the offending line numbers, and verified it bites by stripping =JUL= from one call and confirming it went red naming that line.
+** DONE [#A] Velox still carries the install placeholder passwords :bug:security:velox:
+CLOSED: [2026-08-23 Sun] SCHEDULED: <2026-08-20 Thu>
+:PROPERTIES:
+:CREATED: [2026-08-20 Thu]
+:LAST_REVIEWED: 2026-08-20
+:END:
+Closed 2026-08-23: I'd already rotated all three on the 08-14 bringup day, so
+this task was never live. Verified on velox before closing — =chage -l= puts the
+last password change for both =cjennings= and =root= at Aug 14 2026, and
+=/etc/zfs/zroot.key= was rewritten 2026-08-14 05:29 and no longer holds the
+placeholder (checked with a =grep -qx= that returns a yes/no without reading the
+key into a transcript).
+
+The premise below was wrong, and it's worth naming how. Nothing ever tested the
+credentials: the claim came from an unticked runbook item plus the archangel
+session handing back the values the *installer* had set, which reads as "these
+are current" only if you assume nobody changed them in between. An inference
+about a security exposure got recorded in the same voice as a measurement. The
+one command that settles it costs a second.
+
+Original body follows.
+
+The 2026-08-13 reinstall set placeholder credentials and the runbook's Phase 5
+item to replace them (=passwd=, =zfs change-key zroot=) was never ticked.
+Believed still live 2026-08-20 via the archangel handoff, which had to hand
+them back to Craig to get into the machine: =welcome1= for the pool, =welcome=
+for the accounts.
+
+So velox's full-disk encryption is currently protected by a dictionary word
+with a digit, on the machine that travels. Anyone who picks it up owns the pool
+and every account on it — the encryption is doing no work at all.
+
+Two commands, both on velox:
+- =passwd= for each account.
+- =zfs change-key zroot= for the pool passphrase. Note this is the ZBM unlock
+ passphrase, so get it right before rebooting.
+
+Grading: *severity-alone carve-out* — this is a security exposure, so the
+frequency row does not discount it (=todo-format.md=). Critical severity: total
+compromise of an encrypted-at-rest laptop from a guessable string, with the
+device leaving the house. = P1 = [#A].
+
+Distinct from the =VERIFY [#A] Rotate the credentials exposed by the 2026-08-09
+dotfiles leak= under the cgit audit — that one covers credentials a crawler
+already took from a public repo. This one is a local default never changed. Both
+are rotation work; neither substitutes for the other.
+** CANCELLED [#B] Consistent keybinding family for the panel console :feature:hyprland:
+CLOSED: [2026-08-21 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-09
+:END:
+Merged into =[#B] Reconcile panel keybindings around Super+N=, which now carries
+this body's detail: the collision list, the velox plain-keyboard constraint, and
+maintenance-M as the priority chord. Cancelled rather than done — the work is
+still open, just tracked in one place instead of two.
+
+Consider putting every panel (net, bluetooth, audio, timer, and the coming maintenance console) on one consistent chord family — a shared modifier set (Super+Shift, Control+Alt, or similar) plus a mnemonic letter per panel (N/B/A/T/M). Today the panels open via waybar clicks only; a uniform chord family makes them keyboard-reachable and predictable. Watch for collisions with existing binds: Super+Shift+A is already PTT toggle, and the hold-to-talk grave bind is load-bearing. Decide the family, audit current hyprland binds for conflicts, wire via the dotfiles hyprland config, and document in the keybind reference. Both machines (velox can't QMK-remap, so chords must work on a plain laptop keyboard).
+*** 2026-07-14 Tue @ 00:31:36 -0500 Folded Craig's ask for a maintenance-panel keybinding; bumped [#C] → [#B]
+Craig asked (in session, 2026-07-14) for a maintenance keybinding specifically — the panel he's reaching for without one. Maintenance (M) is the priority chord when this task gets worked. The capture graduated the task from parking lot to active backlog.
+** DONE [#B] Waybar network module — custom/net :feature:waybar:network:
+CLOSED: [2026-08-21 Fri]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-09
+:END:
+Closed 2026-08-21: the module shipped and is in daily use. Phases 1-4 all landed
+in dotfiles, and the tunnels track absorbed most of what Phase 5 originally
+covered. The one piece genuinely left — the =net vpn= CLI subcommand — is now its
+own task below, so the residual is tracked at its real size instead of holding a
+finished umbrella open.
+Unifies the old wifi-no-internet indicator (was =[#C]=) and the network-manager
+dropdown (was =[#B]=) into one =custom/net= module: a tested Python =net= engine
+(nmcli + diagnostics), a thin bar indicator, and a GTK4 layer-shell panel. Code
+lives in the dotfiles repo (hyprland tier + a =net/= package like pocketbook);
+archsetup only installs deps. Secrets stay in NetworkManager's own store (no
+separate credential store). The =captive= script becomes the diagnostics engine.
+Full design, acceptance criteria, and the failure-mode coverage table:
+[[file:docs/design/2026-06-29-waybar-network-module-spec.org][2026-06-29-waybar-network-module-spec.org]].
+
+Phases below, dependency order. Engine/unit work is agent-verifiable (=unittest=
++ fakes on PATH, coverage via venv); the live-network and visual states need real
+conditions, filed under "Manual testing and validation".
+
+*** 2026-06-29 Mon @ 20:19:11 -0400 Phase 1 shipped — indicator + console recovery
+Shipped to the dotfiles repo (10 commits, =5254bd8=..=c095a22=, pushed to main).
+The =net= engine is a src-layout Python package in-tree, imported by a bin shim
+that resolves the stow symlink back to the repo — so it runs from a bare TTY with
+no install, which the recovery path depends on.
+
+Landed: =net status= (fast path, one nmcli call + sysfs, degraded fallback in
+budget) + =net probe= (native captive probe, single-flight flock, atomic cache,
+fresh/stale/expired/unknown classes, iface/SSID/UUID invalidation); =waybar-net=
+replacing =custom/netspeed=, throughput → tooltip, CSS states in both themes +
+live; =net diagnose= (read-only steps) + =net repair= (rfkill/reset/bounce/
+dns-test, cleanup-verified) + =net doctor [--fix]= with the four terminal
+classifications; =net portal= + the =captive --probe-json= refactor; redacted
+JSONL event log; Makefile recovery targets (=make online= etc.); =~/.config/net/
+config=. Verified live: =make net-status= reads the real wlp170s0 / @Hyatt_WiFi.
+
+Airplane (Craig's call, option 1): =custom/net= absorbs only the *display* — net
+reads the airplane-mode state file and shows an airplane state/glyph. The
+airplane-mode toggle stays (it's a low-power mode — radios + CPU + brightness +
+services — not a radio switch), now on =custom/net='s right-click + signal 15.
+Deleted: =waybar-airplane=, =waybar-netspeed=, =custom/airplane=, their tests +
+css. =airplane-mode= kept.
+
+Tests: 160 in =tests/net/= (fake nmcli/curl/rfkill/resolvectl/ping/getent/
+systemctl on a temp PATH; doctor-classification fixtures; degraded-under-slow-
+nmcli benchmark) + the =captive= probe-mode tests; full dotfiles suite green (32
+suites). Coverage-gap pass via throwaway venv: pure modules ≥90% branch
+(classify 100%), IO-error branches excused in the test docstring.
+Deferred to Phase 2/3: archsetup deps (gtk4-layer-shell/python-gobject Phase 2,
+speedtest-go-bin Phase 3 — not added before the code that needs them).
+Verify (manual, live): see Manual testing and validation.
+
+*** 2026-06-29 Mon @ 22:19:25 -0400 Phase 2 shipped — panel shell + connection management
+Shipped to dotfiles (commits =4e7740f=..=24bcac5=, pushed). Engine: =net list= (saved
+MRU + in-range wifi scan, infrastructure types filtered), =net up/down= (UUID-keyed,
+mutation safety — keep prior link until target activates, classify wrong-password vs
+generic, report auto-reactivation), =net add/edit/remove/rescan= (open + WPA-PSK;
+enterprise activate-only; secret to NM's store, never our JSON/log — tested).
+
+Panel: a GTK-free PanelModel (selection, four state machines, the UX-flow enable
+rules, terminal states) + a GTK4 gtk4-layer-shell window (=net panel=) anchored
+top-right under the bar — Connections section with MRU list, active marked, signal
+glyph, row-click select, Connect/Add/Forget/Rescan, confirm-on-forget, worker-thread
+engine calls via GLib.idle_add. GTK imported lazily so the CLI/tests stay GTK-free.
+
+Bar interactions (settled with Craig over live iteration): left = =net-panel= toggle,
+middle = =net portal=, right = =net-fix= (notify the doctor result when one-way; open
+a terminal only when the outcome is fixable — the sudo/interactive case). Airplane on
+Super+Shift+A. archsetup adds =gtk4-layer-shell= + =python-gobject= (this commit);
+already on velox.
+
+Tests: 204 in tests/net (merge ordering/dedup, up/down mutation safety, no-secret-leak
+on add/edit, panel model + state machines, gui row-format helpers). Full dotfiles suite
+green (32 suites). Live-verified on velox: panel opens/toggles, list shows real 24
+profiles, right-click notification delivers (Craig confirmed). Phase 3 (diagnose/repair/
+speedtest IN the panel) is next; the engine for it already exists from Phase 1.
+
+*** 2026-06-29 Mon @ 22:43:40 -0400 Phase 3 shipped — diagnostics + speed test in the panel
+Shipped to dotfiles (=91277cf=..=691abcb=) + archsetup (=48052d6=, speedtest-go-bin),
+pushed. Engine: =net speedtest= (parses speedtest-go --json → ping from latency ns,
+down/up from per-server byte rates; missing-backend / offline / malformed → error
+envelope per the failure table). Panel grew a section switcher with four pages:
+- Connections (Phase 2).
+- Diagnose: =net diagnose= on a worker thread, each step a row (✓/✗/… glyph + title +
+ redacted evidence), read-only; Open-portal button when captive.
+- Repair: "Get me online" (=net doctor --fix=) + tiers (rfkill/reset/bounce/dns-test)
+ + force portal. Confirmations in-panel with the spec's exact wording; the privileged
+ tiers run via =net-popup= terminal (where the sudo prompt + step output, incl.
+ cleanup-verified, show) — a panel has no tty, and pkexec would mean a prompt per op.
+- Speed test: in-process =net speedtest= (no privilege → inline result: ↓/↑ Mbps + ping
+ + server), Run/Cancel (Cancel pkills the child), error envelope shown.
+
+213 net tests; pure helpers (step_indicator, format_speedtest) unit-tested. Full
+dotfiles suite green (32 suites). One unverified assumption: speedtest-go's dl/ul unit
+(taken as bytes/s; =BYTES_PER_SEC= flips it) — needs one real run vs a reference. The
+in-panel repair streaming (vs terminal) is a named future polish once the GUI-privilege
+story settles.
+
+The waybar network module ([#B] parent) is now COMPLETE through Phase 3. Phase 4
+(in-app help + user guide) and Phase 5 (VPN/WireGuard) remain as future work; the core
+feature (indicator + recovery + panel + diagnostics + speed test) is done.
+Verify (manual, live): see Manual testing and validation.
+
+*** 2026-07-09 Thu @ 16:32:54 -0500 Audit reconcile: Phase 4 is filed on the dotfiles side, waiting on them
+The dotfiles project accepted the Phase 4 handoff and filed it as a =[#C]= task in their own =todo.org= (their note, 2026-07-08 16:56): the help-text audit + panel help affordance, the user-guide/README, and the ratio rollout doc. Not started there. They ping when it lands, and this task's Phase 4 child closes then. Nothing to do here meanwhile.
+
+*** 2026-08-17 Mon @ 19:57:42 -0700 Landed on the dotfiles side; the block is cleared
+dotfiles shipped it as =138da7b= and closed its own task, so this one closes
+with it and the =:blocked:= tag comes off. Found by checking their =todo.org=
+rather than waiting for the ping — their close-out note says "archsetup pinged
+so its Phase 4 task can close", so the handoff worked and only this end was
+left open.
+
+All three acceptance criteria are met on their side: the help audit found and
+fixed a stale =net repair= action list (nine of nineteen actions were named;
+both the CLI help and =repair.py='s docstring now generate from the ACTIONS
+registry), =net/README.md= covers every command plus the recovery targets, and
+the ratio rollout is documented with both daily drivers verified current.
+
+They split the panel help affordance out rather than inventing it — no sibling
+panel has one, so its shape is a design call. It is tracked on their side, not
+here.
+
+Original deliverable, for the record: in-app help (=net --help= + per-command,
+panel help affordance); README/user-guide; archsetup Hyprland dep install
+(=gtk4-layer-shell=, =python-gobject=, =speedtest-go-bin=); ratio manual dep +
+stow step. Handed off 2026-07-04 with the archsetup deps already confirmed
+installed.
+
+*** 2026-08-21 Fri @ 14:18:03 -0700 Promoted the Phase 5 residual out to its own task
+Rescoped 2026-07-04 (audit): the tunnels track already shipped most of the original Phase 5. Panel tunnel bring-up/down and detection landed (dotfiles 2d9d060 probes tailscale/NM-wireguard/Proton; 21db05a brings overlays up/down from the panel's Tunnels sub-view; 31ba056 diagnose/doctor understand tunnel routes; archsetup 2e40781 wireguard config import; the net-panel-other-interfaces spec is IMPLEMENTED). What remains for Phase 5 is only the =net vpn ...= CLI subcommand — cli.py still has no vpn/tunnel parser. Fold the panel's existing tunnel operations into a CLI surface; spec separately when picked up.
+** DONE [#A] Ratio: pull .emacs.d before upgrading Emacs to 31.1 :chore:ratio:emacs:
+CLOSED: [2026-08-25 Tue]
+:PROPERTIES:
+:CREATED: [2026-08-25 Tue]
+:LAST_REVIEWED: 2026-08-25
+:END:
+Emacs 31.1's warnings.el defers daemon-startup warnings into a closure holding
+the =*Warnings*= buffer; the config's dashboard-only sweep killed that buffer,
+so the first client frame of every fresh 31.1 daemon failed on Wayland and
+emacsclient silently fell back to =$DISPLAY= (XWayland, pgtk warning dialog).
+Fixed in =.emacs.d= commit =63831060= (2026-08-25, velox verified live:
+=GdkWaylandDisplay=). Ratio is still on 30.2, which lacks the deferring code,
+so it is fine until it upgrades — then it hits the same trap once per daemon
+start unless the fix is pulled first.
+
+Order on ratio: =git -C ~/.emacs.d pull= (the push from velox is the telega
+session's; confirm =63831060= is on origin first), then the =pacman -Syu= that
+brings =emacs-wayland 31.1=, then restart the daemon. Check afterwards:
+=emacsclient -e '(pgtk-backend-display-class)'= → =GdkWaylandDisplay=.
+
+*** 2026-08-25 18:10 — pull already landed; the upgrade half remains
+Checked ratio over tailscale: =~/.emacs.d= is clean at =91fbac72= (= =origin/main=),
+and =63831060= is an ancestor of HEAD — =modules/undead-buffers.el= carries the
+=*Warnings*= entry. Ratio is on =emacs-wayland 30.2-3= with =31.1-1= pending among
+720 updates (last full upgrade 2026-08-01; kernel 7.1.5 → 7.1.9 also pending,
+btrfs root, uptime 3.5 weeks). The daemon is a plain =emacs --daemon= (not a
+user unit) holding 2 live frames, so the restart step will drop those frames.
+What remains: the =pacman -Syu= on ratio, the daemon restart, and the
+=(pgtk-backend-display-class)= check.
+
+*** 2026-08-25 Tue @ 18:35:00 -0600 Upgraded ratio to Emacs 31.1 and verified the Wayland backend
+Ran the upgrade over tailscale as a transient unit (=ratio-upgrade.service=,
+log at =/var/log/ratio-upgrade.log=): 714 packages, =--ignore= on the six
+packages the live-update guard would have blocked (aquamarine, hyprland,
+hyprutils, mesa, vulkan-radeon, wayland — still pending, apply from a TTY
+before the reboot). One orphan cleared first: =qemu-block-gluster= had been
+dropped from the repo and pinned =qemu-common=; the new =qemu-full= no
+longer needs it. Killed the plain =emacs --daemon= (no modified buffers, no
+graphical frames), started =emacs.service= instead so the daemon carries the
+systemd user environment, and probed from a throwaway frame:
+=(pgtk-backend-display-class)= → =GdkWaylandDisplay=, =*Warnings*= alive.
+Ratio still wants a reboot for =linux 7.1.9=. Pacnews to review there:
+=/etc/ssh/sshd_config.pacnew= and two =/etc/tpm2-tss/fapi-profiles/*.json=.
+** DONE [#B] Function keys issue media actions instead of F-keys :bug:velox:
+CLOSED: [2026-09-01 Tue]
+:PROPERTIES:
+:CREATED: [2026-08-19 Wed]
+:LAST_REVIEWED: 2026-08-19
+:END:
+
+From the roam inbox, Craig's words: "function keys should issue F+number
+functionality rather than their media functionality when the button is hit.
+currently it's reversed and I have to hit function and the f button for F+number
+functionality."
+
+Check first whether this belongs to archsetup at all. On a Framework the Fn-lock
+is a firmware-level toggle held in the keyboard itself (Fn+Esc on most
+revisions), not something the OS sets, in which case this is one keystroke
+rather than a change here. If it is instead a hid/keyboard-module quirk, it is
+ours.
+
+Grading: Minor severity (the keys work, they are on the wrong layer, and there
+is a workaround) x every user every time (every F-key press) = P2 = [#B].
+
+Resolved 2026-09-01: not ours, as the body suspected. The Fn layer is decided
+in the EC (the keyboard reaches Linux as a plain AT keyboard on i8042), so no
+OS-side knob exists. One keystroke: Fn+Esc toggles Fn Lock; Craig confirmed
+F1-F12 now send F-keys by default. The EC holds the state across reboots; it
+reverts only if the EC loses power (battery disconnect or mainboard reset),
+which is likely why it flipped around the August reinstall.
diff --git a/archsetup b/archsetup
index 96ef231..7c71256 100755
--- a/archsetup
+++ b/archsetup
@@ -1200,6 +1200,36 @@ configure_build_environment() {
echo 'OPTIONS=""' > /etc/sysconfig/chronyd
systemctl enable chronyd.service >> "$logfile" 2>&1 || error_warn "$action" "$?"
+ # Bootstrap NTP sources addressed by IP, never by hostname.
+ #
+ # Arch's stock chrony.conf names its pool by hostname, and the DNS this
+ # installer configures later runs DNSSEC=yes, which validates signature
+ # windows against the wall clock, so a machine that boots with a wrong
+ # clock resolves nothing: chrony cannot reach the pool, so the clock stays
+ # wrong, so DNS stays dead. Neither side moves, and recovery needs a second
+ # device to look up an NTP address by hand. An IP-addressed source needs no
+ # DNS and no certificate, so it breaks the deadlock unattended. I would
+ # rather carry two extra server lines than lose a laptop's network to any
+ # RTC fault. See the clock/DNS deadlock entry in the net failure taxonomy
+ # under docs/design/.
+ action="adding IP-addressed NTP bootstrap sources" && display "task" "$action"
+ mkdir -p /etc/chrony.d
+ cat << 'EOF' > /etc/chrony.d/10-bootstrap-ip-ntp.conf
+# Reachable without DNS, so a wrong clock can always correct itself.
+server 162.159.200.1 iburst
+server 162.159.200.123 iburst
+EOF
+ # Stock chrony.conf reads no drop-in directory, so point it at one.
+ if [ -f /etc/chrony.conf ]; then
+ backup_system_file /etc/chrony.conf
+ if ! grep -qE '^[[:space:]]*confdir[[:space:]]+/etc/chrony\.d' /etc/chrony.conf; then
+ printf '\n# Read drop-ins (archsetup owns /etc/chrony.d).\nconfdir /etc/chrony.d\n' \
+ >> /etc/chrony.conf || error_warn "$action" "$?"
+ fi
+ else
+ error_warn "$action (no /etc/chrony.conf to point at /etc/chrony.d)" 1
+ fi
+
action="configuring compiler to use all processor cores" && display "task" "$action"
backup_system_file /etc/makepkg.conf
sed -i "s/-j2/-j$(nproc)/;s/^#MAKEFLAGS/MAKEFLAGS/" /etc/makepkg.conf >> "$logfile" 2>&1
@@ -1266,14 +1296,17 @@ zfs_scrub_timer_units() {
done
}
-# Enable a systemd --user service at install time by creating the wants symlink
+# Enable a systemd --user unit at install time by creating the wants symlink
# directly. `systemctl --user enable` fails during install (no user session
# bus), so the link is wired by hand -- the pattern syncthing and gamemode both
-# need. Args: <username> <service-unit> <service-file> [home-dir].
+# need. Args: <username> <unit> <unit-file> [home-dir] [wants-target].
+# The wants target defaults to default.target; a socket unit passes
+# sockets.target to match its [Install] WantedBy.
enable_user_service() {
local username="$1" service="$2" service_file="$3"
local home="${4:-/home/$username}"
- local wants_dir="$home/.config/systemd/user/default.target.wants"
+ local target="${5:-default.target}"
+ local wants_dir="$home/.config/systemd/user/${target}.wants"
mkdir -p "$wants_dir"
ln -sf "$service_file" "$wants_dir/$service"
chown -R "$username:$username" "$home/.config/systemd"
@@ -1425,8 +1458,13 @@ clone_user_repos() {
# Without this, symlinks could point to /root or a tmpfs that disappears.
user_archsetup_dir="/home/$username/code/archsetup"
action="cloning archsetup to user's home directory" && display "task" "$action"
+ # Full history, deliberately. This is a working repo, not a build tree, and
+ # a shallow clone degrades silently: `git log -- <path>` answers "no
+ # commits" past the graft point rather than failing, so history questions
+ # come back confidently wrong. The AUR clones stay shallow; they're
+ # discarded after the build.
(mkdir -p "$(dirname "$user_archsetup_dir")" && \
- git clone --depth 1 "$archsetup_repo" "$user_archsetup_dir" && \
+ git clone "$archsetup_repo" "$user_archsetup_dir" && \
chown -R "$username": "/home/$username/code") \
>> "$logfile" 2>&1 || error_warn "$action" "$?"
@@ -1439,7 +1477,8 @@ clone_user_repos() {
# leaves /home/$username root-owned — so a clone running as the user fails with
# "Permission denied" creating ~/.dotfiles. Cloning as root sidesteps that, and
# chown -R gives the user the working tree. Mirrors the archsetup clone above.
- (git clone --depth 1 --branch "$dotfiles_branch" "$dotfiles_repo" "$dotfiles_dir" \
+ # Full history for the same reason as the archsetup clone above.
+ (git clone --branch "$dotfiles_branch" "$dotfiles_repo" "$dotfiles_dir" \
&& chown -R "$username": "$dotfiles_dir") >> "$logfile" 2>&1 || error_warn "$action" "$?"
# Q5: the --adopt/restore conflict handling below needs a real git checkout.
@@ -1680,12 +1719,73 @@ aur_installer() {
error_fatal "$action" "$?" "confirm base-devel is installed (pacman -S base-devel), then re-run"
}
+### GNOME Keyring Secret Service fix
+# gnome-keyring 48 through 51.1 aborts when a client's first OpenSession is
+# dispatched before the daemon has created its per-client record (GNOME issue
+# 190, merge request !112, unmerged as of 2026-10-17). Each abort drops the
+# unlocked state, so the next request pops the keyring password dialog; on
+# ratio that fired about three times a day once the ChatGPT desktop app's
+# codex app-server started bursting keyring reads. Until Arch or upstream
+# ships the fix, packages/gnome-keyring in this repo is Arch's own recipe for
+# 1:51.1-1 plus the patch, at pkgrel 1.1. That version sorts above stock and
+# below any future 1:51.1-2 or 51.2, so a normal upgrade replaces it the
+# moment a real fix lands; nothing here pins or holds the package.
+#
+# The build runs as the user from a copy of the recipe under the source dir:
+# makepkg refuses to run as root and writes build products next to the
+# PKGBUILD, which must not land in the user's checkout. A failed build warns
+# and leaves the stock package in place rather than installing anything by
+# hand.
+#
+# This step owns the stock install too. pacman's --needed only skips an equal
+# version, so a plain pacman_install gnome-keyring ahead of this step would
+# downgrade the rebuild back to stock on every re-run and trigger a fresh
+# build and daemon restart each time. Stock is installed only when no
+# gnome-keyring is present at all, as the baseline the rebuild upgrades.
+install_gnome_keyring_fix() {
+ local recipe="${user_archsetup_dir:-/home/$username/code/archsetup}/packages/gnome-keyring"
+ local build_dir="$source_dir/gnome-keyring"
+ local fixed_version="1:51.1-1.1"
+ local installed
+
+ action="patched gnome-keyring (Secret Service missing-client fix)" && display "task" "$action"
+
+ installed="$(pacman -Q gnome-keyring 2>/dev/null | awk '{print $2}')"
+ if [ -z "$installed" ]; then
+ pacman_install gnome-keyring
+ else
+ case "$(vercmp "$installed" "$fixed_version")" in
+ 0) return 0 ;; # the rebuild is already installed
+ 1) # A deliberate skip, not an error: something newer than the
+ # rebuild is installed, so the fix is assumed to be upstream.
+ display "task" "gnome-keyring $installed is newer than the rebuild, skipping; verify the Secret Service no longer aborts"
+ return 0 ;;
+ esac
+ fi
+
+ [ -f "$recipe/PKGBUILD" ] || { error_warn "$action: recipe missing at $recipe" 1; return 1; }
+
+ if [ -e "$build_dir" ]; then
+ safe_rm_rf "$build_dir" "$source_dir" >> "$logfile" 2>&1 || \
+ { error_warn "$action: removing stale build dir $build_dir" "$?"; return 1; }
+ fi
+ (mkdir -p "$build_dir" && cp -a "$recipe/." "$build_dir/" && chown -R "$username": "$build_dir") >> "$logfile" 2>&1 || \
+ { error_warn "$action: staging the recipe" "$?"; return 1; }
+
+ # -s installs the makedepends, -i installs the result; one command, as the
+ # yay bootstrap does, so a build failure is the install failure.
+ (cd "$build_dir" && sudo -u "$username" makepkg --noconfirm -si >> "$logfile" 2>&1) || \
+ { error_warn "$action: makepkg failed, stock gnome-keyring left in place" "$?"; return 1; }
+}
+
### Essential Services
essential_services() {
display "title" "Essential Services"
configure_randomness
configure_networking
configure_power
+ configure_hibernate_delay
+ configure_backlight_access
configure_ssh_server
configure_fail2ban
configure_firewall
@@ -1768,8 +1868,12 @@ configure_networking() {
wifi.scan-rand-mac-address=yes
[connection-mac-randomization]
-# Random MAC for each WiFi connection (prevents tracking)
-wifi.cloned-mac-address=random
+# Stable per-network MAC, not a fresh one per connection. Both hide the real
+# address from a venue; random also hides this machine from itself, so every
+# reconnect at a hotel or airport looks like a new device and the portal login
+# starts over. stable derives a different address per network, so the privacy
+# across networks is unchanged and the reconnect friction goes away.
+wifi.cloned-mac-address=stable
# Stable MAC for ethernet (avoids issues with MAC-based DHCP reservations)
ethernet.cloned-mac-address=stable
EOF
@@ -1786,7 +1890,17 @@ EOF
DNS=1.1.1.1#cloudflare-dns.com 9.9.9.9#dns.quad9.net
FallbackDNS=1.0.0.1#cloudflare-dns.com 149.112.112.112#dns.quad9.net
DNSOverTLS=yes
-DNSSEC=yes
+# allow-downgrade, not yes. Venue resolvers that mangle DNSSEC records are
+# common on hotel and airport wifi, and yes turns that into no answer at all
+# rather than an unauthenticated one. The encryption is the part worth being
+# strict about, so DNSOverTLS stays yes.
+#
+# This is not what fixes the clock deadlock, despite being the obvious reach.
+# Resolved downgrades when a server lacks DNSSEC support, and a clock-skew
+# signature failure is a validation failure, so no downgrade fires. Measured on
+# velox 2026-08-19: dead across six retries and a reset-server-features. The
+# IP-addressed NTP source above is what breaks that deadlock.
+DNSSEC=allow-downgrade
# Disable mDNS in resolved - avahi handles .local resolution exclusively
MulticastDNS=no
EOF
@@ -1797,6 +1911,8 @@ EOF
dns=systemd-resolved
EOF
+ configure_tunnel_dns_over_tls
+
# Note: If Docker containers have DNS issues, systemd-resolved's stub resolver
# (127.0.0.53) may be the cause. Fix: configure Docker to use direct DNS, or
# disable systemd-resolved and use /etc/resolv.conf directly. (2026-01-18)
@@ -1806,6 +1922,127 @@ EOF
run_task "linking resolv.conf to systemd-resolved" ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
}
+configure_tunnel_dns_over_tls() {
+ # The resolved drop-in above pins DNSOverTLS=yes for every link. Proton
+ # VPN (proton0) and the static Proton WireGuard profiles (wgpvpn) push
+ # an in-tunnel resolver, 10.2.0.1, that answers plain port 53 and never
+ # completes TLS on 853, so with strict DoT every lookup through the
+ # tunnel hangs (2026-09-09). The Proton client deletes and recreates
+ # its NM profile on every connect, so a per-profile dns-over-tls
+ # setting can't stick and the net doctor's per-link repair is
+ # session-only there. A NetworkManager [connection-*] default matched
+ # on the interface names is what NM pushes to resolved on every
+ # activation, with no script and no race; wifi and everything else keep
+ # the strict setting. Verified live on ratio 2026-09-10 and velox
+ # 2026-09-12.
+ #
+ # $1 is the conf.d directory, defaulting to the system's so tests can
+ # run against a temp dir.
+ local confdir="${1:-/etc/NetworkManager/conf.d}"
+ local dropin="$confdir/tunnel-dns-over-tls.conf"
+
+ action="turning DNS over TLS off for the Proton tunnel links" && display "task" "$action"
+
+ mkdir -p "$confdir" 2>> "$logfile" || { error_warn "$action" "$?"; return 1; }
+ cat > "$dropin" << 'NMEOF' 2>> "$logfile" || { error_warn "$action" "$?"; return 1; }
+# Proton VPN (proton0) and the static Proton WireGuard profiles (wgpvpn) push
+# an in-tunnel resolver (10.2.0.1) that answers plain port 53 and never
+# completes TLS on 853. The global resolved drop-in pins DNSOverTLS=yes, and
+# the Proton client recreates its profile on every connect and sets no
+# per-link mode, so every lookup through the tunnel fails. Default DNS over
+# TLS off for those links only; wifi and everything else keep the strict
+# setting. Diagnosed 2026-09-09/10.
+[connection-tunnel-dot]
+match-device=interface-name:proton0,interface-name:wgpvpn
+connection.dns-over-tls=0
+NMEOF
+ chmod 644 "$dropin" 2>> "$logfile" || error_warn "$action" "$?"
+}
+
+configure_backlight_access() {
+ # Screen backlight and keyboard-LED brightness, writable by the video
+ # group. Arch ships brightnessctl with no udev rules: it relies on
+ # logind, which grants brightness writes only to the *active seat
+ # session*, so a script, a remote shell, or a panel launched into a
+ # different session gets EPERM against root-owned sysfs. Found on velox
+ # 2026-08-13: after a fresh install both brightness sliders in the
+ # desktop settings panel were inert. Group-writable sysfs is
+ # session-independent, and inert on a machine with no such devices.
+ #
+ # leds are granted to video rather than input on purpose: input-group
+ # membership also confers read access to every input device, which is
+ # too much authority to hand out for dimming a keyboard.
+ #
+ # $1 is the rules directory, defaulting to the system's so tests can
+ # run against a temp dir.
+ local ruledir="${1:-/etc/udev/rules.d}"
+ local rule="$ruledir/90-backlight.rules"
+
+ action="granting the video group brightness control" && display "task" "$action"
+
+ mkdir -p "$ruledir" 2>> "$logfile" || { error_warn "$action" "$?"; return 1; }
+ cat > "$rule" << 'UDEVEOF' 2>> "$logfile" || { error_warn "$action" "$?"; return 1; }
+# Brightness control without root: make the backlight and keyboard-LED
+# brightness attributes group-writable by video. brightnessctl otherwise
+# depends on logind, which only grants the active seat session.
+ACTION=="add", SUBSYSTEM=="backlight", RUN+="/usr/bin/chgrp video /sys/class/backlight/%k/brightness"
+ACTION=="add", SUBSYSTEM=="backlight", RUN+="/usr/bin/chmod g+w /sys/class/backlight/%k/brightness"
+ACTION=="add", SUBSYSTEM=="leds", KERNEL=="*kbd_backlight", RUN+="/usr/bin/chgrp video /sys/class/leds/%k/brightness"
+ACTION=="add", SUBSYSTEM=="leds", KERNEL=="*kbd_backlight", RUN+="/usr/bin/chmod g+w /sys/class/leds/%k/brightness"
+UDEVEOF
+ chmod 644 "$rule" 2>> "$logfile" || error_warn "$action" "$?"
+
+ # Apply now so brightness works in this boot, not just after a reboot.
+ udevadm control --reload >> "$logfile" 2>&1 || error_warn "reloading udev rules" "$?"
+ udevadm trigger --subsystem-match=backlight --action=add >> "$logfile" 2>&1 || \
+ error_warn "triggering backlight udev rules" "$?"
+ udevadm trigger --subsystem-match=leds --action=add >> "$logfile" 2>&1 || \
+ error_warn "triggering leds udev rules" "$?"
+}
+
+configure_hibernate_delay() {
+ # How long a suspended machine waits before hibernating. Read only by
+ # systemctl suspend-then-hibernate, so it does nothing until something
+ # asks for that — a desktop panel's idle stage, a lid rule, or a hand
+ # invocation.
+ #
+ # Unset, the delay depends on the hardware: a machine with a battery
+ # hibernates on a low-battery alarm at an hour nobody chose, and one
+ # without a battery falls back to systemd's 2h default. 90 minutes
+ # makes it a stated ceiling instead — on a laptop systemd still
+ # hibernates early if the battery gets there first, since it runs both
+ # timers and takes whichever fires soonest. The ceiling is long enough
+ # that stepping away costs a screen unlock rather than a passphrase
+ # and a full resume, and short enough that a laptop in a bag stops
+ # holding its encryption keys in RAM (a suspended machine does; a
+ # hibernated one does not).
+ #
+ # Harmless on a machine that cannot hibernate: nothing reads it there.
+ # Numbered 60- because systemd reserves 10-40 for vendor drop-ins under
+ # /usr and 60-90 for local ones under /etc; a 10- file here would sort
+ # below a vendor drop-in and quietly lose to it.
+ # $1 is the drop-in directory, defaulting to the system's so tests can
+ # run against a temp dir.
+ local confdir="${1:-/etc/systemd/sleep.conf.d}"
+ local dropin="$confdir/60-hibernate-delay.conf"
+
+ action="setting the suspend-to-hibernate delay" && display "task" "$action"
+
+ mkdir -p "$confdir" 2>> "$logfile" || { error_warn "$action" "$?"; return 1; }
+ cat > "$dropin" << 'SLEEPEOF' 2>> "$logfile" || { error_warn "$action" "$?"; return 1; }
+# Wait 90 minutes after suspending before hibernating.
+#
+# Unset, the delay depends on the hardware: a machine with a battery
+# hibernates on a low-battery alarm, one without falls back to systemd's
+# 2h default. 90 minutes keeps a short absence cheap (a screen unlock, not
+# a passphrase and a resume) while making sure a laptop left in a bag
+# drops its encryption keys out of RAM rather than holding them for hours.
+[Sleep]
+HibernateDelaySec=90min
+SLEEPEOF
+ chmod 644 "$dropin" 2>> "$logfile" || error_warn "$action" "$?"
+}
+
configure_power() {
# Power
@@ -1925,8 +2162,18 @@ configure_service_discovery() {
run_task "enabling avahi for mDNS discovery" systemctl enable avahi-daemon.service
fi
- pacman_install wsdd
- run_task "enabling wsdd for Windows network discovery" systemctl enable wsdd.service
+ # wsdd.service used to be enabled here "for Windows network discovery".
+ # That is the host daemon: it advertises THIS machine as a Samba host to
+ # Windows clients, and nothing here runs Samba, so it advertised a
+ # share server that doesn't exist while listening on every interface
+ # including the VPN and tailscale links (2026-09-12). Browsing Windows
+ # shares is the other direction and is gvfs-wsdd's job; it spawns its
+ # own wsdd in discovery mode (see supplemental_software). A machine set
+ # up before this change still has the unit enabled, so a re-run turns it
+ # off; a fresh install has no unit yet and the guard skips quietly.
+ if systemctl is-enabled --quiet wsdd.service 2>/dev/null; then
+ run_task "disabling wsdd.service (no Samba host to advertise)" systemctl disable --now wsdd.service
+ fi
pacman_install geoclue # geolocation service for location-aware apps
run_task "enabling geoclue geolocation service" systemctl enable geoclue.service
@@ -1939,7 +2186,7 @@ configure_service_discovery() {
error_warn "$action" "$?"
fi
- # Whitelist gammastep in geoclue config (geoclue demo agent is started via hyprland.conf exec-once)
+ # Whitelist gammastep in geoclue config (geoclue demo agent is started from hyprland.lua at login)
action="whitelisting gammastep in geoclue" && display "task" "$action"
if ! grep -q "^\[gammastep\]" /etc/geoclue/geoclue.conf 2>/dev/null; then
backup_system_file /etc/geoclue/geoclue.conf
@@ -2002,6 +2249,7 @@ configure_snapshots() {
if is_zfs_root; then
configure_zfs_snapshots
+ mask_tmp_mount_for_zfs
elif is_btrfs_root; then
configure_btrfs_snapshots
else
@@ -2009,6 +2257,17 @@ configure_snapshots() {
fi
}
+mask_tmp_mount_for_zfs() {
+ # A ZFS /tmp dataset and systemd's tmp.mount (tmpfs) race at boot; when
+ # tmpfs wins it shadows the dataset and systemd-tmpfiles-clean fails
+ # repeatedly with "Protocol driver not attached" (velox, 2026-04-10).
+ # Mask tmp.mount so the dataset owns /tmp. No /tmp dataset, or no zfs at
+ # all → nothing to do.
+ command -v zfs >/dev/null 2>&1 || return 0
+ zfs list -H -o mountpoint 2>/dev/null | grep -qx '/tmp' || return 0
+ run_task "masking tmp.mount (ZFS owns /tmp)" systemctl mask tmp.mount
+}
+
configure_zfs_snapshots() {
# ZFS: Install sanoid for snapshot management
display "task" "ZFS detected - installing sanoid"
@@ -2371,6 +2630,9 @@ hyprland() {
# enables TLP on battery machines, and the two daemons fight over
# platform profiles. On TLP machines the panel's power control reads
# as unavailable, which the settings engine handles.
+ # Not enabling it here is necessary but NOT sufficient on those machines:
+ # ppd is D-Bus activated, so configure_tlp_power masks it outright. Without
+ # that mask the panel starts ppd on demand and systemd kills TLP.
pacman_install power-profiles-daemon
if ! ls /sys/class/power_supply/BAT* &>/dev/null; then
run_task "enabling power-profiles-daemon" systemctl enable power-profiles-daemon.service
@@ -2545,7 +2807,7 @@ desktop_environment() {
action="Authentication Tools" && display "subtitle" "$action"
pacman_install gnupg
pacman_install polkit
- pacman_install gnome-keyring
+ install_gnome_keyring_fix # installs stock gnome-keyring first when none is present
pacman_install seahorse
pacman_install pass
pacman_install rbw # Bitwarden CLI client (Rust)
@@ -2557,8 +2819,12 @@ desktop_environment() {
find /home/"$username"/.gnupg -type f -exec chmod 600 {} \;
find /home/"$username"/.gnupg -type d -exec chmod 700 {} \;
- # pre-create gnome-keyring directory and set default keyring to "login"
- # this allows auto-unlock without password prompt (works with autologin)
+ # Pre-create the gnome-keyring directory and name "login" as the default
+ # collection. That only picks which collection applications use; the
+ # unlock itself comes from pam_gnome_keyring in the login and hyprlock PAM
+ # stacks, which unlock the login collection with the password typed at
+ # login. It does not unlock anything under console autologin, where no
+ # password is typed (I had claimed otherwise here; corrected 2026-10-17).
keyring_dir="/home/$username/.local/share/keyrings"
mkdir -p "$keyring_dir"
echo "login" > "$keyring_dir/default"
@@ -2578,6 +2844,7 @@ desktop_environment() {
pipewire-docs pamixer pulsemixer easyeffects ffmpeg rtkit; do
pacman_install "$software"
done
+ configure_framework_amd_internal_mic
# disable the pc speaker beep
rmmod pcspkr 2>/dev/null || true
echo "blacklist pcspkr" > /etc/modprobe.d/nobeep.conf
@@ -2697,6 +2964,61 @@ desktop_environment() {
(systemctl enable cups.service >> "$logfile" 2>&1) || error_warn "$action" "$?"
}
+# Is this a Framework Laptop 13 on AMD Ryzen AI 300 -- the board whose ACP PDM
+# digital mic the kernel gets wrong? $1 is the DMI directory, defaulting to the
+# system path so the gate runs against fixtures.
+#
+# The gate reads the model string rather than looking for the acp-pdm-mach
+# card: that card exists on every AMD laptop with a PDM mic, fine or broken,
+# and it stays present after the upstream fix lands, so its presence says
+# nothing about whether the workaround belongs. The hostname is out for the
+# usual reason -- the fix belongs to the board, not to velox.
+#
+# All three facts are needed. "Framework" lives only in sys_vendor. "Laptop
+# 13" rules out the Framework Desktop (ratio, which has no internal mic to
+# break) and the Framework 16. "AMD Ryzen AI 300" rules out the Intel 13 (SOF
+# stack, no ACP path) and the Ryzen 7040 13 (Phoenix; the workaround is
+# unverified there), and it does not match ratio's "AMD Ryzen AI Max 300".
+is_framework_13_ryzen_ai_300() {
+ local dmi="${1:-/sys/class/dmi/id}"
+ local vendor model
+ vendor="$(cat "$dmi/sys_vendor" 2>/dev/null)"
+ model="$(cat "$dmi/product_name" 2>/dev/null)"
+ printf '%s' "$vendor" | grep -qi "framework" || return 1
+ printf '%s' "$model" | grep -qi "laptop 13" || return 1
+ printf '%s' "$model" | grep -q "AMD Ryzen AI 300" || return 1
+ return 0
+}
+
+configure_framework_amd_internal_mic() {
+ # Internal-mic workaround for the Framework 13 on AMD Ryzen AI 300, matched
+ # by DMI (see is_framework_13_ryzen_ai_300). A known, still-open kernel bug
+ # in the ACP PDM digital-mic path makes the DMIC card (acp-pdm-mach) emit a
+ # constant full-scale rail instead of audio, and with UCM on that broken
+ # DMIC is the only internal mic PipeWire offers, so the built-in microphone
+ # is dead in every app (velox, 2026-10-06; Framework community threads
+ # 81866 and 75748). The shipped WirePlumber drop-in turns UCM off, which
+ # makes the ALC285 fall back to its analog-stereo profile whose Internal
+ # Microphone pin carries real audio, and disables the standalone PDM card
+ # so nothing can select it. It is the shipped layer: a re-run refreshes it,
+ # and removing it plus a wireplumber restart reverts the machine.
+ # The installer cannot restart wireplumber itself -- it is a user service
+ # and there is no user session bus during install -- so the drop-in takes
+ # effect when wireplumber next starts (first login on a fresh install;
+ # `systemctl --user restart wireplumber` on a repair re-run).
+ # Lives beside the pipewire/wireplumber package install because that is
+ # what creates the daemon the drop-in configures. When the upstream fix
+ # lands, the DMIC records real audio with UCM back on, and this step goes.
+ # $1/$2 are the wireplumber conf.d dir and the DMI dir, defaulting to the
+ # system paths so tests can run against fixtures.
+ local confd="${1:-/etc/wireplumber/wireplumber.conf.d}" dmi="${2:-/sys/class/dmi/id}"
+ is_framework_13_ryzen_ai_300 "$dmi" || return 0
+ action="installing the Framework 13 AMD internal-mic workaround (UCM off)" && display "task" "$action"
+ local src="${user_archsetup_dir:-/home/$username/code/archsetup}/configs/wireplumber/50-disable-ucm.conf"
+ install -Dm644 "$src" "$confd/50-disable-ucm.conf" >> "$logfile" 2>&1 \
+ || error_warn "$action" "$?"
+}
+
### Gaming
gaming() {
display "title" "Gaming"
@@ -2864,6 +3186,7 @@ install_programming_languages() {
# Shell
pacman_install shellcheck # Shell script linter
pacman_install shfmt # Shell script formatter
+ pacman_install bash-language-server # Bash language server; prog-shell.el warns at every start without it
# Go
pacman_install delve # Go programming language debugger
@@ -2901,6 +3224,7 @@ install_programming_languages() {
# HTML
pacman_install tidy # HTML formatter
pacman_install prettier # Multi-language formatter
+ pacman_install hugo # Static site generator; builds the website repo
# General Utilities
pacman_install difftastic # Structural diff tool
@@ -3046,9 +3370,136 @@ EOF
pacman_install podman
pacman_install podman-compose
pacman_install python-dotenv
+
+ # Rootless podman API socket. Socket-activated (zero cost while idle),
+ # and every podman GUI/API client connects through it rather than the
+ # CLI — without it a client like Pods opens to an empty window with no
+ # useful error. From the winvm 2026-08-07 handoff.
+ action="enabling rootless podman API socket" && display "task" "$action"
+ enable_user_service "$username" podman.socket \
+ /usr/lib/systemd/user/podman.socket "/home/$username" sockets.target \
+ || error_warn "$action" "$?"
+
+ install_camera_passthrough_rules
+}
+
+# USB camera passthrough into the Windows VM: usbredirect must open the raw
+# USB node read-write to claim it and detach the kernel drivers, and the node
+# defaults to root-owned with no group write, so the attach fails with a bare
+# "Failed to open device!". GROUP/MODE is the verified grant (winvm,
+# 2026-08-07). The uaccess tag is kept, and the file NUMBER is load-bearing:
+# logind's ACL is applied by 73-seat-late.rules, so the tag only works from a
+# file that sorts below 73 — a 99- file adds it after that test already ran
+# (winvm correction, 2026-08-08). Whether uaccess alone would then suffice is
+# untested; the group grant stays as the belt. 3564:ff02 is the OBSBOT,
+# 046d:085e the Logitech BRIO. $1 is the rules path, defaulting to the system
+# path so tests can run against fixtures.
+install_camera_passthrough_rules() {
+ local rules_file="${1:-/etc/udev/rules.d/72-usb-passthrough-cameras.rules}"
+ action="installing camera passthrough udev rules" && display "task" "$action"
+ cat << 'EOF' > "$rules_file" || error_warn "installing camera passthrough udev rules" "$?"
+# USB camera passthrough for the Windows VM (usbredirect needs rw on the raw
+# node). GROUP/MODE is the working grant; the uaccess tag only matters because
+# this file sorts below 73-seat-late.rules, which applies the ACL.
+SUBSYSTEM=="usb", ATTR{idVendor}=="3564", ATTR{idProduct}=="ff02", GROUP="video", MODE="0660", TAG+="uaccess"
+SUBSYSTEM=="usb", ATTR{idVendor}=="046d", ATTR{idProduct}=="085e", GROUP="video", MODE="0660", TAG+="uaccess"
+EOF
}
### Supplemental Software
+mask_fwupd_passim() {
+ # fwupd depends on passim, a daemon that shares firmware metadata with
+ # other machines on the LAN by listening publicly on 0.0.0.0:27500. Any
+ # fwupdmgr run D-Bus-activates it, and the unit is static (no [Install]
+ # section), so `systemctl disable` is a no-op: it came back on velox the
+ # next time fwupdmgr ran (2026-09-12). Masking is what holds, and it is
+ # how ratio has carried it since 2026-07-21. fwupd itself is unaffected;
+ # it just stops offering metadata to the LAN.
+ run_task "masking passim (fwupd's LAN metadata sharing daemon)" systemctl mask passim.service
+}
+
+# ChatGPT desktop (the Codex app) ships for Arch as chatgpt-bin from OpenAI's
+# own signed pacman repository. I configure that repository rather than take
+# the AUR repackage of the same binary (chatgpt-desktop): the repo keeps the
+# app on ordinary pacman upgrades under OpenAI's signature, it is how ratio
+# and velox were set up on 2026-10-11 (OpenAI's install-arch.sh), and the
+# AUR package conflicts with chatgpt-bin on a machine already using the repo.
+# The drop-in is byte-identical to the one OpenAI's installer writes, so a
+# machine it configured compares equal here and is left alone.
+#
+# Trust is pinned, not delegated: the key is fetched from the publisher and
+# accepted only when its primary fingerprint matches the one carried here
+# (the same pin OpenAI's installer ships). An untrusted key imports nothing
+# and leaves pacman.conf untouched, because a half-configured repository is
+# worse than none: with the Include present and the key untrusted, SigLevel
+# TrustedOnly makes every later pacman transaction fail on the invalid
+# database. Returns non-zero only when the repository is not usable, so the
+# caller can skip chatgpt-bin instead of watching it fail.
+#
+# $1 pacman.conf path, $2 repository drop-in path (overridable for tests).
+configure_openai_chatgpt_repo() {
+ local pacman_conf="${1:-/etc/pacman.conf}"
+ local repo_conf="${2:-/etc/pacman.d/openai-chatgpt.conf}"
+ local key_url="https://persistent.oaistatic.com/codex-app-prod/linux/repository-signing-key.gpg"
+ local key_fpr="3BFA0E4AE8B8CC16A2D9BA684A3B4A566C4660E4"
+ local include_line="Include = $repo_conf"
+ local tmp fpr
+
+ if ! pacman-key --list-keys "$key_fpr" >> "$logfile" 2>&1; then
+ action="importing the OpenAI repository signing key" && display "task" "$action"
+ tmp=$(mktemp -d) || { error_warn "$action" "$?"; return 1; }
+ curl --proto '=https' --tlsv1.2 --connect-timeout 15 -fsSL \
+ -o "$tmp/key.gpg" "$key_url" >> "$logfile" 2>&1 || \
+ { error_warn "$action (download failed)" "$?"; rm -rf "$tmp"; return 1; }
+ # Primary-key fingerprint only; a subkey line must not satisfy the pin.
+ fpr=$(gpg --homedir "$tmp" --batch --show-keys --with-colons "$tmp/key.gpg" 2>> "$logfile" |
+ awk -F: '$1 == "pub" { primary=1 } $1 == "sub" { primary=0 } $1 == "fpr" && primary { print $10; primary=0 }')
+ if [ "$fpr" != "$key_fpr" ]; then
+ error_warn "$action (fingerprint ${fpr:-unreadable}, expected $key_fpr)" 1
+ rm -rf "$tmp"
+ return 1
+ fi
+ pacman-key --add "$tmp/key.gpg" >> "$logfile" 2>&1 || \
+ { error_warn "$action" "$?"; rm -rf "$tmp"; return 1; }
+ rm -rf "$tmp"
+ fi
+ # The local signature is what TrustedOnly accepts. Signing is idempotent,
+ # so an already-signed key costs nothing, and a run that added the key but
+ # died before signing gets repaired here.
+ action="trusting the OpenAI repository signing key" && display "task" "$action"
+ pacman-key --lsign-key "$key_fpr" >> "$logfile" 2>&1 || { error_warn "$action" "$?"; return 1; }
+
+ action="configuring the openai-chatgpt repository" && display "task" "$action"
+ tmp=$(mktemp) || { error_warn "$action" "$?"; return 1; }
+ cat << 'EOF' > "$tmp"
+### THIS FILE IS AUTOMATICALLY CONFIGURED ###
+# Remove it and its Include line to opt out of automatic package updates.
+[openai-chatgpt]
+SigLevel = Required DatabaseRequired TrustedOnly
+Server = https://persistent.oaistatic.com/codex-app-prod/linux/arch/$arch
+EOF
+ if [ -e "$repo_conf" ] && ! cmp -s "$repo_conf" "$tmp"; then
+ error_warn "$action (keeping the existing $repo_conf, which differs from the published definition)" 1
+ else
+ install -D -m 0644 "$tmp" "$repo_conf" 2>> "$logfile" || \
+ { error_warn "$action" "$?"; rm -f "$tmp"; return 1; }
+ fi
+ rm -f "$tmp"
+
+ if ! grep -qxF "$include_line" "$pacman_conf" 2>/dev/null; then
+ action="including the openai-chatgpt repository in $pacman_conf" && display "task" "$action"
+ backup_system_file "$pacman_conf"
+ # Start on a line of its own even when the file lacks a final newline.
+ local sep=""
+ [ -n "$(tail -c1 "$pacman_conf" 2>/dev/null)" ] && sep=$'\n'
+ printf '%s\n%s\n' "$sep" "$include_line" >> "$pacman_conf" 2>> "$logfile" || \
+ { error_warn "$action" "$?"; return 1; }
+ fi
+
+ action="refreshing package databases (openai-chatgpt)" && display "task" "$action"
+ pacman -Sy >> "$logfile" 2>&1 || { error_warn "$action" "$?"; return 1; }
+}
+
supplemental_software() {
display "title" "Supplemental Software"
@@ -3071,6 +3522,7 @@ supplemental_software() {
pacman_install fdupes # identify binary duplicates
pacman_install filezilla # ftp gui
pacman_install gimp # image editor
+ pacman_install git-lfs # large-file storage; repos tracking LFS globs fail checkout without it
pacman_install gparted # disk partition utility
pacman_install gst-plugin-pipewire # gstreamer audio plugin for pipewire
pacman_install gst-plugins-base # gstreamer base audio plugins
@@ -3082,9 +3534,11 @@ supplemental_software() {
pacman_install gucharmap # gui display of character maps
pacman_install gzip # compression tool
pacman_install handbrake # video transcoder
+ pacman_install imv # wayland image viewer (gui-open --image execs it)
pacman_install libconfig # library for processing structured config files
pacman_install libmad # mpeg audio decoder
pacman_install libmpeg2 # library for decoding mpeg video streams
+ pacman_install libreoffice-fresh # office suite; the mimeapps.list defaults resolve to its .desktop files
pacman_install maim # screenshot utility
pacman_install mosh # alt SSH terminal with roaming and responsiveness support
pacman_install odt2txt # converts from open document to text
@@ -3108,6 +3562,16 @@ supplemental_software() {
else
aur_install slack-desktop # team messaging
fi
+ # Claude desktop app (repackaged official .deb). Hard-depends on
+ # qemu-system-x86, edk2-ovmf and virtiofsd for its Cowork VM.
+ aur_install claude-desktop
+ # ChatGPT desktop (the Codex app) from OpenAI's signed repository; see
+ # configure_openai_chatgpt_repo for why not the AUR chatgpt-desktop
+ # repackage. Skipped (warning already recorded) when the repository
+ # could not be trusted, so the rest of the step is unaffected.
+ if configure_openai_chatgpt_repo; then
+ pacman_install chatgpt-bin # ChatGPT / Codex desktop app
+ fi
# zoom retired 2026-07-02: zoom-web (dotfiles) opens meetings in the browser
pacman_install iperf3 # network bandwidth testing
pacman_install bind # DNS utilities (dig, host, nslookup)
@@ -3115,6 +3579,7 @@ supplemental_software() {
pacman_install smartmontools # monitors hard drives
pacman_install lm_sensors # temperature sensors (maintenance console)
pacman_install fwupd # firmware update checks (maintenance console)
+ mask_fwupd_passim
pacman_install lynis # security auditing tool
pacman_install telegram-desktop # messenger application
# LaTeX - minimal set for document compilation with latexmk
@@ -3164,11 +3629,10 @@ supplemental_software() {
aur_install dtrx # extraction tool
aur_install hfsprogs # file system tools for Mac OS
aur_install insync # Google Drive sync client
- aur_install masterpdfeditor # PDF editor (court forms, etc.)
aur_install nsxiv # image viewer
aur_install snore-git # sleep with feedback
pacman_install gvfs-smb # SMB network share browsing in Nautilus
- pacman_install wsdd # WS-Discovery daemon (Windows network discovery)
+ pacman_install wsdd # WS-Discovery client, spawned by gvfs-wsdd; wsdd.service stays off (no Samba here)
pacman_install gvfs-wsdd # WS-Discovery backend for gvfs (browse Windows shares)
aur_install topgrade # upgrade everything utility
aur_install ueberzug # allows for displaying images in terminals
@@ -3190,6 +3654,7 @@ supplemental_software() {
boot_ux() {
action="Boot UX" && display "title" "$action"
+ install_cpu_microcode
tighten_efi_permissions
add_nvme_early_module
configure_initramfs_hook
@@ -3389,17 +3854,24 @@ configure_tlp_power() {
# TLP power management — laptops only (battery present). Manages wifi,
# USB, PCIe, and CPU power policy on AC/battery transitions. systemd-rfkill
# is masked per TLP's docs (it fights TLP's radio-state handling).
- if ls /sys/class/power_supply/BAT* &>/dev/null; then
+ # $1/$2 are the tlp.d dir and power-supply dir, defaulting to the system
+ # paths so tests can run against fixtures.
+ local tlpd="${1:-/etc/tlp.d}" psdir="${2:-/sys/class/power_supply}"
+ if ls "$psdir"/BAT* &>/dev/null; then
pacman_install tlp
action="writing TLP custom config" && display "task" "$action"
- mkdir -p /etc/tlp.d
- cat << 'EOF' > /etc/tlp.d/01-custom.conf
+ mkdir -p "$tlpd"
+ cat << 'EOF' > "$tlpd/01-custom.conf" || error_warn "writing TLP custom config" "$?"
# Custom TLP overrides (tuned on a Framework 13 Intel; generic for any laptop).
# Defaults are sane; these pin the CPU energy/perf split explicitly.
CPU_ENERGY_PERF_POLICY_ON_AC=balance_performance
CPU_ENERGY_PERF_POLICY_ON_BAT=power
PLATFORM_PROFILE_ON_AC=balanced
PLATFORM_PROFILE_ON_BAT=low-power
+# Radios on at boot: systemd-rfkill is masked (below), so TLP is the only
+# thing left that can restore radio state — without this a fresh install can
+# come up with wifi and bluetooth soft-blocked (velox, 2026-04-10).
+DEVICES_TO_ENABLE_ON_STARTUP="bluetooth wifi"
# Battery longevity: cap charge at 80% where the EC supports it.
# Off by default — uncomment (and match the BAT name) to enable.
#STOP_CHARGE_THRESH_BAT1=80
@@ -3407,10 +3879,74 @@ EOF
run_task "enabling TLP service" systemctl enable tlp.service
systemctl mask systemd-rfkill.service systemd-rfkill.socket >> "$logfile" 2>&1 || \
error_warn "masking systemd-rfkill for TLP" "$?"
+ # Masking systemd-rfkill leaves the resume edge with no owner. TLP's own
+ # sleep hook runs `tlp resume`, but DEVICES_TO_ENABLE_ON_STARTUP means
+ # startup and TLP has no ON_RESUME, so radio state is not restored after
+ # a sleep cycle. WiFi survives because NetworkManager unblocks itself;
+ # bluetooth stays soft-blocked, and after a hibernate its controller
+ # comes back wedged as well. This hook closes both, and it belongs here
+ # rather than beside the other installs because the mask above is what
+ # creates the gap it fills.
+ # The hook has to live in /usr/lib/systemd/system-sleep/: that is the
+ # only directory systemd-sleep scans (its man page names just the one,
+ # and so does the binary), which is why Arch ships no /etc counterpart.
+ # An earlier version of this step created /etc/systemd/system-sleep/
+ # and installed there, and the hook sat dead through about fifteen
+ # hibernate cycles on velox with bluetooth rfkilled after each one.
+ # The systemd package owns the /usr/lib directory, so there is nothing
+ # to create first.
+ install_executable "$user_archsetup_dir/scripts/zz-bluetooth-resume" \
+ /usr/lib/systemd/system-sleep/zz-bluetooth-resume
+ # power-profiles-daemon.service declares
+ # "Conflicts=tuned.service tlp.service auto-cpufreq.service ..." (note
+ # the direction: the line is in ppd's unit, NOT tlp's — grepping
+ # tlp.service for it finds nothing). So systemd TERMs TLP the moment ppd
+ # starts. Leaving ppd merely disabled does not prevent that: it ships
+ # D-Bus activation files, and the desktop-settings panel's own
+ # powerprofilesctl call activates it on demand. Velox ran that way from
+ # its 2026-08-13 rebuild until 2026-08-16 — TLP failed at every boot and
+ # none of its battery policy applied, while the machine looked correctly
+ # configured. Masking blocks D-Bus activation too, so TLP survives and
+ # the panel's power control reads as unavailable, which the settings
+ # engine handles.
+ systemctl mask power-profiles-daemon.service >> "$logfile" 2>&1 || \
+ error_warn "masking power-profiles-daemon for TLP" "$?"
+ # Mask first, then stop: masking blocks any re-activation in the gap, and
+ # a mask alone leaves an already-running ppd running. This script runs on
+ # a booted system (a repair or re-run is normal), so without the stop TLP
+ # stays dead until the next reboot with nothing saying so.
+ # The order is load-bearing, not cosmetic, so don't "tidy" it: only
+ # hyprland() installs ppd, so on a battery machine running dwm or no
+ # desktop env the unit does not exist. Masking first creates the
+ # /dev/null fragment, so the unit loads as masked and the stop exits 0.
+ # Reversed, the stop would hit an unloaded unit, exit 5, and fire
+ # error_warn on every such install.
+ systemctl stop power-profiles-daemon.service >> "$logfile" 2>&1 || \
+ error_warn "stopping power-profiles-daemon for TLP" "$?"
fi
}
+install_cpu_microcode() {
+ # CPU microcode updates by vendor (found missing on velox, 2026-04-10:
+ # the CPU ran old microcode until intel-ucode was installed by hand).
+ # Must run before configure_grub: grub-mkconfig detects the installed
+ # /boot/<vendor>-ucode.img for its initrd lines, and mkinitcpio's
+ # microcode hook embeds it. $1 is the cpuinfo path, defaulting to the
+ # system's so tests can run against fixtures.
+ local cpuinfo="${1:-/proc/cpuinfo}" vendor pkg
+ vendor=$(awk -F': ' '/^vendor_id/ {print $2; exit}' "$cpuinfo" 2>/dev/null)
+ case "$vendor" in
+ GenuineIntel) pkg=intel-ucode ;;
+ AuthenticAMD) pkg=amd-ucode ;;
+ *)
+ error_warn "detecting CPU vendor for microcode (vendor_id: ${vendor:-unreadable})" 1
+ return 1
+ ;;
+ esac
+ pacman_install "$pkg"
+}
+
# Which GPU vendors does this machine physically have? Prints amd / intel /
# nvidia, one per line, for each vendor found. $1 and $2 are the DRM and PCI
# modalias globs, defaulting to the system paths so the probe runs against
@@ -3650,10 +4186,12 @@ outro() {
printf "\n"
printf "If you use Proton Mail Bridge for cmail triage, finish the setup\n"
printf "after reboot:\n"
- printf " 1. Clone claude-templates to ~/projects/claude-templates if missing.\n"
- printf " 2. Run 'protonmail-bridge --cli', log in, then quit.\n"
- printf " 3. Run ~/code/archsetup/scripts/cmail-setup-finish.sh\n"
- printf " 4. First mail sync: mbsync cmail && mu index\n"
+ printf " 1. Run 'protonmail-bridge --cli', log in, then quit.\n"
+ printf " 2. Run ~/code/archsetup/scripts/cmail-setup-finish.sh\n"
+ printf " 3. First mail sync: mbsync cmail && mu index\n"
+ printf "\n"
+ printf "Sending mail also needs cmail-action, which rulesets owns:\n"
+ printf "clone it to ~/code/rulesets and run 'make install'.\n"
printf "\n"
printf "Please reboot before working with your new workstation.\n\n"
diff --git a/assets/2026-07-15-velox-zbm-zfs-list-photo.jpg b/assets/2026-07-15-velox-zbm-zfs-list-photo.jpg
new file mode 100644
index 0000000..a46cc3b
--- /dev/null
+++ b/assets/2026-07-15-velox-zbm-zfs-list-photo.jpg
Binary files differ
diff --git a/assets/2026-07-19-settings-panel-scope-decisions.org b/assets/2026-07-19-settings-panel-scope-decisions.org
new file mode 100644
index 0000000..cde8f33
--- /dev/null
+++ b/assets/2026-07-19-settings-panel-scope-decisions.org
@@ -0,0 +1,15 @@
+#+TITLE: From home: Craig's scope decisions for the desktop-settings panel spec
+#+SOURCE: from home
+#+DATE: 2026-07-19 19:49:18 -0500
+
+From home: resolved the open 'few other things' decision on the Desktop-Settings Dropdown Panel spec (docs/specs/2026-07-02-desktop-settings-panel-spec.org). Craig's calls, 2026-07-19 — fold these into the spec, close the open decision, add the new controls to the controls table, then run spec-review (may flip DRAFT->READY).
+
+ADD to the panel: (1) night-light / color temperature; (2) Do Not Disturb / notifications (dunst); (3) lock / suspend quick actions; (4) power profile (performance/balanced/saver); (5) scenes/profiles — one control that flips several toggles at once (Focus, Presentation, Battery-saver, Night). Scenes are the payoff of consolidating everything in one panel.
+
+OUT, with reasons to record: volume / master-mute stays with the audio panel (this panel does not mirror it); theme light/dark goes to the separate theme-studio task, not here.
+
+FORMAT PICKERS PULLED OUT -> their own future sibling spec. Time/date/weather format is out of THIS panel. Rationale: display-format settings are stored by many programs, so the real design problem is a single source of truth — where the canonical time/date/weather format lives and how every consumer (clock module, weather chip, etc.) reads it without drift. Track a future sibling spec stub in archsetup docs/specs for it (time/date/weather format single-source-of-truth); Craig is thinking it through separately, not started.
+
+STILL OPEN (as the spec already flags): wallpaper manager is confirmed in scope, but row-that-opens-a-sub-view vs its own sub-spec is undecided — resolve at spec-review.
+
+Full reconciliation + the archsetup panel-pattern review that informed this lives in home docs/design/2026-07-19-desktop-settings-module-brainstorm.org. Ownership model (Craig): home drives the build (dotfiles settings/), archsetup keeps the canonical spec.
diff --git a/assets/2026-07-31-agenda-json-api-contract.txt b/assets/2026-07-31-agenda-json-api-contract.txt
new file mode 100644
index 0000000..e4253e1
--- /dev/null
+++ b/assets/2026-07-31-agenda-json-api-contract.txt
@@ -0,0 +1,79 @@
+It's built and pushed. Function, call example, then three things you need to know before you wire it.
+
+* The call
+
+ (cj/agenda-window-json START-EPOCH END-EPOCH &optional OUT-PATH)
+
+From a shell:
+
+ emacsclient --eval '(cj/agenda-window-json 1785474000 1785560399 "/tmp/agenda.json")'
+
+Both bounds are epoch seconds, inclusive. It returns the JSON string and, with
+OUT-PATH, also writes it there through a temp file and a rename. The file lands
+0644 so a reader running as anyone can open it.
+
+Per row: title, start, end, all-day, type, file, keyword, done, repeater,
+location, organizer. Absent values are null rather than omitted, so the shape is
+stable. Verified against my real agenda: 13 rows today, sorted by start, both
+calendars and both todo files, no Emacs buffer left modified.
+
+* The repeater premise was wrong, and the real gap is bigger
+
+You cited "Sched. 19x:" as evidence of repeating entries. That is
+org-agenda-scheduled-leaders, and the number counts days an unfinished
+scheduled item has been carried forward. It is not a repeater. My agenda files
+contain zero repeating timestamps right now.
+
+Your instinct was right anyway, and it points at something worse than the case
+you asked about. I have 18 undone scheduled items dated before today. Org shows
+every one of them on today's agenda, because that is what carrying forward
+means. A query that selects on timestamps inside the window cannot see them. So
+today my agenda has about 31 items and this query returns 13.
+
+Deadlines do the same thing in the other direction: org surfaces a deadline up
+to 14 days before its date, and the query only returns it on the day itself.
+
+I have not guessed at what you want here, because it is a real design question
+for your surface rather than a bug. A 24-hour axis has nowhere obvious to draw
+"overdue since the 12th." Three options, roughly: leave it (a timed-events
+surface, which is defensible), add an overdue flag and let you decide where they
+go, or pin them to the window start. Tell me which and I will build it.
+
+* The .+ field is not there, and I think it should not be
+
+You asked for the fallback mark as a testable field. I did not add it, because
+the state it marks cannot occur, and I would rather tell you than ship a field
+that is permanently false.
+
+Org's own org-closest-date matches the repeater with a regexp that ignores the
+prefix entirely, so .+ and ++ expand from the base exactly like +. The restart
+semantics happen at completion time: org rewrites the base timestamp in the file
+when you close the task. So for an open task the base already is the last
+repeat, and there is nothing unresolved to mark.
+
+You said to tell you rather than guess, so this is me telling you. If you still
+want the field for defensive reasons, say so and it is a one-line addition.
+
+* Smaller things worth knowing
+
+Repeats resolve at day granularity, matching org's agenda, so an hourly repeater
+gives one row per day rather than one per hour.
+
+Archived and commented subtrees are skipped, because org's agenda skips them.
+
+Titles render org links as their description text. One of my live entries is a
+captured web item, and it would otherwise arrive as [[https://...][Tracking your
+habits]] on your wallpaper.
+
+The window is capped at 366 days, and each bound must fall between 1900 and
+2200. Both checks exist for one reason: Date.now() returns milliseconds. Passing
+it for both bounds looks like a plausible 41-day window and answers with dates in
+the year 58549, so magnitude is checked as well as width.
+
+* On the tests
+
+58 of them, across three files. The SCHEDULED one you asked for is there, and
+its fixture carries no body timestamp on purpose, so an implementation that
+drops planning timestamps fails it rather than passing on the body stamp.
+
+The trap earned its comment in the source, as you suggested.
diff --git a/assets/2026-07-31-agenda-json-render-profile.txt b/assets/2026-07-31-agenda-json-render-profile.txt
new file mode 100644
index 0000000..9b0dd5c
--- /dev/null
+++ b/assets/2026-07-31-agenda-json-render-profile.txt
@@ -0,0 +1,55 @@
+The file is there now. ~/.cache/settings/agenda.json, 13 rows, written to your
+contract. Check the wallpaper.
+
+Thank you for the correction on blocking status. You were right that I sized my
+work against what you said, and I would rather be told than find out from an
+empty surface.
+
+* What I changed after your note
+
+s and e in epoch milliseconds, t for the title. I multiplied on my side, so
+change nothing in yours. Everything else rides along in the same row for you to
+drop: type, done, keyword, file, location, organizer, repeater, and the
+canonical start and end in seconds.
+
+The two units never share a key. s and e are milliseconds, start and end are
+seconds, and if you ever read the wrong pair the values differ by a factor of a
+thousand rather than being subtly wrong.
+
+Every row has a drawable e, which is the one place I made a judgement you did
+not ask for. Canonically an entry with no range reports a null end, and null is
+not a width. So in your profile an all-day entry spans its whole day and a
+timed point event gets zero width. Tell me if you would rather have the null
+and decide yourself.
+
+* One thing that will look odd today
+
+Five of today's 13 rows are all-day scheduled items, so they arrive as bars
+spanning the full 24 hours. That is honest but probably ugly on a horizontal
+axis. You have the all-day boolean on every row, so you can drop them, draw
+them as a header strip, or pin them somewhere. Your call, and I have not
+guessed.
+
+* The refresh is not wired yet
+
+I wrote the file, so it is correct as of now. Nothing schedules a rewrite yet,
+so it will go stale after midnight. Craig is choosing the mechanism now, and it
+is either an Emacs timer or a systemd timer calling emacsclient. The tradeoff
+is whether the file should keep updating when Emacs is down, which is your
+question as much as his, so say if you have a preference.
+
+Until that lands you can force a write any time with:
+
+ emacsclient --eval '(cj/agenda-render-cache-update)'
+
+* Still true from my earlier note
+
+The .+ mark is not a field, because the state it marks cannot occur. Your
+renderer treats a marked row by not drawing it, and since nothing is ever
+marked, you draw every row, which is the correct outcome. Say the word if you
+want the field anyway.
+
+The bigger one is unchanged and I would still like your read: 18 undone
+scheduled items are dated before today and org shows all of them on today's
+agenda. Your surface has no way to know about them. That is the carry-forward
+question from my earlier note, still open.
diff --git a/assets/2026-07-31-agenda-json-window-refresh.txt b/assets/2026-07-31-agenda-json-window-refresh.txt
new file mode 100644
index 0000000..ed67175
--- /dev/null
+++ b/assets/2026-07-31-agenda-json-window-refresh.txt
@@ -0,0 +1,53 @@
+Three days is live. Reload and the left third fills in.
+
+* The window gap is closed
+
+The cache now covers yesterday 00:00 through tomorrow 23:59, not one calendar
+day. 31 rows right now instead of 13. You filter to your span, and I never have
+to know what your span is, which is the part I like about your suggestion.
+
+Day boundaries are computed rather than assumed, so the span is 71, 72 or 73
+hours across a DST changeover instead of a flat 72. Same reason your bars will
+not drift that week.
+
+* Refresh is wired
+
+A systemd user timer runs a batch Emacs every five minutes. Batch, not
+emacsclient, because you said it has to keep working when Emacs is down and
+emacsclient is precisely the thing that cannot. Nothing about it needs the
+daemon or a compositor.
+
+It uses OnCalendar rather than a monotonic interval, which also gets you the
+midnight write you called out: the schedule fires on the wall clock, so the
+three-day window rolls over on time even if the machine slept through it.
+Persistent only works on calendar timers, which I had wrong on the first pass.
+
+* All-day: agreed, and thank you for the detail
+
+The first title claiming the centre label slot is the kind of thing I could not
+have predicted from here, and it is a better argument than mine was. The flag
+stays exactly as it is and every all-day row keeps coming.
+
+* Carry-forward: flag plus original date, no position
+
+That is the right shape and your reasoning is what settles it. Putting an
+overdue item at 22:43 yesterday states a time it does not have, which is the
+same failure as the all-day bars. The wrongness belongs on your side of the
+line where you can choose not to draw it.
+
+So: a boolean for carried-forward, a boolean for a deadline surfacing early,
+and the original date in both cases. Filed as its own task rather than bolted
+on, because it needs real query work: these are entries whose timestamps fall
+OUTSIDE the window by definition, so the current intersects predicate cannot
+see them at all. It is a second pass over the same files, not a field on an
+existing row.
+
+Craig has the task and will schedule it. You are not blocked, since the surface
+is honest without them today.
+
+* On being checked
+
+You said the probe beat your premise twice. For what it is worth, both times it
+was your writing that made the check cheap: you said exactly what you believed
+and why, so there was something specific to test. A vaguer note would have sent
+me looking in the wrong place.
diff --git a/assets/2026-08-13-velox-amd-board-swap-context.org b/assets/2026-08-13-velox-amd-board-swap-context.org
new file mode 100644
index 0000000..531c365
--- /dev/null
+++ b/assets/2026-08-13-velox-amd-board-swap-context.org
@@ -0,0 +1,5 @@
+#+TITLE: Velox context for the microcode check and possible reinstall
+#+SOURCE: from work
+#+DATE: 2026-08-13 14:34:48 -0500
+
+Velox context for the microcode check and possible reinstall. Velox's mainboard was swapped today from Intel to AMD (Ryzen AI 9 HX 370, Radeon 890M, 96GB new RAM, trained fine). The old SSD is intact but the new board's NVRAM is empty, so there is no boot entry: the fix in progress from the work session is boot the Arch USB (Secure Boot enforcement must be disabled first, factory default is on), chroot, efibootmgr --create --disk /dev/nvme0n1 --part 1 --label GRUB --loader '\EFI\GRUB\grubx64.efi', then swap intel-ucode for amd-ucode, grub-mkconfig, mkinitcpio -P. Craig may instead do a full reinstall via archsetup. The ask he is bringing to this session: verify archsetup installs the correct microcode on AMD — whether it hardcodes intel-ucode or detects the CPU vendor — and fix it if it is Intel-only, since velox is now AMD and flies with him Sunday. Also note: the new board reset the RTC to 2025-01-01, so first boot may hit TLS/pacman signature grumpiness until the clock syncs.
diff --git a/assets/2026-08-13-velox-bios-boot-manager-photo.jpg b/assets/2026-08-13-velox-bios-boot-manager-photo.jpg
new file mode 100644
index 0000000..0808254
--- /dev/null
+++ b/assets/2026-08-13-velox-bios-boot-manager-photo.jpg
Binary files differ
diff --git a/assets/2026-08-13-velox-bios-setup-utility-photo.jpg b/assets/2026-08-13-velox-bios-setup-utility-photo.jpg
new file mode 100644
index 0000000..f314e31
--- /dev/null
+++ b/assets/2026-08-13-velox-bios-setup-utility-photo.jpg
Binary files differ
diff --git a/assets/2026-10-06-history-rewrite-commit-map.txt b/assets/2026-10-06-history-rewrite-commit-map.txt
new file mode 100644
index 0000000..72f08a5
--- /dev/null
+++ b/assets/2026-10-06-history-rewrite-commit-map.txt
@@ -0,0 +1,954 @@
+# git filter-repo commit map for the 2026-10-06 history rewrite (old new, one per line).
+# 59 commits changed SHA, every descendant of the 08-17 post-rebuild-check commit; the rest map to themselves.
+old new
+001c04eda1bbb681789765d91fa448e731fc173b 001c04eda1bbb681789765d91fa448e731fc173b
+0051671d0cff859ad3a67a21714005714ae1f752 0051671d0cff859ad3a67a21714005714ae1f752
+00529b3cef2eeb237be96563d0b47dc1c618b3de 00529b3cef2eeb237be96563d0b47dc1c618b3de
+007a44b2e6536e3af62c63e7e71dd124b6b9cc9d 007a44b2e6536e3af62c63e7e71dd124b6b9cc9d
+00c104036e3104312e3f95c5ddf359fc09cd7547 00c104036e3104312e3f95c5ddf359fc09cd7547
+00d1df090c00c44ac0f016b6e3bc84d3e099b93b 00d1df090c00c44ac0f016b6e3bc84d3e099b93b
+0179db57944567374f3676fe4929dcbd08697a9b 0179db57944567374f3676fe4929dcbd08697a9b
+0249b5d7b9874757220eb2d014b72a5adccecb33 0249b5d7b9874757220eb2d014b72a5adccecb33
+02a51e15938e8aa1defbe6c5b5e99ba53e193c62 02a51e15938e8aa1defbe6c5b5e99ba53e193c62
+02e8481c84cf123d31219955cafd74eec808441c 0c28aa32f6bc97228ce722e7f6e18c90e2dc23b7
+02ecf5248213469c33417ee30055b993eb97e91f 02ecf5248213469c33417ee30055b993eb97e91f
+030b667ca90c282ea16df5fbfe0fac7183bd69d5 030b667ca90c282ea16df5fbfe0fac7183bd69d5
+0355dd1b32cd337b3f5ad770596c532a5911e682 0355dd1b32cd337b3f5ad770596c532a5911e682
+03d044d573022eb156ee1b34f7f8df7a278d116a af516272d433329cec7328c11e4e25e28dc26eec
+041f65a97c1821fec7d786ddaf26a241bc53e667 041f65a97c1821fec7d786ddaf26a241bc53e667
+04783f53885c0b3b1359fefd88e88ec84476bc47 04783f53885c0b3b1359fefd88e88ec84476bc47
+04d344d90449d3fe4f0d0b474b0b50e61ff2a79a 04d344d90449d3fe4f0d0b474b0b50e61ff2a79a
+04f99818ff10ce4d49baa675aee88222313e5cd4 04f99818ff10ce4d49baa675aee88222313e5cd4
+05eebf54257069d4e2bca5419d15bc023cbae6dc 05eebf54257069d4e2bca5419d15bc023cbae6dc
+074599118036aa4a76fa9210ac1b1704d5e55b9e 074599118036aa4a76fa9210ac1b1704d5e55b9e
+0749f3d2f748367c364d056c797c66328edfc997 0749f3d2f748367c364d056c797c66328edfc997
+0795a001b6ad9c154a15a7febabdcf3663e7a826 0795a001b6ad9c154a15a7febabdcf3663e7a826
+0806d111747f7905636ce817e67a835f523327ee 0806d111747f7905636ce817e67a835f523327ee
+08635abe41a16a92058fe68a652311daa49810a1 08635abe41a16a92058fe68a652311daa49810a1
+088192bf47b403d664750f0e60ca8dd8a8636425 088192bf47b403d664750f0e60ca8dd8a8636425
+0924d125e3066271a49c56164a8c2563a8eb75af 0924d125e3066271a49c56164a8c2563a8eb75af
+092517cd3d711bbb1b88773eb62b32b82e023a3d 092517cd3d711bbb1b88773eb62b32b82e023a3d
+0a3f1afda596d742f9f3e48e1105d4ea9b4dffb1 0a3f1afda596d742f9f3e48e1105d4ea9b4dffb1
+0a4ba5002ba36324cc07551d625631eb48c0d715 0a4ba5002ba36324cc07551d625631eb48c0d715
+0a4c02d5e7b270c6fd07bb9fc34db184fb218c6e 0a4c02d5e7b270c6fd07bb9fc34db184fb218c6e
+0a98a1721271536db3e79f2f170deced839b6aec 0a98a1721271536db3e79f2f170deced839b6aec
+0a9a22972e9fac3c39835a4c9e3a0b497bdd2a0f 0a9a22972e9fac3c39835a4c9e3a0b497bdd2a0f
+0ab2f932e8f79b24b1d1e7fd58b3aa14228c61d3 0ab2f932e8f79b24b1d1e7fd58b3aa14228c61d3
+0ab8934b8852345c03aecc53a2ff8af1e5037cdd 0ab8934b8852345c03aecc53a2ff8af1e5037cdd
+0b0a680a27c45a339b5790b24d23b97f9a3ce353 0b0a680a27c45a339b5790b24d23b97f9a3ce353
+0b931368defa1c3a25756621ba9a77e9e2ed4248 0b931368defa1c3a25756621ba9a77e9e2ed4248
+0bbe76c286e7d679278b8377a5117f3fb6d76eac 0bbe76c286e7d679278b8377a5117f3fb6d76eac
+0bccc450e1d08a795b1d57a4255dd20aa8029f72 0bccc450e1d08a795b1d57a4255dd20aa8029f72
+0c61560696ba2a7ec9e3b559a8ef5e4d7ded5098 0c61560696ba2a7ec9e3b559a8ef5e4d7ded5098
+0c7c7c28f9f8fe5efcc6a9173b32b4e2bc7502f1 0c7c7c28f9f8fe5efcc6a9173b32b4e2bc7502f1
+0caa491803ffcccc0c1d0115f5711a2a6dedbb40 0caa491803ffcccc0c1d0115f5711a2a6dedbb40
+0cc8c3f296e254f5ec63c800e37be814ff15867c 0cc8c3f296e254f5ec63c800e37be814ff15867c
+0ccb69933173985bb3df186dc8abe0ccb38ce46a 0ccb69933173985bb3df186dc8abe0ccb38ce46a
+0ccd75b9bfc0a6fd202192758a656bcba1cf9f76 0ccd75b9bfc0a6fd202192758a656bcba1cf9f76
+0d29a59d7c6110bd7faec96705c3dae140da85c2 0d29a59d7c6110bd7faec96705c3dae140da85c2
+0d8a35485b3b7eacbbf542b4db758fb50cae2282 0d8a35485b3b7eacbbf542b4db758fb50cae2282
+0da519b4f5ef84788a29c872e4014c270dd3efcb 0da519b4f5ef84788a29c872e4014c270dd3efcb
+0de81447125c49acae4c90b77a38efaa04d45bf6 0de81447125c49acae4c90b77a38efaa04d45bf6
+0e1b7cf40025e84a6c5418857c5f0f0c18e74713 0e1b7cf40025e84a6c5418857c5f0f0c18e74713
+0e29c5d88888c8b07ebce4d7cbde3277ac716128 0e29c5d88888c8b07ebce4d7cbde3277ac716128
+0e551a2fefa118d5b380280d032699bf0297efcd 0e551a2fefa118d5b380280d032699bf0297efcd
+0e737a73270ff3c8ec556e8b3a7418724fd745f5 0e737a73270ff3c8ec556e8b3a7418724fd745f5
+0e99d15158cb2208f9a5adac8d456886c7cffc40 0e99d15158cb2208f9a5adac8d456886c7cffc40
+0eebbcb570fa20e83effc9fdb1267a2e61499ce2 0eebbcb570fa20e83effc9fdb1267a2e61499ce2
+0ef5731ecfdd4e4f7991e9527305da56af3b81c0 0ef5731ecfdd4e4f7991e9527305da56af3b81c0
+0f133a60f9222dad6625652eb0d668995a887f6a 0f133a60f9222dad6625652eb0d668995a887f6a
+0f31715badfb26a07369888e9731c8c4940b7669 0f31715badfb26a07369888e9731c8c4940b7669
+0f61d8cd14a3458dcf6e75762136028fbc5128d9 0f61d8cd14a3458dcf6e75762136028fbc5128d9
+0f9d4d5d0dc638d7b11a1bcb5874cef5b524a3e7 0f9d4d5d0dc638d7b11a1bcb5874cef5b524a3e7
+0fc4c8195684b1adad88da8ae310df675ddcdb4a 0fc4c8195684b1adad88da8ae310df675ddcdb4a
+0fcbee766310ee22671e160d608da85bff1bc110 0fcbee766310ee22671e160d608da85bff1bc110
+105533372d7bb4bad3de5affd6d9d3fb1477672b 105533372d7bb4bad3de5affd6d9d3fb1477672b
+10ff53933385cbc01fcd1df0ab2d2b6e127f93bc 10ff53933385cbc01fcd1df0ab2d2b6e127f93bc
+113b5e524d4e3b990fc36ba04100a0f41dd04d92 113b5e524d4e3b990fc36ba04100a0f41dd04d92
+113c80b1143ef5de0380aa3467105eb94af26272 113c80b1143ef5de0380aa3467105eb94af26272
+114abc61b0ef9b916293c5cdb0ca7e39c6525050 114abc61b0ef9b916293c5cdb0ca7e39c6525050
+11520cdfe16949ef6ba1bb6abc838e1a6155ffc3 11520cdfe16949ef6ba1bb6abc838e1a6155ffc3
+115892d7c1550b9492b6145f338808562f7aec19 115892d7c1550b9492b6145f338808562f7aec19
+11598df488ebe5fd7935ce14a84b44a43bd9ba78 11598df488ebe5fd7935ce14a84b44a43bd9ba78
+115cb7b5f19797b0fab9afa0328186a0c89ce05f 115cb7b5f19797b0fab9afa0328186a0c89ce05f
+1175c53cf15d3dfd256a0df51630c20bae065a43 1175c53cf15d3dfd256a0df51630c20bae065a43
+119eb9ea8d65bbfbde1e94b426ad5b6ca3861d98 119eb9ea8d65bbfbde1e94b426ad5b6ca3861d98
+120593b93d211688e49259862e9b8b70da3a68cb 120593b93d211688e49259862e9b8b70da3a68cb
+120aad0a33654f960ba17c5f7fb12a34e4f3e3a3 120aad0a33654f960ba17c5f7fb12a34e4f3e3a3
+127eff2b56cadf243f17188fbd0e395205f821ce 127eff2b56cadf243f17188fbd0e395205f821ce
+12ee214f1ab1271b9e4082e05ca44d6cd8a4b03a 407530e6cb0f27b70afa9511126eab58ca80d854
+1331d401540bc42b6b7652bd68cf5784a925841a 1331d401540bc42b6b7652bd68cf5784a925841a
+1343dd39e48809222f1bf509c7bee823a994bea8 1343dd39e48809222f1bf509c7bee823a994bea8
+13e0589c93a22d124add7f4df1779d9df50b841f 13e0589c93a22d124add7f4df1779d9df50b841f
+13ea5176eab0e664b1e66a88858c42d081925af4 13ea5176eab0e664b1e66a88858c42d081925af4
+14099fdbcc6375d071e9df59da855075961d5b03 14099fdbcc6375d071e9df59da855075961d5b03
+141c28cd6bcb086771117aea27bef66d73986a6d 141c28cd6bcb086771117aea27bef66d73986a6d
+147eccf52168d20eba263acc0dec60baea5a7b0e 147eccf52168d20eba263acc0dec60baea5a7b0e
+14ddfe269477637c1434d7454175ed77f6907479 14ddfe269477637c1434d7454175ed77f6907479
+14f54a9443ffa685bc7137adab93c24c63c20144 14f54a9443ffa685bc7137adab93c24c63c20144
+15119b8ae811907c15bd9797e5b15886984b3338 15119b8ae811907c15bd9797e5b15886984b3338
+158836ff433fdc51416f23d022a68a483b69603b 158836ff433fdc51416f23d022a68a483b69603b
+15c0b721c07e70efbc8f8cb5eecfee2970eed620 15c0b721c07e70efbc8f8cb5eecfee2970eed620
+169dab71a063f4fab6c46f95e11d6e85b140cb8f 169dab71a063f4fab6c46f95e11d6e85b140cb8f
+1730fccc3a5c98811ae390768734bd611b5cc986 1730fccc3a5c98811ae390768734bd611b5cc986
+17428cf724e60aedd5d0ad6197feed96e5d8e7ef 17428cf724e60aedd5d0ad6197feed96e5d8e7ef
+1844428cb3b769e830249b1b60433fa28d14cb77 1844428cb3b769e830249b1b60433fa28d14cb77
+187e175b13954584e772aa200d5939973b2dbf8f 187e175b13954584e772aa200d5939973b2dbf8f
+1940cffb273636d93c83cee3661ca520232f5340 1940cffb273636d93c83cee3661ca520232f5340
+1a791a500ab5bfb63b1e16518faabc43628282b8 1a791a500ab5bfb63b1e16518faabc43628282b8
+1b00cb9fabb1f60901e669f22d07a75c9a6b9980 1b00cb9fabb1f60901e669f22d07a75c9a6b9980
+1bb5955b121d650302ba3eab08e44cc004470a91 0b05a5ca77d40f8fb302f0e49a0f78e5fc78cf9c
+1bd920205e42793c44feba1d84ef256e1d885eb7 1bd920205e42793c44feba1d84ef256e1d885eb7
+1bf9591e246fe531e3cdd8e414170fe186cad6fd 1bf9591e246fe531e3cdd8e414170fe186cad6fd
+1c504d4d6a7a944de2ca000660d4c4461b5f1779 37e8296f83241ac14fbb4b3a8cac6eb5b4d24a17
+1c96eb59c3126356547cc9631fca11641befe981 1c96eb59c3126356547cc9631fca11641befe981
+1ca59a335959c607074d68630696864297d2da74 1ca59a335959c607074d68630696864297d2da74
+1d486a7b43e3fdd2e194b579285d979dc9beb788 1d486a7b43e3fdd2e194b579285d979dc9beb788
+1da9fcc00d347d1b21999c0f24a626963026f626 1da9fcc00d347d1b21999c0f24a626963026f626
+1dcf6abf664c00269b0cd10a36f4a829944e5763 1dcf6abf664c00269b0cd10a36f4a829944e5763
+1dd929df9607bb1ff1d50e5afd535f4a3186c619 1dd929df9607bb1ff1d50e5afd535f4a3186c619
+1de1edf664e5487777bbd4112bd2290ef2276417 1de1edf664e5487777bbd4112bd2290ef2276417
+1e3713a854caed81662752b2c6939cdb31ead49a 1e3713a854caed81662752b2c6939cdb31ead49a
+1e3f7a37b58d75cb7e1253b76676fbe428dea43e d6c51ae3ae0cd51b3f3832e831e1d2c28f6b2276
+1e62fb90096517da7722e8a79abc2df57ca61237 1e62fb90096517da7722e8a79abc2df57ca61237
+1e81319c7a41623a8e361ef3cc3898b9a023530b 1e81319c7a41623a8e361ef3cc3898b9a023530b
+1e8af4c060506e56959d7b788039f522496ee1d9 1e8af4c060506e56959d7b788039f522496ee1d9
+1e8ccf639995737ea2e1570567a86a24354c601f 1e8ccf639995737ea2e1570567a86a24354c601f
+1eadb6a23838a4b931718d4667fe36c226a9965b 1eadb6a23838a4b931718d4667fe36c226a9965b
+1f084a165b8761db2adae3c18632cf6659deff1b 1f084a165b8761db2adae3c18632cf6659deff1b
+1f20a9249cfbe11c4b18507a626bcf59f13e7829 1f20a9249cfbe11c4b18507a626bcf59f13e7829
+1f6680feb0700467a6fcfb8e3c1b8a2d7c1d1e9a 1f6680feb0700467a6fcfb8e3c1b8a2d7c1d1e9a
+1f7ae221341004ad28f56a20c8f942cd19aff965 1f7ae221341004ad28f56a20c8f942cd19aff965
+1f85ef960478c5f781a60d5ea5b6964e923ec7a5 1f85ef960478c5f781a60d5ea5b6964e923ec7a5
+20467be4dad13057a9aae6e0613fc4510d1877f3 20467be4dad13057a9aae6e0613fc4510d1877f3
+205e43d1e4c6a30d783f8e45c48b9c7a36c62dd1 205e43d1e4c6a30d783f8e45c48b9c7a36c62dd1
+2062c192179ed4ebb1378d92e560439408175c0c 2062c192179ed4ebb1378d92e560439408175c0c
+208069d02fc0f8af6083cf8f958fc19a7bef9c01 208069d02fc0f8af6083cf8f958fc19a7bef9c01
+20cf3f7b1ea10b1d03c0fa0bb7f8855f09320885 20cf3f7b1ea10b1d03c0fa0bb7f8855f09320885
+218196e4c0c1efd59137c2a9bcb2969266a3fac4 218196e4c0c1efd59137c2a9bcb2969266a3fac4
+21953c2318bb37c47c018561c50c3dd8dcff47d9 21953c2318bb37c47c018561c50c3dd8dcff47d9
+21d8dc73e025af432c02eb847d65252debebeaae 21d8dc73e025af432c02eb847d65252debebeaae
+21e5cef6ad57b5be0298730b360566ea49c45f3e 21e5cef6ad57b5be0298730b360566ea49c45f3e
+21f7ccd50eb6fe2ff11d36b42c070050a232ae7a 21f7ccd50eb6fe2ff11d36b42c070050a232ae7a
+221bf506a5619ae4d635ced55f13f5549dc351d4 3db26f67b22a17228c980d6515f80708e882462d
+22782817e7c0be4762b35501325ad2ba09f8fb09 4294899768ff983f1b7e218daabca0c018c8f8fe
+22ccde298de7828562c4526b44eca80ef89607b9 22ccde298de7828562c4526b44eca80ef89607b9
+22e90fcbb7e7173803724f118ab3feafcf6b3d1b 22e90fcbb7e7173803724f118ab3feafcf6b3d1b
+2315f4a111510140eb6afae94db503b4091553b7 2315f4a111510140eb6afae94db503b4091553b7
+232f29c84a944dc65717c9119bfbb3c3231cd981 232f29c84a944dc65717c9119bfbb3c3231cd981
+234c2316a52759591623698954a6d173341ca229 234c2316a52759591623698954a6d173341ca229
+23b206edc670c49a3d750f4753858120b48fd633 23b206edc670c49a3d750f4753858120b48fd633
+23b3cad45c8ecbcb8f6613b88598f3e2cdcd4571 23b3cad45c8ecbcb8f6613b88598f3e2cdcd4571
+23c98b106d3521485276b3811938ceaeec5811ce 23c98b106d3521485276b3811938ceaeec5811ce
+242fe5bb96f254724e4744fd01c26939947c6f01 242fe5bb96f254724e4744fd01c26939947c6f01
+24963baa69b2ecfc8e4a3b98d65101f16d6eb873 24963baa69b2ecfc8e4a3b98d65101f16d6eb873
+24e1c3ce87e7997991b1a96bf5dd4e4fce892e77 24e1c3ce87e7997991b1a96bf5dd4e4fce892e77
+24fd88d2d5553a093107572a8ef88c1611bf32b1 24fd88d2d5553a093107572a8ef88c1611bf32b1
+259ada3d5059987b1c2fbb12ca4d57d99a8a6052 259ada3d5059987b1c2fbb12ca4d57d99a8a6052
+25f16a8f995cad5c6edc8050c3b17b7fbab2ff95 25f16a8f995cad5c6edc8050c3b17b7fbab2ff95
+2604e03dc15b85a86bc52d9111760fd7d70eecd3 2604e03dc15b85a86bc52d9111760fd7d70eecd3
+265de096f1b4d7dd9ea44967db4777f67ee0c695 265de096f1b4d7dd9ea44967db4777f67ee0c695
+265f13ba0de8221ca16e68853f0bc2837d606f9b 265f13ba0de8221ca16e68853f0bc2837d606f9b
+26f9acbf6b643816be3fd691e612b7b3bbba7287 26f9acbf6b643816be3fd691e612b7b3bbba7287
+2797633b4817c5a86f04a2eaf3a08a7ab5f3b1d6 2797633b4817c5a86f04a2eaf3a08a7ab5f3b1d6
+27b592a3dafe4e8361402a07bc1dec72d2922c82 27b592a3dafe4e8361402a07bc1dec72d2922c82
+27d1a2c00c6c4dd918b2dad8585b65b0b4e9eb81 e312829a5b6175b7826088a55522afb89733b219
+289c8b4fe42088a5c983b0b79f8496a9716c4160 289c8b4fe42088a5c983b0b79f8496a9716c4160
+28e825141382172dd2ce4912e1afa7ea2ef5682f 28e825141382172dd2ce4912e1afa7ea2ef5682f
+291a650d5b7710569e2f7f0505f8925922e8bd4d 291a650d5b7710569e2f7f0505f8925922e8bd4d
+296e75abcf83a604dba118da2a31956c9792ca5f 296e75abcf83a604dba118da2a31956c9792ca5f
+2991618cb7cd33e900982f188b7fb16d15a2f3d9 2991618cb7cd33e900982f188b7fb16d15a2f3d9
+2b137d81ad4963551b684903d55e0b3abf53ae6b 2b137d81ad4963551b684903d55e0b3abf53ae6b
+2b212b9538e11b7c78a5a19aea08b102302275b7 2b212b9538e11b7c78a5a19aea08b102302275b7
+2b4346002ad80934f49848c52b321d0c8450a30f 2b4346002ad80934f49848c52b321d0c8450a30f
+2b5201300523e144654dd55160b935772b1a5610 2b5201300523e144654dd55160b935772b1a5610
+2b6222d278d32dc546a64b93b66a7d761bace866 2b6222d278d32dc546a64b93b66a7d761bace866
+2c17b9a912a6b53a2b2f4e311564305382d909fc 2c17b9a912a6b53a2b2f4e311564305382d909fc
+2c2670a2b62295990e115420cc3e4ddcb3d0042c 2c2670a2b62295990e115420cc3e4ddcb3d0042c
+2c2d6fcf6d8597257c398e1b05bd3ca58f9dfca0 2c2d6fcf6d8597257c398e1b05bd3ca58f9dfca0
+2c699930c217729cd2fd9a09c7f9ce535399e912 2c699930c217729cd2fd9a09c7f9ce535399e912
+2d2d0d95db989ef3f14b771ae2fd9e61fa5749f5 2d2d0d95db989ef3f14b771ae2fd9e61fa5749f5
+2d356a23c8900223a067c562ce73e5b8760ce6d9 2d356a23c8900223a067c562ce73e5b8760ce6d9
+2d4e039831953f4fa066d9a1a3b38eb2da4de466 2d4e039831953f4fa066d9a1a3b38eb2da4de466
+2d9083c784edc19131e3ac4421deea15480211ff 2d9083c784edc19131e3ac4421deea15480211ff
+2e303a07d1a6e4de85277a34669f8da61f7fdb6e 20196051d9cd25d775373a48fcf05dc258497301
+2e3831ff318e5fce282843d507fe3bdb3f382c9c 2e3831ff318e5fce282843d507fe3bdb3f382c9c
+2e396dd92fa5e4af0da0b9802fc9d4cb7c5fdbf9 2e396dd92fa5e4af0da0b9802fc9d4cb7c5fdbf9
+2e65750c5dced78933ab6bd9e7ff78f4fc65115e 2e65750c5dced78933ab6bd9e7ff78f4fc65115e
+2e78f55638ee80da6ce4998d37996bea73076efa 2e78f55638ee80da6ce4998d37996bea73076efa
+2eb8d489fb3ee9b0cb5a4ff9d19862032548efe4 2eb8d489fb3ee9b0cb5a4ff9d19862032548efe4
+2eee86841a0ce1b4481a9084d415eb13f1f916ac 2eee86841a0ce1b4481a9084d415eb13f1f916ac
+2f40d86f4a51a72b7d5d60cf2455b1c2ca78ac08 2f40d86f4a51a72b7d5d60cf2455b1c2ca78ac08
+2f4359d7d5762312735b3d24f87c7577d55d3ffa 2f4359d7d5762312735b3d24f87c7577d55d3ffa
+2f81174b1de13d9148113fb9f3d389ca6c61ac5c 0e85fe37d4680492a6455672835cdd65901ad22c
+2f87baa926531d7c8f2a9bd149638ed1a79a3604 2f87baa926531d7c8f2a9bd149638ed1a79a3604
+2fd1174875fc38dcb05e8fc0fcfdd5fa95a5e79e 2fd1174875fc38dcb05e8fc0fcfdd5fa95a5e79e
+3104c06f7b8e95364d8381f0e1f4144fd449186e 3104c06f7b8e95364d8381f0e1f4144fd449186e
+3146303312ab2e9f762864ec6621333e2160c6d4 3146303312ab2e9f762864ec6621333e2160c6d4
+3163d99f3a63434f11174cfe1405b47de9826614 3163d99f3a63434f11174cfe1405b47de9826614
+31d4500e3b6755f25d8d8fdb034511a7342ce6e6 31d4500e3b6755f25d8d8fdb034511a7342ce6e6
+324377297f06e610993a687c2c03740efd8b221e 324377297f06e610993a687c2c03740efd8b221e
+325dcc0b99dd2c2c88aaa85d34d480e67d4f311f 325dcc0b99dd2c2c88aaa85d34d480e67d4f311f
+327cb4aaff5eefbe9e2590835e638a57e5b4f94f 327cb4aaff5eefbe9e2590835e638a57e5b4f94f
+32a5944e907c551ac6768aaf4eb97129a8a69dec 32a5944e907c551ac6768aaf4eb97129a8a69dec
+32ef3ff747a5e295fd01dc0c62b7eda91a83a457 32ef3ff747a5e295fd01dc0c62b7eda91a83a457
+32f7ff8780bcfeb2d54d7e5a0ef8cad2a3fdeb42 32f7ff8780bcfeb2d54d7e5a0ef8cad2a3fdeb42
+33361246380db7fb3881ec6f7a5bd4055865c07c 33361246380db7fb3881ec6f7a5bd4055865c07c
+338364cfc593d4e52100f29a5ddfe811106da459 338364cfc593d4e52100f29a5ddfe811106da459
+33d6162d3adbe4efefc8fb5b2c1596c103342b1a 33d6162d3adbe4efefc8fb5b2c1596c103342b1a
+33db1c630d08ab5a123977afd135ebb79baea3ce 33db1c630d08ab5a123977afd135ebb79baea3ce
+3492767c4095c99f694353ae51b00bbeaadb1551 3492767c4095c99f694353ae51b00bbeaadb1551
+34abc7f1ce44dc5d0f8998ec83d014376638d930 34abc7f1ce44dc5d0f8998ec83d014376638d930
+35139e9fc7590ef5fd474df967d9ae6ee7f8b749 35139e9fc7590ef5fd474df967d9ae6ee7f8b749
+352394cecdbf1b5fb5e047dc973226d6503e2df0 352394cecdbf1b5fb5e047dc973226d6503e2df0
+356a38fef66bb1ab4db44e4f9db92e378dc31e62 356a38fef66bb1ab4db44e4f9db92e378dc31e62
+35a90e8928508f36c8be8b35c61c3fdc1eefe075 35a90e8928508f36c8be8b35c61c3fdc1eefe075
+35e3312974654239d3a64c5c241d57176ff49b25 35e3312974654239d3a64c5c241d57176ff49b25
+367ee3bff59fe4578f9610acee4813f3311bff9c 367ee3bff59fe4578f9610acee4813f3311bff9c
+36973f235e5ac22e91524d935ab72a37a59e9091 36973f235e5ac22e91524d935ab72a37a59e9091
+375d7abb5c517508b98f005517daad2e0d4ff37a 375d7abb5c517508b98f005517daad2e0d4ff37a
+37cc1bd0c64640fb7ff696c6d48c73f708c46146 37cc1bd0c64640fb7ff696c6d48c73f708c46146
+37fdf77a98e2a0067b37ce8ddf0c42872030f774 37fdf77a98e2a0067b37ce8ddf0c42872030f774
+38ac8232348f5b1a3050ab30a8f49e0194482ad5 38ac8232348f5b1a3050ab30a8f49e0194482ad5
+38b1758716f5e5086eac9e9cdc0f0adfba9e54e5 495d16d17b49d37336f1c1189543928b396449e0
+38dd7958520a9f590132ba92e378688d9c67e258 38dd7958520a9f590132ba92e378688d9c67e258
+38e0dd7563d59ee01113972be5a0010cf8b91c3e 38e0dd7563d59ee01113972be5a0010cf8b91c3e
+38e6ccc8a3587651cfc957472ae007dd230dff08 38e6ccc8a3587651cfc957472ae007dd230dff08
+39357449d743a4fb2f9441a5b866f2f5eda20160 39357449d743a4fb2f9441a5b866f2f5eda20160
+394a05135a53950b28ea973b7ec8dcc9060d1522 c133185373a1d87f04e23d900fe4bb8f1c570ab4
+39552d987cb731e5fd928fece9002862c827201c 39552d987cb731e5fd928fece9002862c827201c
+39a2f915c27ddeadd8a2bd6542f2cc355beb7093 39a2f915c27ddeadd8a2bd6542f2cc355beb7093
+39b08ed7fba0051454bee084c6592fbfc28bd926 39b08ed7fba0051454bee084c6592fbfc28bd926
+3a392e123b568d949584abe13a96a33b5853a93a 3a392e123b568d949584abe13a96a33b5853a93a
+3a457fae6188c98cdca1102b6da7a49e6a8b39f3 3a457fae6188c98cdca1102b6da7a49e6a8b39f3
+3a6d647c300f898890398ce7cbd1790f320ea02b 3a6d647c300f898890398ce7cbd1790f320ea02b
+3a78cbdcf00ae4056f9f2d250f1ef816dae2747e 3a78cbdcf00ae4056f9f2d250f1ef816dae2747e
+3b5eb96ee1bebc3f3aa9d1a807fbbca8071cc212 3b5eb96ee1bebc3f3aa9d1a807fbbca8071cc212
+3b754368eaa4b8b4e9cc54d4ccb8ab1f832f08d4 3b754368eaa4b8b4e9cc54d4ccb8ab1f832f08d4
+3b8aa8411f9732cdc9c11eb8457ef52c827480ce 3b8aa8411f9732cdc9c11eb8457ef52c827480ce
+3b972296a87bcf10ebea36270255b4d2a4c90b51 3b972296a87bcf10ebea36270255b4d2a4c90b51
+3ba6f910dbb0acbe6972d105ef90f7730027772a 3ba6f910dbb0acbe6972d105ef90f7730027772a
+3be5a7c7bb0847322d8fb0a33287c0de4e3c30b1 3be5a7c7bb0847322d8fb0a33287c0de4e3c30b1
+3bf0ddcc5180bd5111b1e5c59296f2496fb2c874 3bf0ddcc5180bd5111b1e5c59296f2496fb2c874
+3c19cb0538148c0caa751e637765fa2d43fff66d 3c19cb0538148c0caa751e637765fa2d43fff66d
+3c582bcffba6e5c6d5d9b1e9f2f44d94348873cd 3c582bcffba6e5c6d5d9b1e9f2f44d94348873cd
+3cb9c89146544ddcf22d72bbc65842f02b876e91 3cb9c89146544ddcf22d72bbc65842f02b876e91
+3d25aa2de36bb9dfb95613bb06df92faec019a39 3d25aa2de36bb9dfb95613bb06df92faec019a39
+3d2b820f3b0204616a25d42d3b54005e71659edd 3d2b820f3b0204616a25d42d3b54005e71659edd
+3d7e1bf5d7f6347790699b8d3c38215eb82c9cde 3d7e1bf5d7f6347790699b8d3c38215eb82c9cde
+3d84575b68eda073b5baeecebb639ef3e6cfba1d 3d84575b68eda073b5baeecebb639ef3e6cfba1d
+3df22bf89d9d808ffd49eb38c7ef06f4f6e0e33b d43fef150f41a59c92513a3f5c50858a6ff8dce4
+3e628cc36ce4cfa3e4d93a9f8c4b2e2592184dae 3e628cc36ce4cfa3e4d93a9f8c4b2e2592184dae
+3ea15f3f0af3fe3c7e8b42002ae69bca88f6238d 3ea15f3f0af3fe3c7e8b42002ae69bca88f6238d
+3fbf3e022a364fe30609fb2d8dbc49ad6c961f70 368650016357f66a591a2e4c006f31b079ca23a5
+3fd917d00d09542f81b24b5ed9553efb34924099 3fd917d00d09542f81b24b5ed9553efb34924099
+401b4ad5ec3fbf40995b6664e9868ca566a9131d 401b4ad5ec3fbf40995b6664e9868ca566a9131d
+40fe5c43cd002a4c1f932ae7173a3f671394c8b7 40fe5c43cd002a4c1f932ae7173a3f671394c8b7
+410261651866045694fa923f85dbc7c4d5cf75e0 410261651866045694fa923f85dbc7c4d5cf75e0
+4169357993e38d53c5f8753bb82663560d474baf 4169357993e38d53c5f8753bb82663560d474baf
+41872a2c3a4f33caf6751acff9f033a277408536 41872a2c3a4f33caf6751acff9f033a277408536
+41f4cf7bcc7c2604f355cd9f7cc0e960ec4b8eac 41f4cf7bcc7c2604f355cd9f7cc0e960ec4b8eac
+4200cd631848c8eeea0b220e09fbaa2ebd8c0a16 4200cd631848c8eeea0b220e09fbaa2ebd8c0a16
+4208b2b051062512fda67a79970dc8c8c0b638ac 4208b2b051062512fda67a79970dc8c8c0b638ac
+422f34f82f7cb5bc67b84c2b2ed94123111427bd 422f34f82f7cb5bc67b84c2b2ed94123111427bd
+4285319dffa22d4ab1f30c28702c12e9cc33dda2 4285319dffa22d4ab1f30c28702c12e9cc33dda2
+42cea276c4ae00c9914c79cb9c6a8d06f9a47fba 42cea276c4ae00c9914c79cb9c6a8d06f9a47fba
+42e42e0882d69d5f2391a41343a8524251cd5230 42e42e0882d69d5f2391a41343a8524251cd5230
+42f9ceb7b39caae010af54d69c5b3f9533082dfc 42f9ceb7b39caae010af54d69c5b3f9533082dfc
+430c357c984a649aa163faaee81c7ded1514073e 430c357c984a649aa163faaee81c7ded1514073e
+43725ff359d2a01668a015a4da12602ae47694d7 43725ff359d2a01668a015a4da12602ae47694d7
+43acf51c4eb036c867df2fbeef99dcaef17a09a4 64ccceff708535cba51e16c36cd77c14b6c8a729
+43d0c8fe990ecd7fb446a6f7ae3dd2d39af0cd3b 43d0c8fe990ecd7fb446a6f7ae3dd2d39af0cd3b
+4429f32048123c1d21ef5cc9cd6b32a50cd7ebc6 4429f32048123c1d21ef5cc9cd6b32a50cd7ebc6
+44502c000bf9c0e772e5299d3ef01dbc96f31f65 44502c000bf9c0e772e5299d3ef01dbc96f31f65
+4453139ba9ba9a347f8c11183219cc54be5ec37c a055d55d9fb6c5499047dce258477d8a12a8ba8f
+4469387b990c402f8d004ca2c0e460a3f44ca5d6 4469387b990c402f8d004ca2c0e460a3f44ca5d6
+446ee19f9e1cb7c179cf7ea15d3e6b471865ac1c 446ee19f9e1cb7c179cf7ea15d3e6b471865ac1c
+4489f56a4d3d1ef4cedff93624dde930829cdc74 4489f56a4d3d1ef4cedff93624dde930829cdc74
+455720b03cdee23fcd942b12adb87812aabf5f4d 455720b03cdee23fcd942b12adb87812aabf5f4d
+46022c8106ab2312782d85123ff880cec7f05747 46022c8106ab2312782d85123ff880cec7f05747
+4608084db013c631ea16694cdc1555c20413ae5b 4608084db013c631ea16694cdc1555c20413ae5b
+463f9b7de893424e1439bfd4bad2d880423cacc3 684d0b22e87a2cb685c66a66ce52086262294b34
+468dfcdf1fade38a77a7befd92d4fc55e9e8bbf9 468dfcdf1fade38a77a7befd92d4fc55e9e8bbf9
+46b6927d2b92de4a639184c194807b6e244260d5 46b6927d2b92de4a639184c194807b6e244260d5
+46edac20cdbca12d30e00db56460726583e3ce6d 46edac20cdbca12d30e00db56460726583e3ce6d
+471a0bcedd03e35cc583358003182b34f6a1f210 471a0bcedd03e35cc583358003182b34f6a1f210
+476b643b9434a887d885397da214a3169e541e17 476b643b9434a887d885397da214a3169e541e17
+481a184a83c1b4656eb35e241f6f0ab0732224ac f2d8a1a29e9fce01e972483f919a5b048a348a09
+482cd5b58625cd5b4831c6703c2d767ee9975d09 482cd5b58625cd5b4831c6703c2d767ee9975d09
+490a6da8f59d0aab74f763e3a5554f3de05bec24 490a6da8f59d0aab74f763e3a5554f3de05bec24
+492bd9cc55f970f0f336a2fa91dba2bec2146636 492bd9cc55f970f0f336a2fa91dba2bec2146636
+495826ee5a740feb23583f3f5c99a1698702e871 495826ee5a740feb23583f3f5c99a1698702e871
+49afe5636fb1f97e098abefbf91ff0fca194cba3 49afe5636fb1f97e098abefbf91ff0fca194cba3
+49e48c9dbd421933eeb4e401a3f68674d2cd7f7f 49e48c9dbd421933eeb4e401a3f68674d2cd7f7f
+4a5c4e7bfb7f94f20a6eb6a44806e64fa282efed 4a5c4e7bfb7f94f20a6eb6a44806e64fa282efed
+4ab11a47cd1bd661b0714acc34136a2ec876ca88 4ab11a47cd1bd661b0714acc34136a2ec876ca88
+4b561b117cce07683629e5eaf8ef552b55d0fc44 4b561b117cce07683629e5eaf8ef552b55d0fc44
+4b61b966cbc26210037fc5d827d8caf2b839274f 4b61b966cbc26210037fc5d827d8caf2b839274f
+4bace5842c2c75926b7d5b668a81eaa94bea2c4c 4bace5842c2c75926b7d5b668a81eaa94bea2c4c
+4bbf7ea4599b8e5c9fe080dccf476ae8c2fc805b 4bbf7ea4599b8e5c9fe080dccf476ae8c2fc805b
+4c60ca9e4f0e41605a00ca11d347fdba5d9888cd 4c60ca9e4f0e41605a00ca11d347fdba5d9888cd
+4c757d4539c431be0f9ed9701a57d5b21f8e895f 4c757d4539c431be0f9ed9701a57d5b21f8e895f
+4c7f68427710feda4dbd661cb0d8db296eb72852 4c7f68427710feda4dbd661cb0d8db296eb72852
+4c8a2216f34fc2502da5644a8cfc614b76efc4a8 4c8a2216f34fc2502da5644a8cfc614b76efc4a8
+4ca80d788246aeab024bb2f5d18a1819ddfb1d15 4ca80d788246aeab024bb2f5d18a1819ddfb1d15
+4cc25993f862dcd6f4a639f10f6096cee32664bd 4cc25993f862dcd6f4a639f10f6096cee32664bd
+4cd23c923b5de5f7b771f6d4405c856431a888c7 4cd23c923b5de5f7b771f6d4405c856431a888c7
+4ce6edb8cc5bdbab457e00e2a9a0e934015e9d59 4ce6edb8cc5bdbab457e00e2a9a0e934015e9d59
+4d48e32c47f06a9d45baa8b1932958f223c8ce24 4d48e32c47f06a9d45baa8b1932958f223c8ce24
+4dfecc5f705c9df0d1f8e8245276146f826e16cc 622b7dd5e7b3cd3268c3fedd77103293a448baad
+4e37b511b89b6ef7be1e9823d0edb306d2b79c37 4e37b511b89b6ef7be1e9823d0edb306d2b79c37
+4f252c5d64b9cbcdcb6ea9ed2accfc7c89452a24 4f252c5d64b9cbcdcb6ea9ed2accfc7c89452a24
+4f32dce461c6c2e5142510be3459cfb74f3a81b1 4f32dce461c6c2e5142510be3459cfb74f3a81b1
+4f43ac5064ee0d591c38b294d2643747674a115e 4f43ac5064ee0d591c38b294d2643747674a115e
+4f62b9cfc6ecdbd9381616223135f69ac09d68b8 4f62b9cfc6ecdbd9381616223135f69ac09d68b8
+4f7e43421abfa28bacde2da3d598c179d2efbbaf 4f7e43421abfa28bacde2da3d598c179d2efbbaf
+4f915ce9ad174522923eff6f12be44919e10b947 4f915ce9ad174522923eff6f12be44919e10b947
+500b8356bd3678875a4d9c3687cd16f41d02f9d2 500b8356bd3678875a4d9c3687cd16f41d02f9d2
+503df7c271552bc089c1eb0a67e0b3c9c60eb510 503df7c271552bc089c1eb0a67e0b3c9c60eb510
+509ea145e60e26c4b1af5f4425ee687d91039927 509ea145e60e26c4b1af5f4425ee687d91039927
+50aa5ac052db9b12f049276799898089e2226f80 50aa5ac052db9b12f049276799898089e2226f80
+50ad8cdb5f92c062b940d5332771d81b6c079f4d 50ad8cdb5f92c062b940d5332771d81b6c079f4d
+50ca1ef867d1bf36ad036e34d280f644467d5898 50ca1ef867d1bf36ad036e34d280f644467d5898
+51dbae5557f8a336ef442d90f5746d88699024c7 51dbae5557f8a336ef442d90f5746d88699024c7
+51e8cf8cd10b91836d1b25f257dde1707725313f 51e8cf8cd10b91836d1b25f257dde1707725313f
+522e0ee0f51e04bead0bb4d4aa7b35abcc133f00 522e0ee0f51e04bead0bb4d4aa7b35abcc133f00
+5255ff0fca04211f466a573b65c18e4db4d1e110 5255ff0fca04211f466a573b65c18e4db4d1e110
+529c6ee33e5f4eefe55c90386082fdc53b0ded62 529c6ee33e5f4eefe55c90386082fdc53b0ded62
+52ff7883e6ba7f4c9269b3f3eaa7e20fe7e282e2 52ff7883e6ba7f4c9269b3f3eaa7e20fe7e282e2
+5310510f29d361b791467d817a26935acbf09c43 5310510f29d361b791467d817a26935acbf09c43
+537b13cd032b8cd48f980f05b8b2cbc1155e36a3 537b13cd032b8cd48f980f05b8b2cbc1155e36a3
+53ce3b6c4b74834c11605ef1ea9c6e7e0204ba35 53ce3b6c4b74834c11605ef1ea9c6e7e0204ba35
+544f0284e17243cfead96eee04210755a6216185 544f0284e17243cfead96eee04210755a6216185
+545f93132b24005874646fc928319cf238415480 545f93132b24005874646fc928319cf238415480
+54695f39df1b07e000cbcab36858a14a37156066 54695f39df1b07e000cbcab36858a14a37156066
+55092dd7b11c84996ed99d04329b70f577407678 55092dd7b11c84996ed99d04329b70f577407678
+565cb80d10a0a382d320cec18582e92a09c0e214 565cb80d10a0a382d320cec18582e92a09c0e214
+57156271a37584907045f27654ae58309b4e0a0c 57156271a37584907045f27654ae58309b4e0a0c
+5733ee098e0eefe27da8fddcdaa98a1f19d453fd 5733ee098e0eefe27da8fddcdaa98a1f19d453fd
+574ca0d4427256788574ff6380fec382f91ceb4a 574ca0d4427256788574ff6380fec382f91ceb4a
+58904be957de0555dcd1f163890dcacb568e262a 58904be957de0555dcd1f163890dcacb568e262a
+590fc00ead35d1d61ea9dfea6c1905b2516c6953 590fc00ead35d1d61ea9dfea6c1905b2516c6953
+594995fa65149638e843f98b6a99a9cc2090457f 594995fa65149638e843f98b6a99a9cc2090457f
+594dfed71068cb3a5629a46ad28f35a0155a5950 594dfed71068cb3a5629a46ad28f35a0155a5950
+59760335a1fe7ec692a2a613d6565507de8f9502 dec0446b7e990177edc34b071ba63c4942e867be
+5984e27f16d1589fe8bb2342df90f277317b375c 5984e27f16d1589fe8bb2342df90f277317b375c
+5a330e479e90618eaa7d5c03db17ccccd96a1049 5a330e479e90618eaa7d5c03db17ccccd96a1049
+5a36b6a72d9260514cf8c77cb715e4d5b4626b0f 5a36b6a72d9260514cf8c77cb715e4d5b4626b0f
+5aea85849ffca52dd4ccfefc85ffa7a6bdcde40b 5aea85849ffca52dd4ccfefc85ffa7a6bdcde40b
+5b36f172dea7ca4791bec7032ec9c3a40869bd2b 5b36f172dea7ca4791bec7032ec9c3a40869bd2b
+5b3b64ad66679bde7d90d5937794f4125d5a2fd2 4b213a1ed6fc0829c3f74ed4e96f55ffdf4b7b42
+5bebb2f39841705c0203fdeeb1836d02b92b8049 5bebb2f39841705c0203fdeeb1836d02b92b8049
+5c7a5888ed2a68476500b00d4fe3df5d3bde2f0f 7ee4084e8700c89a25fb53eeeacc2eaf8fcc2fa0
+5cc7cc1f02d32715a90cc90510823b9a02694742 5cc7cc1f02d32715a90cc90510823b9a02694742
+5d2008cb5743a95b58952fcc20c3a5f9e393ff73 5d2008cb5743a95b58952fcc20c3a5f9e393ff73
+5d5996fca6b193d025c27df39dd1a312dcfe4e5a 5d5996fca6b193d025c27df39dd1a312dcfe4e5a
+5dbbd4ecca520d5a753f538f0e895140348771e5 5dbbd4ecca520d5a753f538f0e895140348771e5
+5dbfad841c359ce0dd489cf39df12d9109bb2ea0 5dbfad841c359ce0dd489cf39df12d9109bb2ea0
+5e2db2ba8adee1b315a7b43581b690130fd8af4b 5e2db2ba8adee1b315a7b43581b690130fd8af4b
+5eb6755aa18f847395615fa9c1ec8d5ed97d2f7f 5eb6755aa18f847395615fa9c1ec8d5ed97d2f7f
+5ee897646b3555eaf0860220983d1bd18fa32e0d 5ee897646b3555eaf0860220983d1bd18fa32e0d
+5f206bcc45ca8dc2ce6c3009f7285152c0185860 5f206bcc45ca8dc2ce6c3009f7285152c0185860
+5f3ad76a3636cb7b0fe0a4eb3e29a7c2158279b0 5f3ad76a3636cb7b0fe0a4eb3e29a7c2158279b0
+5f52d3887c610b93c6918d637f63cc326237aea5 5f52d3887c610b93c6918d637f63cc326237aea5
+5f63b11936fb3eff942082641883a620125ee3e6 5f63b11936fb3eff942082641883a620125ee3e6
+5f7c17eca6e7cfdaa4db47dcaed980e3a33ac5c6 5f7c17eca6e7cfdaa4db47dcaed980e3a33ac5c6
+5fb62f7e558e35f39ea3fa4b9fd36fe5acbd780b 5fb62f7e558e35f39ea3fa4b9fd36fe5acbd780b
+5fb6b9836ac8389bb68ce27e70331b3e2d29aa04 5fb6b9836ac8389bb68ce27e70331b3e2d29aa04
+5fee1efd003f04e50bd560c1239458ff113d42f6 5fee1efd003f04e50bd560c1239458ff113d42f6
+6009a50b8bb61caf6663b05d0b37bd5747bf1d02 6009a50b8bb61caf6663b05d0b37bd5747bf1d02
+606603d8923dab83534390c3afaad5b85e1e1521 606603d8923dab83534390c3afaad5b85e1e1521
+60fc235cf7acb9774b4699e26a9b1145469e3d98 60fc235cf7acb9774b4699e26a9b1145469e3d98
+61628faca8ccd0c32c7d63b94fbcc8c4e66ba45f 61628faca8ccd0c32c7d63b94fbcc8c4e66ba45f
+6163a0c08af167f7f4febed1f7d2d07cf032c1a9 6163a0c08af167f7f4febed1f7d2d07cf032c1a9
+6168f66cfb9bc42eba634089826c2a4b8da01fc6 6168f66cfb9bc42eba634089826c2a4b8da01fc6
+618231ad4517b4063d924b59d9129f18f246f77c 618231ad4517b4063d924b59d9129f18f246f77c
+61a3f924d8cd6e8398e4ec3e04554a921112bf00 61a3f924d8cd6e8398e4ec3e04554a921112bf00
+61c37965f9c8a01510a294da6a9369c5f1ef1e7e 61c37965f9c8a01510a294da6a9369c5f1ef1e7e
+62e006aa68df78c681cc3fa0450538c594324ed4 62e006aa68df78c681cc3fa0450538c594324ed4
+6316797f15e81b7d07d1e347b563c4233528778b 6316797f15e81b7d07d1e347b563c4233528778b
+633d6267f311de1d7fd301763a4190a8a6856e84 633d6267f311de1d7fd301763a4190a8a6856e84
+643cb7184d5ceee15ef0597f912a934239c5c58e 643cb7184d5ceee15ef0597f912a934239c5c58e
+653e888beb4ea28f84fa81950bd66dbdd8e6059c 653e888beb4ea28f84fa81950bd66dbdd8e6059c
+653ff64f7690e52922d9c95f36737db2bb5cbdcb 653ff64f7690e52922d9c95f36737db2bb5cbdcb
+65acb170f2a53109371de6f909eb4b4c63ab574b 65acb170f2a53109371de6f909eb4b4c63ab574b
+6617530df6348e825403db96ec309b6d92d54c33 6617530df6348e825403db96ec309b6d92d54c33
+671e0fcbe1ea79baa9fc3d88f322c919195c3225 671e0fcbe1ea79baa9fc3d88f322c919195c3225
+67e8d3c5e7f8e0451ff43079247f4f63db97001e 67e8d3c5e7f8e0451ff43079247f4f63db97001e
+6844594da0b4e1b41ff114a0294c34b49538951e 6844594da0b4e1b41ff114a0294c34b49538951e
+685335cdde12f8de9e29aad7f8137c3b2df8bc61 685335cdde12f8de9e29aad7f8137c3b2df8bc61
+687b23e9a4bfcb0c3824f1d844770ac41a085184 687b23e9a4bfcb0c3824f1d844770ac41a085184
+690d523e4d5008ce169c8a746551153d2f15f164 690d523e4d5008ce169c8a746551153d2f15f164
+6988cf83b8a9a99d9e52c0031c0ae32295d95bab 6988cf83b8a9a99d9e52c0031c0ae32295d95bab
+6a2e98532eeba34609d432b25b8a6664bbb34256 ba2a3e522986e0fa9576b99704453b63bfce05d0
+6a6b965277c74b1695a9fdd1318817a2ae01b528 6a6b965277c74b1695a9fdd1318817a2ae01b528
+6acbbb47e122c720e864e1d59ccdacb91b19f4e5 6acbbb47e122c720e864e1d59ccdacb91b19f4e5
+6b24bbe4248fb2a459fdc22f0c52c075dee9b612 6b24bbe4248fb2a459fdc22f0c52c075dee9b612
+6b404b042667016aa327d46f61f97ab5c858fed7 6b404b042667016aa327d46f61f97ab5c858fed7
+6b89a3fec7c3c4401208017df102b7afe4c76de8 6b89a3fec7c3c4401208017df102b7afe4c76de8
+6ba103e484118c2803ec59a991f091c364a4e775 6ba103e484118c2803ec59a991f091c364a4e775
+6bca971185ffc95b54c14d53de384e48d4b35b3a 6bca971185ffc95b54c14d53de384e48d4b35b3a
+6c14331e984dbaa0ab986aa327fb71a65c9f4f54 6c14331e984dbaa0ab986aa327fb71a65c9f4f54
+6c2ccf2346d8978057dad7095ca37a2bca9af5c1 6c2ccf2346d8978057dad7095ca37a2bca9af5c1
+6c4cad1044d16b9654f386be7df0ad818a220937 6c4cad1044d16b9654f386be7df0ad818a220937
+6dbc86df63a12b6ea16287bd1086b6aed545329b 6dbc86df63a12b6ea16287bd1086b6aed545329b
+6e5629df0f374a7cbe29f5f5aa05708f1af550e3 d2cd18514b86e66372bfead26045ab8cece7d6ff
+6ead911993f4f6dc11c2e418c3f88b176fc21dc6 1d8e36d8f86fc50b97e1e0aed7cb1959c6f0385f
+6eb244362e8e906d8e3cba1f4ad06e1d783b47d5 6eb244362e8e906d8e3cba1f4ad06e1d783b47d5
+6f286be20628453776235d9a9f5201a888accd78 6f286be20628453776235d9a9f5201a888accd78
+6f30e0d6d75a140c3d60966de698d67cd23afcd0 6f30e0d6d75a140c3d60966de698d67cd23afcd0
+6faa31c1890e931c92d18a3c42ec57e0fd2503a8 41fea69af643fb60cb8f256a60ff84a19a5a5c32
+6fe2913dc01330edaa4e8b36d06a250e63c20b6b 6fe2913dc01330edaa4e8b36d06a250e63c20b6b
+702d9b944adb9e9d64e919791e9c73a1f96c5605 702d9b944adb9e9d64e919791e9c73a1f96c5605
+70520724b6081a1a7afc30a015ccf7da4de23e41 70520724b6081a1a7afc30a015ccf7da4de23e41
+706bb525dfe041497e24bca0fee815d0de963e55 706bb525dfe041497e24bca0fee815d0de963e55
+70d2ebcad0fbc43b1b80d4bfff3d8eddeda4a66b 70d2ebcad0fbc43b1b80d4bfff3d8eddeda4a66b
+70d8b476b4435892d29f86dda83c5f22eee2cd9b 70d8b476b4435892d29f86dda83c5f22eee2cd9b
+7146c79b43c9d2610048ee653c85d3bdf5aeaccf 7146c79b43c9d2610048ee653c85d3bdf5aeaccf
+716e3f1e46ec47d1550978edff70df7087d76849 716e3f1e46ec47d1550978edff70df7087d76849
+719a5999db90b7903e8091603c92da24e57d0736 719a5999db90b7903e8091603c92da24e57d0736
+719ee0031eaacbd7ee4d13a4eabfee4cd3eb9906 719ee0031eaacbd7ee4d13a4eabfee4cd3eb9906
+722e9f9da69f00b87d99eda0183b0ef333c8744d 722e9f9da69f00b87d99eda0183b0ef333c8744d
+724a1fab21652dac2f71caeaa02143e16d581816 724a1fab21652dac2f71caeaa02143e16d581816
+72ed381574621de2c042abe49d5ed7ce3ad055dd 72ed381574621de2c042abe49d5ed7ce3ad055dd
+73f9e840812d1922a11a2fa85cb7dccec1312dcd 73f9e840812d1922a11a2fa85cb7dccec1312dcd
+74181a3235001758a0da93159f0c7a2a45fb35c2 74181a3235001758a0da93159f0c7a2a45fb35c2
+741bf0e25d5cd2ca54eee1b2da6566a1319a4174 741bf0e25d5cd2ca54eee1b2da6566a1319a4174
+7432358da4314e398d67933598f79d1668df8737 7432358da4314e398d67933598f79d1668df8737
+74486bd7b41bc5f82ef40b5028f9a003b5fffb04 74486bd7b41bc5f82ef40b5028f9a003b5fffb04
+746566d90a77cd3e616cebf9bd47c73b80cfa866 746566d90a77cd3e616cebf9bd47c73b80cfa866
+74b5b58a1476e2fa107b5e00dd358c0d1b4b174c 74b5b58a1476e2fa107b5e00dd358c0d1b4b174c
+74f0febc5a53378553fd0526cbe9f293646deddc 74f0febc5a53378553fd0526cbe9f293646deddc
+75171eedadea6f32554bc5f637a6cd13353fa122 75171eedadea6f32554bc5f637a6cd13353fa122
+751cb4d02cd9d6fdfbc2a3d2bcd509390fc9c146 751cb4d02cd9d6fdfbc2a3d2bcd509390fc9c146
+753accea633238c8cdde3cc9d25e31b6b132c79b 753accea633238c8cdde3cc9d25e31b6b132c79b
+755e8c2a47be32da14e24d243315061478d54eba 755e8c2a47be32da14e24d243315061478d54eba
+762caef347e96606c39e00c31ce3bedb5d3afa0b 762caef347e96606c39e00c31ce3bedb5d3afa0b
+76a2bbfd9fcc307a66aa220c64ccef2e903a52ec 76a2bbfd9fcc307a66aa220c64ccef2e903a52ec
+76b62a696acbe83ba8c673a269047eccc61f3137 76b62a696acbe83ba8c673a269047eccc61f3137
+76b6b7f9458863cf3f10bc83f318b013ad3711bd 76b6b7f9458863cf3f10bc83f318b013ad3711bd
+76d98bdf5f4c151b666aac8a66e842b53fbdde24 76d98bdf5f4c151b666aac8a66e842b53fbdde24
+7702c96d18a7e000bf886d5d64d8b21310cee60b 7702c96d18a7e000bf886d5d64d8b21310cee60b
+7717cc2483b7331eb478fcc7e0ce3e295350063d 7717cc2483b7331eb478fcc7e0ce3e295350063d
+77447d02dcc3d68abfbf6e097f8dc0b0f7d443ac a50fb3bf1e01aca8a540a4030e95af9af1e584cf
+777c8ab95af83848622085302be7de5fa4c3423c 777c8ab95af83848622085302be7de5fa4c3423c
+78259c54d81bbcb63dc83f6b50e3fe77db746dd7 78259c54d81bbcb63dc83f6b50e3fe77db746dd7
+7837fecfad653b66730a11f34441022ea7821f12 7837fecfad653b66730a11f34441022ea7821f12
+7863615db76eb392d5bab2a4c9df5129d820a2f6 7863615db76eb392d5bab2a4c9df5129d820a2f6
+78895ff94891dcd78cfc0e786db016b860d2db63 78895ff94891dcd78cfc0e786db016b860d2db63
+7917d0262b56a5f5f38b8cfb784e34369787e5a1 7917d0262b56a5f5f38b8cfb784e34369787e5a1
+7934fe2db74ef3e849e561d86757e3f9db3dc094 7934fe2db74ef3e849e561d86757e3f9db3dc094
+79841dfd82a1cc40460f0af014bb4c94e73515f4 79841dfd82a1cc40460f0af014bb4c94e73515f4
+7a025d94bf131b8c1b2d2cf8473d873b3e76c649 7a025d94bf131b8c1b2d2cf8473d873b3e76c649
+7a91b2a12b35cafa68a06cb85d6580954911f487 7a91b2a12b35cafa68a06cb85d6580954911f487
+7ac0c363c830840090ad81b5e9f07f350c6f6e57 7ac0c363c830840090ad81b5e9f07f350c6f6e57
+7af90dcf52ed8d0712aaf57c9f303820e300a094 7af90dcf52ed8d0712aaf57c9f303820e300a094
+7b04c45577aed34e3e7d1b4aec4486151ae5dc99 7b04c45577aed34e3e7d1b4aec4486151ae5dc99
+7b57eb8a801887b15e8c54f25aae167e07917bec 7b57eb8a801887b15e8c54f25aae167e07917bec
+7b68f77a9b99e5400472986cb80bae5fb92e2320 ab598c26b8a142454d0afdd3adf4b784655615b8
+7c2521263a0e5691fc1b3a0d3f4387db1324443d 7c2521263a0e5691fc1b3a0d3f4387db1324443d
+7c38d0d0252f4ac551e480783f315bb325dce4fa 7c38d0d0252f4ac551e480783f315bb325dce4fa
+7c3f53ed7cc2a676ad27498101ab506db910e9f8 7c3f53ed7cc2a676ad27498101ab506db910e9f8
+7d2571b33232b40ec96c10b30dd0ecd86199c506 6c756ad3d02c5b201ee443f3b9fcb53323d2e19d
+7da4afdcdaa15a3af47d3295460b9298288d3bcb 7da4afdcdaa15a3af47d3295460b9298288d3bcb
+7e20256036c10323176dc77401ab7ce41ffc87f9 7e20256036c10323176dc77401ab7ce41ffc87f9
+7e44e2e45aacb4c6ea61f88b97418979c7b2eb4f 7e44e2e45aacb4c6ea61f88b97418979c7b2eb4f
+7e8d8473c9005969416aeb08cae033809fe90f98 7e8d8473c9005969416aeb08cae033809fe90f98
+7ed8e5701c83c5e26c2416b9a0365c12f801f7ed cfe9ab2c9a23757bf23d827c22d183e42f458541
+7f3127fa976ab4007313bfa3eb687f44fa02f3d6 7f3127fa976ab4007313bfa3eb687f44fa02f3d6
+803356d57b9a42d614d3f79e0aba434aef2f6dd7 803356d57b9a42d614d3f79e0aba434aef2f6dd7
+808d75fbe689433e509ec2bc79274b1ac75f5475 808d75fbe689433e509ec2bc79274b1ac75f5475
+811b22cf2f8c5ab8f71bf7f18d5db0a9b34a4fe4 811b22cf2f8c5ab8f71bf7f18d5db0a9b34a4fe4
+81c4a1d7f360e3dd0162a7f905f804d19fb7251c 81c4a1d7f360e3dd0162a7f905f804d19fb7251c
+81dddfe8810d22201d0af9bdbc254949b4028f1b 73a2a5e6a7fd582bccab858b3b7e9a1c61e6e750
+8252c494d274d3e5e122b780d8c685f03c48ef43 8252c494d274d3e5e122b780d8c685f03c48ef43
+828889c87a304174837b070416769973f4fae0c5 828889c87a304174837b070416769973f4fae0c5
+828d4cc76fe8cb01ed89ec55cd83850697b7fdec 828d4cc76fe8cb01ed89ec55cd83850697b7fdec
+82b3ec742d1345021ab70e8f7645baa72a98269e 82b3ec742d1345021ab70e8f7645baa72a98269e
+82f2f645785b2eef464ea8164aafa57d4b7deb4b 82f2f645785b2eef464ea8164aafa57d4b7deb4b
+831bae31c0ee5602012f27086ecc9ccf886ae62a 831bae31c0ee5602012f27086ecc9ccf886ae62a
+834cee6f0918cd8f94a83cb095e6c3cbcede6765 834cee6f0918cd8f94a83cb095e6c3cbcede6765
+83b773719872fa2cc2e145b8fae5c252c3f799dd 83b773719872fa2cc2e145b8fae5c252c3f799dd
+83f3d2ba6f898c1fbfe13cbda3e2a7a7c6aca182 83f3d2ba6f898c1fbfe13cbda3e2a7a7c6aca182
+8472977e74c588508b6583f19864b2f947da767f 8472977e74c588508b6583f19864b2f947da767f
+84be3ecae54a752986853847d04d22b28fb4eb93 84be3ecae54a752986853847d04d22b28fb4eb93
+85858db9957509fc5dae11ae8640de261d5d1658 85858db9957509fc5dae11ae8640de261d5d1658
+85a0cb1fa5234fcfc35ad38b575a2f3850f91787 85a0cb1fa5234fcfc35ad38b575a2f3850f91787
+85cfe35efd105f047a6dad4d7fba7987d4bc8315 85cfe35efd105f047a6dad4d7fba7987d4bc8315
+85dcae46c0ea39bc587757a5363e483d9ece77de 85dcae46c0ea39bc587757a5363e483d9ece77de
+86bf4d94333f582e10e5d129f578d29c871cbbd6 86bf4d94333f582e10e5d129f578d29c871cbbd6
+86f8dcea6df55fed82883b925dd3f6c584427193 86f8dcea6df55fed82883b925dd3f6c584427193
+8716f05c6b4e0837cf973e150781fd0b7aef5647 8716f05c6b4e0837cf973e150781fd0b7aef5647
+874ddea038274263e01b32edc2ab6405659f7c5e 874ddea038274263e01b32edc2ab6405659f7c5e
+8767fbdc75f420c86b13fa30e7e24dd9224e761c 8767fbdc75f420c86b13fa30e7e24dd9224e761c
+87826f827ff177e9b4c06db7839e3c4edbe4ac3f 87826f827ff177e9b4c06db7839e3c4edbe4ac3f
+87a8a4f16f4c3eaa84089058693859f070ba5c39 87a8a4f16f4c3eaa84089058693859f070ba5c39
+87ff0b77cdc1d8a66a063cfe471dab78d1966dc4 3397463c0d40e8ec95fb6788a492ddde486076d9
+881c22aab76b0faecfa9264fb4643f704db42182 881c22aab76b0faecfa9264fb4643f704db42182
+881d80fe1d335ce4f24db104b0016756189f9e14 3472deda0fcc13425717554b3708052b078ff9d2
+881f2bc64357156b2ff2f071ee1492f769ded168 881f2bc64357156b2ff2f071ee1492f769ded168
+88848c49e4bd65c96f8ffdef615115d1fce59679 88848c49e4bd65c96f8ffdef615115d1fce59679
+88f581979b0e3e9ae43b7abc7cb5f9121aa384d8 88f581979b0e3e9ae43b7abc7cb5f9121aa384d8
+88faaf0f4e0679f63210a8609168caf918ded147 88faaf0f4e0679f63210a8609168caf918ded147
+892f746322c47ca6a1e28f2c975daba964af4a73 892f746322c47ca6a1e28f2c975daba964af4a73
+89763be913441041206c844f64b679ed8cb53b45 89763be913441041206c844f64b679ed8cb53b45
+89affd552a5346a7ff90664dcee64168468a2836 89affd552a5346a7ff90664dcee64168468a2836
+89e959a9cf2cac30ae356beb53e981743b20f2fd 89e959a9cf2cac30ae356beb53e981743b20f2fd
+8a011485283bf05c4662f92444721716f3780370 8a011485283bf05c4662f92444721716f3780370
+8a1555fa707b8ad7b98b9f312454b77816207bc3 ec5a021194c4ab3b9d9fd2b260b183ad0bdf347f
+8a182fc8b983c32275efa442d7d59667c864e736 8a182fc8b983c32275efa442d7d59667c864e736
+8a32861f98e2c87e1f0cc98b4298e0eaedb3942a 8a32861f98e2c87e1f0cc98b4298e0eaedb3942a
+8a3971710794f209dbef28c8e6df28ddbc011c64 8a3971710794f209dbef28c8e6df28ddbc011c64
+8a85e50809de20cbf4c90a3c61349af0999cb0c1 8a85e50809de20cbf4c90a3c61349af0999cb0c1
+8b5e438d787ff73c3344b4c5ea1141cf8b23ef69 7d20bc89cd2ea92877558b18b4e0ccac6fa17113
+8bcd9cf1378845b0af9907055d2150ff013cfde3 8bcd9cf1378845b0af9907055d2150ff013cfde3
+8c0ce858e908444e083d0934d0fa6109ce3b3c06 8c0ce858e908444e083d0934d0fa6109ce3b3c06
+8c296ddeb82715ac773facb70002323747790dae 8c296ddeb82715ac773facb70002323747790dae
+8c4d47f1e97dfa8e875e21cf57e88f3bb35d3eb8 8c4d47f1e97dfa8e875e21cf57e88f3bb35d3eb8
+8cbb07e9e65d2f523c64080331e1868c7ab2c906 8cbb07e9e65d2f523c64080331e1868c7ab2c906
+8cdf3cbda9fd5eeab0f91571eda1872594a86af7 8cdf3cbda9fd5eeab0f91571eda1872594a86af7
+8da655b62e43512cf81560e91c97a517d1e0ea13 8da655b62e43512cf81560e91c97a517d1e0ea13
+8e071556942f30f72d7fff72e2e9b41f4a824611 8e071556942f30f72d7fff72e2e9b41f4a824611
+8e169235429e02c30e8cb8375da85ac5ed98e507 8e169235429e02c30e8cb8375da85ac5ed98e507
+8e92729a795eff948e52618e1b4e22f8f81aa6ac 8e92729a795eff948e52618e1b4e22f8f81aa6ac
+8e97d64d67f7ef5db1e3c778dac0d1b4690546b1 8e97d64d67f7ef5db1e3c778dac0d1b4690546b1
+8ea5bd45fb6d613a48d5285e99026925a4dfaae0 8ea5bd45fb6d613a48d5285e99026925a4dfaae0
+8ef0eadd453d33cc9a7a4a98b31ee3c87d3f7122 8ef0eadd453d33cc9a7a4a98b31ee3c87d3f7122
+8f27ba2a64362501a0ef3b7e986dfd98acc5de69 8f27ba2a64362501a0ef3b7e986dfd98acc5de69
+8f6f487a4399bf265c9a925830b07de908addf4c 8f6f487a4399bf265c9a925830b07de908addf4c
+8fd6157947eba7bb8769c1282d04cd346e83faf8 8fd6157947eba7bb8769c1282d04cd346e83faf8
+9208de9645ad8a5aae16ee0a3e90b4f0dd949e1d 9208de9645ad8a5aae16ee0a3e90b4f0dd949e1d
+925ed45310b81cd76366c08929a1af42d247c494 925ed45310b81cd76366c08929a1af42d247c494
+926a9a3e34557b259394c25ca8d10c675438515d 926a9a3e34557b259394c25ca8d10c675438515d
+9280d86951c978a9c29a62e1e4dcc22130a8f437 9280d86951c978a9c29a62e1e4dcc22130a8f437
+92c64d84f958721dc02eddff564d80c60b14d004 92c64d84f958721dc02eddff564d80c60b14d004
+92e5e6cd41d7204693b4a9ef0027378ebff617db 92e5e6cd41d7204693b4a9ef0027378ebff617db
+9355bd646922be627b65cacf6724a955308118b1 9355bd646922be627b65cacf6724a955308118b1
+936696f257dc42a4485be2a4608d4ec1acec5e47 936696f257dc42a4485be2a4608d4ec1acec5e47
+939680c2730778bdd6b8f57c99f862444386fd66 939680c2730778bdd6b8f57c99f862444386fd66
+93a5d7e7e595fb5539ffb700ab7070ceb865e1ca 93a5d7e7e595fb5539ffb700ab7070ceb865e1ca
+93cdd0952f6c2e274273812e915b4d2df66f2401 93cdd0952f6c2e274273812e915b4d2df66f2401
+93eae95e847567d4c28627aae77d9c7d28f81276 93eae95e847567d4c28627aae77d9c7d28f81276
+948ead702f10c22a37d8fad64ae8b37d605c99eb 948ead702f10c22a37d8fad64ae8b37d605c99eb
+9501ad6f4f0a64b09885b6c892829235213a19a0 9501ad6f4f0a64b09885b6c892829235213a19a0
+958fde5022d91a6fbfd15d7103aa4f4b0cf7af21 958fde5022d91a6fbfd15d7103aa4f4b0cf7af21
+95bf34768a2dd0ea2c4ed1730ea95b91d8d99e7e 95bf34768a2dd0ea2c4ed1730ea95b91d8d99e7e
+95eda39e1e26936d00ab756ba1709b708326177a 95eda39e1e26936d00ab756ba1709b708326177a
+962593f168e1b772fb2594884473a31599f57cf2 962593f168e1b772fb2594884473a31599f57cf2
+969a6d9b96c86c7c87d05e5775ad5a705b6ac8a0 969a6d9b96c86c7c87d05e5775ad5a705b6ac8a0
+9706c766f47ccf64758e08810429d6b1a4b3e6d5 9706c766f47ccf64758e08810429d6b1a4b3e6d5
+97360b810f303a0795e74361ec78b4a2af606e7a 97360b810f303a0795e74361ec78b4a2af606e7a
+97cdca20456f4e7e9025184aa11316d031287e0d 97cdca20456f4e7e9025184aa11316d031287e0d
+980eebf8e30046706e6a6084af3202ab0dcb2bfa 980eebf8e30046706e6a6084af3202ab0dcb2bfa
+988f0ec401c8bc3a9ef5aba39cc083736ecd1f12 988f0ec401c8bc3a9ef5aba39cc083736ecd1f12
+98ae8c8855e62820063037c1755534aa49e0330b 98ae8c8855e62820063037c1755534aa49e0330b
+98e023602aec53677d0b5966884ef251d71c3684 98e023602aec53677d0b5966884ef251d71c3684
+997d7255cbd8440e4a9a91e316e7bf3f8513889e 997d7255cbd8440e4a9a91e316e7bf3f8513889e
+99d9d6bcc5ae0f1bd13ceab45fbd185ec67c6f9b 99d9d6bcc5ae0f1bd13ceab45fbd185ec67c6f9b
+99f2b2b5a93a872cf5039a8ad5721ef7d80d6d9e 99f2b2b5a93a872cf5039a8ad5721ef7d80d6d9e
+9a05b1849d5dab94b499c82acc60cea0a2e6805e 9a05b1849d5dab94b499c82acc60cea0a2e6805e
+9a18d217c8123a866ebd189ac198d1802178e124 9a18d217c8123a866ebd189ac198d1802178e124
+9b0a874aeb3677bab28df094e87bd29f46da6533 9b0a874aeb3677bab28df094e87bd29f46da6533
+9b15e96cfa93b1b8424827616432ba3fbea2756c 9b15e96cfa93b1b8424827616432ba3fbea2756c
+9b405b697cef5a9be5b2b8eeaa20a8c6dff1ff69 9b405b697cef5a9be5b2b8eeaa20a8c6dff1ff69
+9b794174dfc757c4d233eb7e24cfbcc727a42345 9b794174dfc757c4d233eb7e24cfbcc727a42345
+9c1c19cf975740282a5fe116916a3e2565d9d6f9 9c1c19cf975740282a5fe116916a3e2565d9d6f9
+9d03f6dfa10f0eb8a045dfff466e6533cdd5446a 9d03f6dfa10f0eb8a045dfff466e6533cdd5446a
+9d30025bcae3cd83a4411c9583b8c07833752ac6 9d30025bcae3cd83a4411c9583b8c07833752ac6
+9de74d5ac81f50221e3aa5e46a5c122fe65d2873 9de74d5ac81f50221e3aa5e46a5c122fe65d2873
+9e3b5ee755e14d5773bb62b20947696c6839effd 9e3b5ee755e14d5773bb62b20947696c6839effd
+9e5050ceed600be252216ac223bc84adf8981fed 9e5050ceed600be252216ac223bc84adf8981fed
+9e8c8417fa0938b5d5304ace22b76e328e59efae f0f06aa62438bb2cdf44cb3365042adabf7fd100
+9fa438e990807e358b126f896b84d8b4d45a084b 9fa438e990807e358b126f896b84d8b4d45a084b
+a0063206efa30fc11212873e2910a43e453ecaba a0063206efa30fc11212873e2910a43e453ecaba
+a028aa589056167160b39cf9e023c12dd30dec34 9f3ab5d0e6aa8936bfffea6027e5882ce87108de
+a09bdc62673bf454df1b1895c25b61d75e8128fb a09bdc62673bf454df1b1895c25b61d75e8128fb
+a0d5cdd4da404c02950eba5e60abcae72ac6f46f a0d5cdd4da404c02950eba5e60abcae72ac6f46f
+a1b8d705a041f4acfd53e46348e83ebb5d4d77ac a1b8d705a041f4acfd53e46348e83ebb5d4d77ac
+a20db511a2f03d9eb40b2faa606174c260f544d7 a20db511a2f03d9eb40b2faa606174c260f544d7
+a235311f544ae076fce7a4b4c6b339af2e4aab28 a235311f544ae076fce7a4b4c6b339af2e4aab28
+a24ed657d88b76b48c4dbba838bdabe46520c367 a24ed657d88b76b48c4dbba838bdabe46520c367
+a2adfd4412f25cb5fa302dd6ab5ad3a85e39c693 a2adfd4412f25cb5fa302dd6ab5ad3a85e39c693
+a2b9303126119083d8783f9033d8a2d5db952ef9 a2b9303126119083d8783f9033d8a2d5db952ef9
+a33870dfd8f0732c2014ff2cfdae5c20755f228c a33870dfd8f0732c2014ff2cfdae5c20755f228c
+a363437aa6677e210444d47e027aed0a11253dc2 a363437aa6677e210444d47e027aed0a11253dc2
+a36f8ff2fdd5efd31af36bbb378be7cebe2359a2 a36f8ff2fdd5efd31af36bbb378be7cebe2359a2
+a3a78114a0f1ba75181bd8d8e776bc0e17ecb2db a3a78114a0f1ba75181bd8d8e776bc0e17ecb2db
+a3a87dae939889cae34c4babeb7f7b42bc08aab3 a3a87dae939889cae34c4babeb7f7b42bc08aab3
+a3bb7250dce20eb66e9eb1befbdd481229220e22 a3bb7250dce20eb66e9eb1befbdd481229220e22
+a3bee1daa73b2b7d7d2815c3d8f54cbc1fe11378 a3bee1daa73b2b7d7d2815c3d8f54cbc1fe11378
+a3bf561c67bc155e3276395ad4276dc268e61370 a3bf561c67bc155e3276395ad4276dc268e61370
+a3e96e4e600eafa7b6ee1ec3ae4de79ec3613f0b a3e96e4e600eafa7b6ee1ec3ae4de79ec3613f0b
+a42e06d6ef4fc6296a17c69580aa9911667a6c88 a42e06d6ef4fc6296a17c69580aa9911667a6c88
+a499ed2cc9a2ed49da4059c471da669448d9951a a499ed2cc9a2ed49da4059c471da669448d9951a
+a51b900ba729d0dc0fd22b5c41ab508f547deaf1 a51b900ba729d0dc0fd22b5c41ab508f547deaf1
+a5323e7cbdc04936153db343c48bf17fabaccbcf a5323e7cbdc04936153db343c48bf17fabaccbcf
+a55800c79ab1fe55cba151018ad849750b4e5946 a55800c79ab1fe55cba151018ad849750b4e5946
+a558cfb2e1e7f7e31feb8c0972a88892129e63d7 a558cfb2e1e7f7e31feb8c0972a88892129e63d7
+a5f08f03af7b29620cfa4b1d1bef9ff5effb47fe a5f08f03af7b29620cfa4b1d1bef9ff5effb47fe
+a61c6725e98bcc599f4dac2db4ffab616475f800 a61c6725e98bcc599f4dac2db4ffab616475f800
+a6d650d50af2ed5bf5a510d48e2a94c045aa5729 a6d650d50af2ed5bf5a510d48e2a94c045aa5729
+a6d6a283545884501ff34f2be023ad0d40e14ef5 a6d6a283545884501ff34f2be023ad0d40e14ef5
+a75b37310752e2fe5f58a89f177681ef1deb7f4d a75b37310752e2fe5f58a89f177681ef1deb7f4d
+a7d59272ef5a507d90b0d89eca912621a99dc2b4 a7d59272ef5a507d90b0d89eca912621a99dc2b4
+a8111bb3bdcdbc7fd6fafcd0237b020dd0d9bdf3 a8111bb3bdcdbc7fd6fafcd0237b020dd0d9bdf3
+a816122f49d2c7e43e41308e251e133197a486bc a816122f49d2c7e43e41308e251e133197a486bc
+a8f5bd65d6a06507b828ffa619fed027b734cb3a a8f5bd65d6a06507b828ffa619fed027b734cb3a
+a9067341a3ca358c115531e76c51462ca948e76d a9067341a3ca358c115531e76c51462ca948e76d
+a9200d0dd7296c3baf28d0a19cf32f06401b5eaa a9200d0dd7296c3baf28d0a19cf32f06401b5eaa
+a93aacfff87870bcdd522534e790eb29dc4d47e5 a93aacfff87870bcdd522534e790eb29dc4d47e5
+a953762ad1e51fb51b5d98033107a657b69935e6 a953762ad1e51fb51b5d98033107a657b69935e6
+a96ce0c80eaf7e36aa9ffcd0b016812c898dd308 a96ce0c80eaf7e36aa9ffcd0b016812c898dd308
+a9f393284120933fadb0b88f3d1cfd0aa2168157 a9f393284120933fadb0b88f3d1cfd0aa2168157
+aa8069d6d4b5dd19ebc33bb6f1469b3aa0e4c020 aa8069d6d4b5dd19ebc33bb6f1469b3aa0e4c020
+aa84242243c04cd985206d970bf33cb0d6d05185 aa84242243c04cd985206d970bf33cb0d6d05185
+aa88bbcadd7d83b0c10b643e94a8e5dd7bfa50d5 aa88bbcadd7d83b0c10b643e94a8e5dd7bfa50d5
+aad618a4002f13bf864d73cb9c7c3d815cd4bbbe aad618a4002f13bf864d73cb9c7c3d815cd4bbbe
+aaf51023a5ef956e67824b427c5374a2ad310deb aaf51023a5ef956e67824b427c5374a2ad310deb
+ab22b2c935c3679296d86f740fdb09cf9e367670 ab22b2c935c3679296d86f740fdb09cf9e367670
+ab44b307f67159915fafa88b09b81a37b59b8e23 ab44b307f67159915fafa88b09b81a37b59b8e23
+abc6ef7e28f34036f651e294962b9e6494de0604 abc6ef7e28f34036f651e294962b9e6494de0604
+ac1c8453612bfb93e2a23843ee0fdf31eb33a19e ac1c8453612bfb93e2a23843ee0fdf31eb33a19e
+adab9abae11792eaa17711a63a74efa68885a0be 676970817ef7fdc0425205d4f3c319bdc534b51b
+adfcd6c974a37afc21c2b3b93b428a8678589961 adfcd6c974a37afc21c2b3b93b428a8678589961
+aeb32a7ea616459d0104b95c85c964a32a3edf50 aeb32a7ea616459d0104b95c85c964a32a3edf50
+aeb63ac2c1c2ad3bae3f52bc01518fccf399eced aeb63ac2c1c2ad3bae3f52bc01518fccf399eced
+aeb6bac1194181004f53eccdd75a2d062449f968 aeb6bac1194181004f53eccdd75a2d062449f968
+aec716a3d08f632c0d781a6f596cca2235be19b1 aec716a3d08f632c0d781a6f596cca2235be19b1
+af2385c8e93a4ae32c6f70ba358a5828ee2f542f af2385c8e93a4ae32c6f70ba358a5828ee2f542f
+af6df21cac93723a373c382b143ea9f546655cb0 32008ac10409e1a6f8ddb8820c6e8ac30e575db4
+af75170b6c44596552eec60b28d9eee65b69d05d af75170b6c44596552eec60b28d9eee65b69d05d
+afb3ff822af7971653786e260f0855c4a354735e afb3ff822af7971653786e260f0855c4a354735e
+afbf011aa0937b5702b6d8c1bfca0809ed809425 be0a320dd35f4f4a87341bc3f41132a2a83dbcd7
+afd1f386c2769a1e8b3bc8f388a0dfaec0fd27e7 5a886a9df88065343a3b0be19e05948215779c34
+afd20a487535787da7b704221e294cd99f69b7c1 afd20a487535787da7b704221e294cd99f69b7c1
+b062c02f7a13268fedd4707b4e8894b5742bdaee b062c02f7a13268fedd4707b4e8894b5742bdaee
+b093351226830b2b08dba4822c2f43f6e7206f3a b093351226830b2b08dba4822c2f43f6e7206f3a
+b115653f6adbdb6cf37dc7294f69f89818730e86 b115653f6adbdb6cf37dc7294f69f89818730e86
+b151abaa9a31df2ecc3be68396653b3d521997a7 b151abaa9a31df2ecc3be68396653b3d521997a7
+b1bdc94a5a0e2ea8cabf1b1bf5fe581cc822cec1 b1bdc94a5a0e2ea8cabf1b1bf5fe581cc822cec1
+b1fd20a2a4457c157261b02dbb26a2cb472718fd b1fd20a2a4457c157261b02dbb26a2cb472718fd
+b23d41767eee19d1cbb2aad0c72326a1a898a451 b23d41767eee19d1cbb2aad0c72326a1a898a451
+b253fa0dd22a940d75d9e6c75179a2128168d27a b253fa0dd22a940d75d9e6c75179a2128168d27a
+b282227745df86053c089a4df25b6553db5d0e1a b282227745df86053c089a4df25b6553db5d0e1a
+b29e1ec2c47ac1b1f8276ecede6845130c52acea b29e1ec2c47ac1b1f8276ecede6845130c52acea
+b2de82bad21ed995118e28f052ac55842a3177e8 b2de82bad21ed995118e28f052ac55842a3177e8
+b320d00b1c0c6e90a207de8f2b5aa0b1ba06ad47 b320d00b1c0c6e90a207de8f2b5aa0b1ba06ad47
+b3b020f92e27de3641ca419724fc4490b896333c b3b020f92e27de3641ca419724fc4490b896333c
+b412c08b1e558e5b48522b445f262c5064907852 b412c08b1e558e5b48522b445f262c5064907852
+b44992e498c4f3f290cef30070e50e1487752d65 b44992e498c4f3f290cef30070e50e1487752d65
+b46da23c7732b0beca248ad2920afef17555b6c4 b46da23c7732b0beca248ad2920afef17555b6c4
+b48b245548dace8b8ff1ab78e541b8e67986fc6c b48b245548dace8b8ff1ab78e541b8e67986fc6c
+b49c8c6a6cc1b7728ad0d5e0d9227ebdcc6d169b b49c8c6a6cc1b7728ad0d5e0d9227ebdcc6d169b
+b4a713c1f47ac5bb3cd056d9b3bdafadff935b4d 6e6b552051daef0a0986ae596e8431396854281c
+b4d3a6076b569c4e95f1362d23a9b279e5a5c5f9 b4d3a6076b569c4e95f1362d23a9b279e5a5c5f9
+b5316f26b5fef624587be21e05096712dbc7dfc6 b5316f26b5fef624587be21e05096712dbc7dfc6
+b55746fffaf114ae7929e4592c0d26778fc794d0 b55746fffaf114ae7929e4592c0d26778fc794d0
+b593867e3fe232022ef389bc7c7edb38bd71b69c b593867e3fe232022ef389bc7c7edb38bd71b69c
+b66f06c081e2c668d2599b82932def5c63312874 b66f06c081e2c668d2599b82932def5c63312874
+b6add81e173cb36f66014f759e1cae068f467b45 b6add81e173cb36f66014f759e1cae068f467b45
+b6b6608b76eb91aa067e6b37d2895b361264c510 b6b6608b76eb91aa067e6b37d2895b361264c510
+b6d20c99971a44e5de67c962ab53987493578d6d b6d20c99971a44e5de67c962ab53987493578d6d
+b6f67226df004b834fe922343133175a395aae7d b6f67226df004b834fe922343133175a395aae7d
+b74875295f7ae31e565879e8e83cbe77597813cc b74875295f7ae31e565879e8e83cbe77597813cc
+b7e6728082ce9a5a010736cb08ca7f8bb64c7a58 b7e6728082ce9a5a010736cb08ca7f8bb64c7a58
+b824e300f0eb8822e7b6f1839d2514f40d22de68 b824e300f0eb8822e7b6f1839d2514f40d22de68
+b86853d0ffd7f7083b5eca1dbe97214ed53cc84b b86853d0ffd7f7083b5eca1dbe97214ed53cc84b
+b8d2d39674c1c651033ae149cfb5e60c0f0c6702 b8d2d39674c1c651033ae149cfb5e60c0f0c6702
+b8dfcd1cf94e606ff52ad1fc1a2e8d8cc92c4b81 b8dfcd1cf94e606ff52ad1fc1a2e8d8cc92c4b81
+b90e4b46cdcb77482bcd25401d38a59d1a8566ac b90e4b46cdcb77482bcd25401d38a59d1a8566ac
+b9db8a7e9c1e5e55090785e37a7498f97ab83032 b9db8a7e9c1e5e55090785e37a7498f97ab83032
+b9e892c0364539bef412589393d3307c149ff912 b9e892c0364539bef412589393d3307c149ff912
+b9fa29b03b0bc6bfcdf853fbd752212460a0698a b9fa29b03b0bc6bfcdf853fbd752212460a0698a
+b9ff12704cb64e93c8275903dbddd70daf9cd99e b9ff12704cb64e93c8275903dbddd70daf9cd99e
+ba2d18b26d751cad42c59ab8e0f2eb2d4b95a858 ba2d18b26d751cad42c59ab8e0f2eb2d4b95a858
+ba6334af5b6b722eb223e0adb19c074c349782fb ba6334af5b6b722eb223e0adb19c074c349782fb
+ba98ddb842d72e194097a05d59972564c205fe99 ba98ddb842d72e194097a05d59972564c205fe99
+bb6e82535e3aa386c7175e36544c1dfcaae3aea1 bb6e82535e3aa386c7175e36544c1dfcaae3aea1
+bb725b76591f0629e17277ad3afbd0fab76b7add bb725b76591f0629e17277ad3afbd0fab76b7add
+bc3d3ac308bcd13d6543c53eae2d3f3033daed27 bc3d3ac308bcd13d6543c53eae2d3f3033daed27
+bc3fb235755f37d8f8b72a055a0c3800af393263 bc3fb235755f37d8f8b72a055a0c3800af393263
+bc57f58460e9d91b2ab9bd420cbceaa6ed18ad29 bc57f58460e9d91b2ab9bd420cbceaa6ed18ad29
+bce2396cd36ebcad20652e0d68eedc92aa79cc7b bce2396cd36ebcad20652e0d68eedc92aa79cc7b
+bcf4c2e31f954b8b1e409c4b0fdc862c46e24bbc bcf4c2e31f954b8b1e409c4b0fdc862c46e24bbc
+bd36264fc9713182982d0b4c59395dd5d55b6a89 bd36264fc9713182982d0b4c59395dd5d55b6a89
+bd9b32f1c18d8c081e032f76bea384accb8de4b1 bd9b32f1c18d8c081e032f76bea384accb8de4b1
+bdd2130e32cd8f86160e5fdf5a91b2a000ddae0f bdd2130e32cd8f86160e5fdf5a91b2a000ddae0f
+be2277d0db1afa06c1a1ab468f70b34c8d98e0a1 be2277d0db1afa06c1a1ab468f70b34c8d98e0a1
+be7ceb55016932d9672984aff973ffad06c67a30 be7ceb55016932d9672984aff973ffad06c67a30
+bf2b25997700a4320fa5b658d8e121e18a466e9e bf2b25997700a4320fa5b658d8e121e18a466e9e
+bf4516e060bdea89b837ec9a148cbb5fd7ce4a17 bf4516e060bdea89b837ec9a148cbb5fd7ce4a17
+bf7ddd1c9fc0efe451bd57bc14d63ab46dbabaed bf7ddd1c9fc0efe451bd57bc14d63ab46dbabaed
+bfd25b28b62fad9faa5e09a9f81e84d0d8097dbd bfd25b28b62fad9faa5e09a9f81e84d0d8097dbd
+bfe51d9c02c2fbafa0952440b4b78332b86e6e30 bfe51d9c02c2fbafa0952440b4b78332b86e6e30
+c041db83194efb550a10a7095ec7211e88c91963 c041db83194efb550a10a7095ec7211e88c91963
+c05aeedf67e4f2a1a3f7e255a33d016dc19385fb c05aeedf67e4f2a1a3f7e255a33d016dc19385fb
+c0e559d7af548f1832272ca03857a6e75716fe6a c0e559d7af548f1832272ca03857a6e75716fe6a
+c0e89149d3a3eff45a1309a4cd639dee8ce7c855 c0e89149d3a3eff45a1309a4cd639dee8ce7c855
+c101b80fc2a07d0fa718fe48a2d9b12bece5b756 c101b80fc2a07d0fa718fe48a2d9b12bece5b756
+c10c646bc0557fa3fd7494317d8e715929320c06 c10c646bc0557fa3fd7494317d8e715929320c06
+c13058751de4469f8f190e1696b5545ed9582abe c13058751de4469f8f190e1696b5545ed9582abe
+c15ca2adb9c757ed30e08efc06cf8f9783831880 c15ca2adb9c757ed30e08efc06cf8f9783831880
+c16d0186090440dee0238dc778a9f2c0c5eccd15 c16d0186090440dee0238dc778a9f2c0c5eccd15
+c1959c2f8a549451bc534ba4bb8946d811f2763e c1959c2f8a549451bc534ba4bb8946d811f2763e
+c1cc4b375078584a201e8622a78efc8a9403cbd8 c1cc4b375078584a201e8622a78efc8a9403cbd8
+c1db91cfd359a3c6caf7b1f848f48f37be7bbe8b 1e134f5022f626ed36279915369c06fa6df0e76e
+c208d7aad1748a9059cbc70afbc7dbcdf6fdfa92 c208d7aad1748a9059cbc70afbc7dbcdf6fdfa92
+c24174f811ad5ffca1a3454013b55127df50ff3c c24174f811ad5ffca1a3454013b55127df50ff3c
+c247206545c4123affddf0d6c88a673f4c4abc79 c247206545c4123affddf0d6c88a673f4c4abc79
+c2796a9c873ef36943a22989ec0d1150753d3035 c2796a9c873ef36943a22989ec0d1150753d3035
+c33af343c4caa90d7ff59e6f455d454089242bf6 c33af343c4caa90d7ff59e6f455d454089242bf6
+c3652a57aa9f966c5bcfa377fc0beff48656b421 c3652a57aa9f966c5bcfa377fc0beff48656b421
+c3edd86dc31ccf3db6ad6060de5a949b41a7b809 c3edd86dc31ccf3db6ad6060de5a949b41a7b809
+c40287a46658006711fb6f32c8e43e0eef0818fb c40287a46658006711fb6f32c8e43e0eef0818fb
+c409dd1644ed621f78a275809f791ec376e68779 432eba4653ddf638b6f63e8bbcd4b5f0758cd512
+c40cdd8beb92e780a220d1ddae4d6760e4f10d57 c40cdd8beb92e780a220d1ddae4d6760e4f10d57
+c4370f5b026eed2f61b18bde0cad694fd23a57a0 c4370f5b026eed2f61b18bde0cad694fd23a57a0
+c46ac5fe36e737eed6d1cd63072a6eb5a3f6adb0 c46ac5fe36e737eed6d1cd63072a6eb5a3f6adb0
+c47290949727f8bf6eb85f73561e9b9dcf3d64ee c47290949727f8bf6eb85f73561e9b9dcf3d64ee
+c47e3be3a889a344784a6ea69df08a9a1a72d4cc c47e3be3a889a344784a6ea69df08a9a1a72d4cc
+c492c069ad598b832512637d0b077ac16012a9da c492c069ad598b832512637d0b077ac16012a9da
+c4bcd2db2899b690b18380ddd8986f74a9d27334 c4bcd2db2899b690b18380ddd8986f74a9d27334
+c588b0841df440e1a5290c7150ee245f90d1788e 8731c966b8b3b184accae446aefbc13a073ec6e4
+c593ec05df3bc656f57e2396fb9179fa5ca88db0 c593ec05df3bc656f57e2396fb9179fa5ca88db0
+c5de739dbee01baa68053e5500ddeb7707a27d6c c5de739dbee01baa68053e5500ddeb7707a27d6c
+c60c6fc06cb7c53b7e939dc3988f90b73b459a16 c60c6fc06cb7c53b7e939dc3988f90b73b459a16
+c62ce51d717ff47c117d0873c097f1441a4201f3 c62ce51d717ff47c117d0873c097f1441a4201f3
+c6327a44cae4dca576995e1f2f586adf732d451e c6327a44cae4dca576995e1f2f586adf732d451e
+c659ab9b24716736390429a733e45c4ea688c13f c659ab9b24716736390429a733e45c4ea688c13f
+c667485437e9e34a2517c963152a1a86dbc19972 28d576db056d35033061a2db41b7a7ac5ac6a64e
+c6ba9f7ae6d6868ad09d8674890161fcf90092e0 c6ba9f7ae6d6868ad09d8674890161fcf90092e0
+c7687f088f3811fec353b33b5e30d31485e91a11 c7687f088f3811fec353b33b5e30d31485e91a11
+c79754c033a1823890af6d5ce3c43f3983728b9b c79754c033a1823890af6d5ce3c43f3983728b9b
+c7b10294287e37acd2e6cc71a0ac8f4c92c408e2 c7b10294287e37acd2e6cc71a0ac8f4c92c408e2
+c7e41980468d2da9af8b5f5f40438b6c98d425b0 c7e41980468d2da9af8b5f5f40438b6c98d425b0
+c815f7c1d8fdd6cc927d1255bbafc3d5477be4d9 c815f7c1d8fdd6cc927d1255bbafc3d5477be4d9
+c89a9c0652a688945f0915369ef79ea485b6e191 c89a9c0652a688945f0915369ef79ea485b6e191
+c8ac4d0040309c9b4226a331bafdfa0bd3f4bb73 c8ac4d0040309c9b4226a331bafdfa0bd3f4bb73
+c90d1d4983aeae103a32c22fa56958f76bbdb18a c90d1d4983aeae103a32c22fa56958f76bbdb18a
+c9208be3b9396821497622e20355f94d0c7b4a86 c9208be3b9396821497622e20355f94d0c7b4a86
+c98f99755e3a9f5402614d70249a25a3f020ae80 c98f99755e3a9f5402614d70249a25a3f020ae80
+c9f9a9881548114e49752d5bc4feba4a83ed4d84 00c9eb4a4c7c79161dd04219dd6ad97f050f8bdf
+ca0a5f5f7d5302270bd3a2a9f8ea4f87c780ea6c ca0a5f5f7d5302270bd3a2a9f8ea4f87c780ea6c
+ca59dcb2717d1f32200e3d8c87e136abe3572c6e ca59dcb2717d1f32200e3d8c87e136abe3572c6e
+caf42a96a73cba834b130094978ca6305bebd66a caf42a96a73cba834b130094978ca6305bebd66a
+cb4dbd79c41d08858264bd91832312ca6058aacc cb4dbd79c41d08858264bd91832312ca6058aacc
+cc0f7b5a9652878609d1aee96b0e09ed56392ab3 cc0f7b5a9652878609d1aee96b0e09ed56392ab3
+cc358cffc4d02946a8e6fb649219443f40cee782 cc358cffc4d02946a8e6fb649219443f40cee782
+cc5e164f65f61183842a3fbe829c2159de6479b1 cc5e164f65f61183842a3fbe829c2159de6479b1
+cd4a1ead6c73a41c6180a238b9193628cd6d0565 cd4a1ead6c73a41c6180a238b9193628cd6d0565
+cd6ee68cd28b31236dc72928689951e7ba01c35d cd6ee68cd28b31236dc72928689951e7ba01c35d
+ce037d0bebe1ffc201d62045f4696d77104be58e ce037d0bebe1ffc201d62045f4696d77104be58e
+ce148759ad7aedfc359b93aafc292698e007bdad ce148759ad7aedfc359b93aafc292698e007bdad
+ce28d3595502ef3d690dd447606b266c67e79460 ce28d3595502ef3d690dd447606b266c67e79460
+ce8d38771ef56fab0262fd08408a74673d75a53a ce8d38771ef56fab0262fd08408a74673d75a53a
+cf116a2d1105118bf633ca901e44da540aef8cfa cf116a2d1105118bf633ca901e44da540aef8cfa
+cf11bdb6adae3b860562affa9ce123f87cf7625a cf11bdb6adae3b860562affa9ce123f87cf7625a
+cf12fe73fc279914fd3f615851decc1b0449a8bb cf12fe73fc279914fd3f615851decc1b0449a8bb
+cf274c918ad3cc3365cba2f0cc9079022623b59f cf274c918ad3cc3365cba2f0cc9079022623b59f
+cfa815ac3a622a171cf5ce1be70c2cb49488c16c cfa815ac3a622a171cf5ce1be70c2cb49488c16c
+d14ab82a8c2ad974304477e29014d3a565e64504 d14ab82a8c2ad974304477e29014d3a565e64504
+d1680d54822354b66dd8a44f2a1ee5ab1f2916b7 d1680d54822354b66dd8a44f2a1ee5ab1f2916b7
+d16d9ac2ec676e46e630e68e5e741bb44ddd5872 d16d9ac2ec676e46e630e68e5e741bb44ddd5872
+d1ae9319e15718bb721fe53cff11c78c6a8066d8 d1ae9319e15718bb721fe53cff11c78c6a8066d8
+d1b5ee1fdc07400e4308db7d346c5a8be0309c7a d67c9fb1c397072beb3ca57e6bfd4ef54dcc266a
+d1ddc2ff64965b874ebd2a50953e5779ea9c24db d1ddc2ff64965b874ebd2a50953e5779ea9c24db
+d20673374f6c25875ccd9e756b129af373ac4d66 d20673374f6c25875ccd9e756b129af373ac4d66
+d24d48fbace674ba7465e0e03db4b1c8a70ba637 d24d48fbace674ba7465e0e03db4b1c8a70ba637
+d25ffa9d542e69ae048ef2a4245c9b11113e3311 d25ffa9d542e69ae048ef2a4245c9b11113e3311
+d2db9c70434527d25ad4f527bea613185d726c88 d2db9c70434527d25ad4f527bea613185d726c88
+d2e796dafa8b8731ec474071f471f9622d0e9794 d2e796dafa8b8731ec474071f471f9622d0e9794
+d3adaaf38b05276a5094107a11e8ff97a39dcb56 d3adaaf38b05276a5094107a11e8ff97a39dcb56
+d3c93c43edd91390820652bf452037cbf5320c49 d3c93c43edd91390820652bf452037cbf5320c49
+d3d83ab1b4c9a07e407ca02107813b0a0548cf58 d3d83ab1b4c9a07e407ca02107813b0a0548cf58
+d3f5874d89cbaf3190132a510df098d12bbd9358 d3f5874d89cbaf3190132a510df098d12bbd9358
+d41137df2fc6b6eb22f7fe3249ff421d51715b52 d41137df2fc6b6eb22f7fe3249ff421d51715b52
+d4309e354f7c624b649cd5fdb0f970c0bed4c595 d4309e354f7c624b649cd5fdb0f970c0bed4c595
+d447db5ac9063ec12afb8f5bc7b3a0b05c75bad4 767b84e2675e7074408a81257a5ec6d0dd410c4d
+d49827901abcd71f2a6ca6afd643b634e765dc3a d49827901abcd71f2a6ca6afd643b634e765dc3a
+d523239c984affb85a8bed4b2c3b19380667ff2d d523239c984affb85a8bed4b2c3b19380667ff2d
+d535b4362f07df83c805f1924ccdf663eb978ae1 1d8a3db587e2ded68dbc5d54722932a8926d788f
+d5b355380ff5b441d33cca8970e223df872f19bf d5b355380ff5b441d33cca8970e223df872f19bf
+d5d298218d067e55708bb08f2110fcffa9004112 d5d298218d067e55708bb08f2110fcffa9004112
+d60e59ef08a6532e60da14fb455efafa4086c78e d60e59ef08a6532e60da14fb455efafa4086c78e
+d76815a42f8b96835dd30dddaf4194458d291ce6 d76815a42f8b96835dd30dddaf4194458d291ce6
+d77f10901a804c9c9cb6ee500af9c79508b90337 d77f10901a804c9c9cb6ee500af9c79508b90337
+d7826d875356172085d9e1ebedbaac255542e118 d7826d875356172085d9e1ebedbaac255542e118
+d7d220b8a08219a795d49420abef2ec39483b94b d7d220b8a08219a795d49420abef2ec39483b94b
+d7e504bca18baeba83d7cbc9a3eef5596c6ec8ad d7e504bca18baeba83d7cbc9a3eef5596c6ec8ad
+d84ddbb80e00af4799ce8c69ddf7a717f3eb956e d84ddbb80e00af4799ce8c69ddf7a717f3eb956e
+d8949ad9808437025fef12bbf4b6869a4d23ae60 d8949ad9808437025fef12bbf4b6869a4d23ae60
+d8a75068ad90ed4467fb49096bd869f8c155c565 d8a75068ad90ed4467fb49096bd869f8c155c565
+d8c27aef561d5ac238dc94603c1c074347903189 d8c27aef561d5ac238dc94603c1c074347903189
+d91eb3065811b5284d8bf4fc7e17673844d9133f d91eb3065811b5284d8bf4fc7e17673844d9133f
+d94ffbe079782faf714cbf472a97ba2297f850d7 d94ffbe079782faf714cbf472a97ba2297f850d7
+d97a6bcc001c4324319333bf2a13fd5d25dd2b76 d97a6bcc001c4324319333bf2a13fd5d25dd2b76
+d9a08c5fe16c00d8cbc7e4f67caefe177231df1b d9a08c5fe16c00d8cbc7e4f67caefe177231df1b
+d9b39b1e8b4104542df738ea4c88df2bffaaf091 d9b39b1e8b4104542df738ea4c88df2bffaaf091
+da0b08d7ae4f9b619cee34489b74d5a8df5cd3c2 da0b08d7ae4f9b619cee34489b74d5a8df5cd3c2
+da677c8d514f3c9901e9d9c3b22f7e36e4e7ea1b da677c8d514f3c9901e9d9c3b22f7e36e4e7ea1b
+da7896e3136807ff1b19410f866da12262eab0db da7896e3136807ff1b19410f866da12262eab0db
+dac0db0d7dc34dd5ecf026d5c2620ca5821e4e50 dac0db0d7dc34dd5ecf026d5c2620ca5821e4e50
+dacd3d9b26ff22111c6b0f338d548d4a5d33176c dacd3d9b26ff22111c6b0f338d548d4a5d33176c
+db045b2f8296f450bf29053ad66e708f00b48ca0 db045b2f8296f450bf29053ad66e708f00b48ca0
+db0493331e08afd4291d97db07013b733788e58f db0493331e08afd4291d97db07013b733788e58f
+db4efe28f8583412f41abf44f78b9657c3788cb6 db4efe28f8583412f41abf44f78b9657c3788cb6
+dbddae6c10645c76aaf9f49b0bf5d360377c3a6b dbddae6c10645c76aaf9f49b0bf5d360377c3a6b
+dc00a625906eafd52bc791fe3ba117225e7851da 9c77b0783669580fb94b4ee3624ea6a25cd0e668
+dc068cab17624721c274e6810391308a920f57af ee6b8aacf61a162a00a849e39d201a57181ee182
+dc0adee1a35d989d5e0a47b2f099a58a9968f799 dc0adee1a35d989d5e0a47b2f099a58a9968f799
+dc9fd025b9b9e764b9eb8c10a464194c7703b26b dc9fd025b9b9e764b9eb8c10a464194c7703b26b
+dca6c6a685d5343a76d07455d013478e2cafd6b2 dca6c6a685d5343a76d07455d013478e2cafd6b2
+dca7a8a55d5911ce22230a25d54aafbf95276580 dca7a8a55d5911ce22230a25d54aafbf95276580
+dce77d5d62ef9517ef704c8c57bc98763a515ca1 dce77d5d62ef9517ef704c8c57bc98763a515ca1
+dd8d46463ff72822965aeea0d11964afbf1de671 dd8d46463ff72822965aeea0d11964afbf1de671
+de00f74b54b53f375e40021e55a86a0f634f2f58 de00f74b54b53f375e40021e55a86a0f634f2f58
+de1f9f1ed44c9f97d01204bb3b1efb3ffaa48ec1 de1f9f1ed44c9f97d01204bb3b1efb3ffaa48ec1
+de26fdfc3a2255c482156296d66aaa608c578f1c de26fdfc3a2255c482156296d66aaa608c578f1c
+de5b7e2c0710498087c80d7814f5966993af16c8 de5b7e2c0710498087c80d7814f5966993af16c8
+df115c851472a3d95f6c1b3d4a7431a78b3ddde2 df115c851472a3d95f6c1b3d4a7431a78b3ddde2
+dffd6b7323da15375e7aa03a34ecca86bc413a73 dffd6b7323da15375e7aa03a34ecca86bc413a73
+e0044b0d42c2746afe1b2f74630ba3406095ed2e e0044b0d42c2746afe1b2f74630ba3406095ed2e
+e0355a369707c31eb701213c0edf2e5bb9ccad38 e0355a369707c31eb701213c0edf2e5bb9ccad38
+e09a01fe0b2cbc82670663bb32c0da950d64c71b e09a01fe0b2cbc82670663bb32c0da950d64c71b
+e0cd00eb2179b4b9ac3bd8a8732ca9984eea3965 e0cd00eb2179b4b9ac3bd8a8732ca9984eea3965
+e17870ff2b9c2ebbc16b62f7664537b686ad356b e17870ff2b9c2ebbc16b62f7664537b686ad356b
+e180b0bc64835ded6a6a408209e6b05a5a81ae2c e180b0bc64835ded6a6a408209e6b05a5a81ae2c
+e1829b134c75a202452326dd0b7a8916ab7f6273 e1829b134c75a202452326dd0b7a8916ab7f6273
+e1c46d0346e4657c0af0d189c585db6d830779e7 e1c46d0346e4657c0af0d189c585db6d830779e7
+e1d9c70d1d292cc7775d353070304c1b15f1a3f0 e1d9c70d1d292cc7775d353070304c1b15f1a3f0
+e1db61a0b2b8556651b1b7a05252c4aa46dda25f e1db61a0b2b8556651b1b7a05252c4aa46dda25f
+e207ad195011e9cb3b6e71306205ce7566912e48 e207ad195011e9cb3b6e71306205ce7566912e48
+e22337097d1a8a8a1c13d50f02562b9d5d116759 e22337097d1a8a8a1c13d50f02562b9d5d116759
+e243e4b018e5377d9a14b41580d406fd8aa18bed e243e4b018e5377d9a14b41580d406fd8aa18bed
+e2d3ca987b83a63aa39f7288c3fb7116c3fb0a00 e2d3ca987b83a63aa39f7288c3fb7116c3fb0a00
+e3742fb4605b5b0d2f12418211b8959d206e0c66 e3742fb4605b5b0d2f12418211b8959d206e0c66
+e3cc82806f09adfc376061f73b2c190c91c0974a e3cc82806f09adfc376061f73b2c190c91c0974a
+e3f513e95ecafceea474e48072d0ec8aaa990ad0 e3f513e95ecafceea474e48072d0ec8aaa990ad0
+e40aa46044a321b7c9dec557716888a98de17dbe e40aa46044a321b7c9dec557716888a98de17dbe
+e4189ad80c58f1a5e2f330c39fb5b5a91b246849 e4189ad80c58f1a5e2f330c39fb5b5a91b246849
+e43a92332a476c30c881e968af1b4c524c2dc9a7 e43a92332a476c30c881e968af1b4c524c2dc9a7
+e46f2bbb8021fa4ac4c88a62efc8ede89c40b3ab e46f2bbb8021fa4ac4c88a62efc8ede89c40b3ab
+e4a17e35879bd226d813b761b487645630c0a589 e4a17e35879bd226d813b761b487645630c0a589
+e5747a5d555547b9b13aa4310dec03dcd4dcb32b e5747a5d555547b9b13aa4310dec03dcd4dcb32b
+e57ad0abc66e3605f490230dd541635599607d98 e57ad0abc66e3605f490230dd541635599607d98
+e58a83cc3a1c7e6ec8225532169adbffecd04ea3 e58a83cc3a1c7e6ec8225532169adbffecd04ea3
+e5c40cb6f74ff2d0c410ba1f252b993e5e31ced9 e5c40cb6f74ff2d0c410ba1f252b993e5e31ced9
+e5d78c1daf5d5103c208859cbcb6cfc9d47d132e e5d78c1daf5d5103c208859cbcb6cfc9d47d132e
+e5e0d76ab5b202e71633e79aa339362d78b46ace e5e0d76ab5b202e71633e79aa339362d78b46ace
+e5eeb08215153ac9afdf5756826c184ad60ac579 e5eeb08215153ac9afdf5756826c184ad60ac579
+e6112d174b1f2bd9e43b70c9ebdf1047062065b7 e6112d174b1f2bd9e43b70c9ebdf1047062065b7
+e6a6dbadaea0b6de4d9ea080885aed3214dbbef4 e6a6dbadaea0b6de4d9ea080885aed3214dbbef4
+e6b9275497bf08dc43ab5faad28ef5189d0ae661 e6b9275497bf08dc43ab5faad28ef5189d0ae661
+e6b956fbab19ec4aa9f50a8f298105cd64510052 e6b956fbab19ec4aa9f50a8f298105cd64510052
+e6c45d99dc59f8933b9331ca738dbcb56e350e70 e6c45d99dc59f8933b9331ca738dbcb56e350e70
+e6f27c608b0305f9a9b276f7ac547f1f03589217 e6f27c608b0305f9a9b276f7ac547f1f03589217
+e713213921f7736aca62638b7f80b66d87a67d44 e713213921f7736aca62638b7f80b66d87a67d44
+e73b5a48196a961608a8d1d69a4bd309143fb8d4 1d0ede0727f53c9e65faf09b3fddf6c1962da88f
+e777ddca34f5ea8040d9905c55b853a7089d6682 068306e7fd7963664942cf6511f46d9afe6b9308
+e77d76d75193d5fbce746f8678bfbe52a665f194 e77d76d75193d5fbce746f8678bfbe52a665f194
+e77e0aa0e50ff43d7b7f0c71e76f0bda07ac4406 e77e0aa0e50ff43d7b7f0c71e76f0bda07ac4406
+e7987982ef56010f0a56309bbddc0f1aaf2d3543 e7987982ef56010f0a56309bbddc0f1aaf2d3543
+e8004e3a7416c57661d43e8bfaefb7c77255129e e8004e3a7416c57661d43e8bfaefb7c77255129e
+e80dd50a8fc0c884dff7b5f8e9c0f5d815898cb9 e80dd50a8fc0c884dff7b5f8e9c0f5d815898cb9
+e861c6fd64a59c79675ec56b9af54fcd38ac6800 e861c6fd64a59c79675ec56b9af54fcd38ac6800
+e87411d37da0d539c45eb1ae3984af12982ebe2a e87411d37da0d539c45eb1ae3984af12982ebe2a
+e8c5eea99a3a1d0da4a994adbfe8c0de2c2cfdbb e8c5eea99a3a1d0da4a994adbfe8c0de2c2cfdbb
+e8d21e44c6ad2ac9c1375c04fc7859fd05011591 e8d21e44c6ad2ac9c1375c04fc7859fd05011591
+e93414c5c2c420b9afe227243d0e23b735e7752b 40a8190729683342948c28e3ea1d13d1c6d4eb7e
+e9371fa1843dd089bf00b8aa7a459b8318a1fe02 e9371fa1843dd089bf00b8aa7a459b8318a1fe02
+e973c911f82834f96625f80f375dbeadd23e6d7f e973c911f82834f96625f80f375dbeadd23e6d7f
+e9914143e23a884f666739a9f616a1fde1115199 e9914143e23a884f666739a9f616a1fde1115199
+ea69a4bc2de5763bf2abf926ce2e3651f78ae8b9 ea69a4bc2de5763bf2abf926ce2e3651f78ae8b9
+eaa22c4e62e96ae6ac792d3ef0074530214018b7 eaa22c4e62e96ae6ac792d3ef0074530214018b7
+eaca7d28ad916a5aeced7fe39b62c1fa244b8761 eaca7d28ad916a5aeced7fe39b62c1fa244b8761
+eafd68f3291fe67d53d9fdd4a575f5d501a76b1c eafd68f3291fe67d53d9fdd4a575f5d501a76b1c
+ec3a63caca4f2d955e594318a9a690e4c28af19e 4c1996c8f60ee1d3975f344456ad1e11c758103d
+ec46d9d0c19af6177a28067015ae2390dcac4cfa ec46d9d0c19af6177a28067015ae2390dcac4cfa
+ec4981aa94a67533c3ef4f98aced27b35bc7c1e8 ec4981aa94a67533c3ef4f98aced27b35bc7c1e8
+ec5e7ecc305a8995780ea11e23920ea6db5dc7ea ec5e7ecc305a8995780ea11e23920ea6db5dc7ea
+ed906c37f61649d61065d53450fc097865c13593 ed906c37f61649d61065d53450fc097865c13593
+ed9a819fabf3dd4892c4f295b45dd367d56035f8 ed9a819fabf3dd4892c4f295b45dd367d56035f8
+eda528ae73e96ff659cd815cb869f2f51245f210 eda528ae73e96ff659cd815cb869f2f51245f210
+edb55bb7c431c34b0af22b98aa70995cac29eadc edb55bb7c431c34b0af22b98aa70995cac29eadc
+ede8a4da2ea1bc2803b17c7dd0f2cb5fc8e07f85 ede8a4da2ea1bc2803b17c7dd0f2cb5fc8e07f85
+ee0826a1e475dba34aa84e548c24bca1023eed6d ee0826a1e475dba34aa84e548c24bca1023eed6d
+ee148902a22d5c4b40e29d7f3695a95c77841774 ee148902a22d5c4b40e29d7f3695a95c77841774
+ee2be7f847ae5e44f82990f341c3e54ec393d8c8 ee2be7f847ae5e44f82990f341c3e54ec393d8c8
+eec95de4d681464e691e17269e6593e119c32b29 eec95de4d681464e691e17269e6593e119c32b29
+eed74dc56d1a79a844be57b1aab7fee302040060 eed74dc56d1a79a844be57b1aab7fee302040060
+eef5e35edd715147fcd98306a9b573a870f0a88a eef5e35edd715147fcd98306a9b573a870f0a88a
+ef00914a4dc5ef1347ca0bf9850157e659d0e654 ef00914a4dc5ef1347ca0bf9850157e659d0e654
+ef1c2fe2b9a7eecd47fad24095976371dcae04bd ef1c2fe2b9a7eecd47fad24095976371dcae04bd
+ef4a3ffc11907d61acae7541e8967e5a8e9bd36f ef4a3ffc11907d61acae7541e8967e5a8e9bd36f
+efa15c0d4292873d6a4ee06f00a0a2e0ae54aabd efa15c0d4292873d6a4ee06f00a0a2e0ae54aabd
+f0062ca92337a5797386600c4081ca9d8a877f5c edb2ec3ec9a95a07c5cec94790755a3747ff7337
+f0196a65c8abef3556d71196100223a0c2647261 f0196a65c8abef3556d71196100223a0c2647261
+f01e96d000a195a59a7f25cb133caa72a486007e f01e96d000a195a59a7f25cb133caa72a486007e
+f02805ba3e389add74321648d57224e19ab9f9cb f02805ba3e389add74321648d57224e19ab9f9cb
+f05936e4df5116a13e6867d35224e060161f1707 f05936e4df5116a13e6867d35224e060161f1707
+f0906259cf04b454a405b58f45e7f33aeef7217c f0906259cf04b454a405b58f45e7f33aeef7217c
+f0a16da3d4a0d925a3d5eee1d452965c2d67fd33 f0a16da3d4a0d925a3d5eee1d452965c2d67fd33
+f0ce9ef3dd4c728bb80afc58fc7e5d28734b61ea f0ce9ef3dd4c728bb80afc58fc7e5d28734b61ea
+f136829223cbf6af7189ad004093c81cf05c92a4 f136829223cbf6af7189ad004093c81cf05c92a4
+f1b3e35a62a92e00ebbfb1e6e8fdd9b8cbebf077 f1b3e35a62a92e00ebbfb1e6e8fdd9b8cbebf077
+f1be552c05a7d91f38c9efeba11ffe48b4e4fe86 f1be552c05a7d91f38c9efeba11ffe48b4e4fe86
+f234a1786c74eb9260052fcaa581fbcf91a48967 f234a1786c74eb9260052fcaa581fbcf91a48967
+f2880f9b2a8e0c6bbfb7e41050726d3ecba50a23 f2880f9b2a8e0c6bbfb7e41050726d3ecba50a23
+f2a47add15a75a9f8bde3c6e3bd800cd281aa8bb f2a47add15a75a9f8bde3c6e3bd800cd281aa8bb
+f3094ce9550e84a7f671ce93008f5de76f737a6c f3094ce9550e84a7f671ce93008f5de76f737a6c
+f3d4e26aa16d29bea2cca97f6b50f304642ca6ba f3d4e26aa16d29bea2cca97f6b50f304642ca6ba
+f3e4aaeba0a1f9d02b86aff3c39b5935e4b4ea0b f3e4aaeba0a1f9d02b86aff3c39b5935e4b4ea0b
+f4242d239d2dc165ce8aae7a57dd8b94ed7a3713 f4242d239d2dc165ce8aae7a57dd8b94ed7a3713
+f454b09e11f47385d090e4417f477b651b97851f f454b09e11f47385d090e4417f477b651b97851f
+f4879c0c0b7f72fd9084058f1683bc4a36ba8897 f4879c0c0b7f72fd9084058f1683bc4a36ba8897
+f530b93e1fcafc1a82f54bbf3fe839faf9f832a6 f530b93e1fcafc1a82f54bbf3fe839faf9f832a6
+f5592d3b9750d7f9726d45cd196b93e3cf4646a9 f5592d3b9750d7f9726d45cd196b93e3cf4646a9
+f55eb07e76ef04c6ebc3e04d5c9cefbdc51d823b f55eb07e76ef04c6ebc3e04d5c9cefbdc51d823b
+f5c571712834c87615a040b2901fc3a21e4a98aa f5c571712834c87615a040b2901fc3a21e4a98aa
+f60fd6e434846449923a9407285713d599e9c1a6 f60fd6e434846449923a9407285713d599e9c1a6
+f62eee368a4e2acc4a630601436dfa306ed34ecc f62eee368a4e2acc4a630601436dfa306ed34ecc
+f6a9dfead319b0bb07de3d4a63e528c7bda11bc6 f6a9dfead319b0bb07de3d4a63e528c7bda11bc6
+f6cb628aad6e3e5f08e5533f7b98eaa063640243 f6cb628aad6e3e5f08e5533f7b98eaa063640243
+f6e2e6342fc30bb67f178e6611bfb8eede294cdd f6e2e6342fc30bb67f178e6611bfb8eede294cdd
+f6ea3a1bd9767324fea36ddc89ef18a0a780c43c f6ea3a1bd9767324fea36ddc89ef18a0a780c43c
+f70d81eaaba5279a2ab5ac78fc50d7140863616a f70d81eaaba5279a2ab5ac78fc50d7140863616a
+f7346290a6d7be33a4b65db46f2560c15d04a9d2 f7346290a6d7be33a4b65db46f2560c15d04a9d2
+f7ae8fd2464c131fb6d9b86f12a229931126d28f f7ae8fd2464c131fb6d9b86f12a229931126d28f
+f7cafa3556505fb4fe4971574f684f6780ee2d4c f7cafa3556505fb4fe4971574f684f6780ee2d4c
+f7cb17170f96e080db5ba2bd0c8cf5acbed90852 f7cb17170f96e080db5ba2bd0c8cf5acbed90852
+f81cb7242e326142e4b45718a1740c08248e0492 f81cb7242e326142e4b45718a1740c08248e0492
+f876a717af9ae644320f014e30866bb919fcfe4e c9e9edf1ac39fc9b3f9f08a1832d53a00fd684d4
+f88dfa7620a289f1aff63e446ebdd31853ac3d51 f88dfa7620a289f1aff63e446ebdd31853ac3d51
+f8c2858f010ca9f539f32a3ae21f5ab9394877c3 f8c2858f010ca9f539f32a3ae21f5ab9394877c3
+f8e08a8ee1336057ea9ee90a4d0e3f93d7c82300 f8e08a8ee1336057ea9ee90a4d0e3f93d7c82300
+f8f9900df698033a432f0cd1fe8c76b9c701ad1c f8f9900df698033a432f0cd1fe8c76b9c701ad1c
+f911511dfa66f538fbad7ef66844ad7c8611cddd f911511dfa66f538fbad7ef66844ad7c8611cddd
+f92a835cb9626b5c46da9a348430d9a0a8d52494 f92a835cb9626b5c46da9a348430d9a0a8d52494
+f92cacf54a22c64c7cba22e1ae7443b99d58ee36 f92cacf54a22c64c7cba22e1ae7443b99d58ee36
+f983a89c25bbe76400cf46bda165812b1d70d3bf f983a89c25bbe76400cf46bda165812b1d70d3bf
+f986381d32a3d76bb1c270998eefcad8d274c461 f986381d32a3d76bb1c270998eefcad8d274c461
+f99eb7ad3a9efd43ba144ac92369022a1f7f373a f99eb7ad3a9efd43ba144ac92369022a1f7f373a
+f9daf677e5a0ffb17f10614fccf197f760f58a5e f9daf677e5a0ffb17f10614fccf197f760f58a5e
+fa020437dc6210740d27f04c4ac1697883cda8be fa020437dc6210740d27f04c4ac1697883cda8be
+faac951ab0f36d16b992c5d597595dc6f9017b76 faac951ab0f36d16b992c5d597595dc6f9017b76
+fb13378559cdc6a9ef3b23883bcbda2937dc1195 fb13378559cdc6a9ef3b23883bcbda2937dc1195
+fb645c59a72cb80e491913a69891901d049a293a fb645c59a72cb80e491913a69891901d049a293a
+fb6fa3b175c37a53af16cb0969ed4cc1e47b87a1 fb6fa3b175c37a53af16cb0969ed4cc1e47b87a1
+fb7a19361dfc24caa8a577d2f15a845a01faed4d fb7a19361dfc24caa8a577d2f15a845a01faed4d
+fc3a8508561114177a67bbdb6802bdcf1df7683e fc3a8508561114177a67bbdb6802bdcf1df7683e
+fc9291e883a49da0e8e97221223576fa640fe42c fc9291e883a49da0e8e97221223576fa640fe42c
+fca837883b0b79793ea81d223d6e08a8ee2b2248 fca837883b0b79793ea81d223d6e08a8ee2b2248
+fcafdfa66e2e24abde2ccd71800b9ea22919adf3 fcafdfa66e2e24abde2ccd71800b9ea22919adf3
+fceae96d8c7fb1a588f4bd2eff5b9da3fd53b9da fceae96d8c7fb1a588f4bd2eff5b9da3fd53b9da
+fd2601393fa8eca01a4354a7ec1d0bc793f7c9c6 fd2601393fa8eca01a4354a7ec1d0bc793f7c9c6
+fd2c4e5827805950160d2b17a69d3ad8f456adab fd2c4e5827805950160d2b17a69d3ad8f456adab
+fd363392bcd3dc2ab48e238310e57082fbdc1233 fd363392bcd3dc2ab48e238310e57082fbdc1233
+fd442c79ea7620a78edd19e4c1230258259a228e fd442c79ea7620a78edd19e4c1230258259a228e
+fdb069a8f30ea363866d5c98f6fe4e6546bace19 fdb069a8f30ea363866d5c98f6fe4e6546bace19
+fdccc1cc6e8170780525ccb04c36fd3c89d7928d fdccc1cc6e8170780525ccb04c36fd3c89d7928d
+fe4383bfc0e76f7f1948716aa8b2bc542d712634 fe4383bfc0e76f7f1948716aa8b2bc542d712634
+fef7264836191de042db461084e813f83e570051 fef7264836191de042db461084e813f83e570051
+ff4ac32ecc1f569972039d5eb7e4c8a3d3190951 ff4ac32ecc1f569972039d5eb7e4c8a3d3190951
+ff5f1d6fefd6e22bf6b80e60c527093b31647b89 ff5f1d6fefd6e22bf6b80e60c527093b31647b89
+ffa2a7b46e8fea13c3cd4ad4f11b60d7ca99e1d8 ffa2a7b46e8fea13c3cd4ad4f11b60d7ca99e1d8
diff --git a/configs/wireplumber/50-disable-ucm.conf b/configs/wireplumber/50-disable-ucm.conf
new file mode 100644
index 0000000..99cf8cb
--- /dev/null
+++ b/configs/wireplumber/50-disable-ucm.conf
@@ -0,0 +1,22 @@
+# Framework Laptop 13 (AMD Ryzen AI 300 Series): the ACP PDM digital-mic driver
+# returns saturated full-scale garbage, a known and still-open kernel bug on
+# Strix Point and Krackan Point. Framework community threads:
+# https://community.frame.work/t/81866 (Krackan Point, SOF/ACP timeline)
+# https://community.frame.work/t/75748 (Ryzen AI 340, Fedora 42)
+# With UCM on, PipeWire exposes only that broken DMIC as the internal mic. Turning
+# UCM off makes the ALC285 codec fall back to its analog-stereo profile, whose
+# Internal Microphone pin works. Remove this file and restart wireplumber to revert.
+monitor.alsa.properties = {
+ alsa.use-ucm = false
+}
+
+# The standalone ACP PDM card (the broken digital mic) still appears without UCM;
+# disable it so no app or default-selection can pick it. The ~ prefix makes the
+# match a regex over the card name rather than one PCI path (pci-0000_c1_00.5 on
+# velox), so the same board with a renumbered bus is still caught.
+monitor.alsa.rules = [
+ {
+ matches = [ { device.name = "~alsa_card.pci-.*-platform-acp-pdm-mach" } ]
+ actions = { update-props = { device.disabled = true } }
+ }
+]
diff --git a/docs/2026-08-13-velox-reinstall-runbook.org b/docs/2026-08-13-velox-reinstall-runbook.org
new file mode 100644
index 0000000..2d99fc9
--- /dev/null
+++ b/docs/2026-08-13-velox-reinstall-runbook.org
@@ -0,0 +1,184 @@
+#+TITLE: Velox Reinstall Runbook — DR Test of archangel + archsetup
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-08-13
+
+Context: velox's mainboard swapped Intel → AMD (Ryzen AI 9 HX 370, Radeon
+890M, 96GB RAM). Old SSD intact but the new board's NVRAM has no boot entry,
+and velox is ZFS root + ZFSBootMenu, so a stock Arch USB can't even read the
+pool. Decision: full reinstall via archangel + archsetup, run deliberately as
+a disaster-recovery test of the ISO and scripts before the Sunday flight.
+Recent backup in hand; ratio available as the working machine.
+
+* Outcome (recorded 2026-09-13 Sun)
+
+The drill ran on 2026-08-13 and 14 and velox came back as a working daily
+driver: fresh install from the archangel ISO, keys and data restored from
+the salvage backup, 23 repos re-cloned, rsyncshot reinstalled, hibernate
+proven end to end. The checklist below was the live plan; it was not ticked
+as the phases ran, so read it as the plan, not a log of each step.
+
+What the drill found, each filed as its own task rather than fixed in place:
+velox's truenas backups had silently stopped on 2026-07-06 (found on 08-13
+before partitioning, which is what made the salvage pass required); four phantom
+reboots were a ribbon disturbed by the board swap; a fresh install never
+clones rulesets, never links the .emacs.d systemd user units, ships no
+brightness udev rule, and loses gcalcli and the signal-cli registration.
+Those live in archsetup's todo as the post-rebuild verification pass and its
+siblings. The one commit that existed only on the old disk (emacs-wttrin
+bf0457f) was rescued as a bundle and has its own task.
+
+Companion documents: the UEFI boot-entry recovery reference
+([[file:2026-08-15-velox-uefi-boot-entry-reference.org][2026-08-15-velox-uefi-boot-entry-reference.org]])
+and the three gap reports under docs/design (2026-08-14-velox-reinstall-gaps-1
+to 3).
+
+Fallback ordering if the test finds a real gap:
+- Before partitioning starts: the old system is intact — the ZBM repair
+ route (efibootmgr entry pointing at the ZBM loader on the ESP, then
+ amd-ucode swap in a chroot) is still available.
+- After partitioning: the floor is a manual Arch install; the backup makes
+ that survivable.
+
+* Phase 0 — Preflight on ratio (agent-driven, done before you leave the desk)
+
+- [ ] Rebuild the ISO with archsetup baked in: the 2026-08-02 ISO predates
+ the microcode vendor-detection fix (archsetup, 2026-08-08) and was built
+ without ARCHSETUP_DIR at all.
+ #+begin_src sh
+ cd ~/code/archangel && sudo ARCHSETUP_DIR="$HOME/code/archsetup" ./build.sh
+ #+end_src
+ Two traps in that one line, and either alone silently produces a bare ISO
+ with archsetup absent (archangel, 2026-08-20). The =VAR=value= form is
+ required because sudo's =env_reset= discards an exported variable. And
+ =$HOME= is required because zsh does not expand a tilde on the right-hand
+ side of an assignment — the earlier =ARCHSETUP_DIR=~/code/archsetup= here
+ passed the literal string. build.sh now warns and reports baked/not-baked
+ in its closing summary, so the failure is visible rather than silent.
+- [ ] build.sh fixes before the final rebuild (archangel repo):
+ - rsync exclude for =.ai= (keeps =archsetup/.ai/private-design/= — the
+ credential audit — off the portable USB stick).
+ - copy =installer/velox-*.conf= to =airootfs/root/= so the machine profile
+ is on the ISO at =/root/velox-zfs.conf=.
+- [ ] Verify the ISO carries: =/code/archsetup= (with =install_cpu_microcode=),
+ =/root/velox-zfs.conf=, no =.ai/private-design=. Loop-mount or unsquashfs
+ spot-check.
+- [X] USB ready (done 2026-08-13 15:25): the new ISO was copied to the Ventoy
+ drive, sha256-verified against the source, and the 2026-04-09 + 2026-06-16
+ archangel ISOs removed. Boot the stick and pick
+ =archangel-2026-08-13-vmlinuz-6.18.43-lts-x86_64.iso= from the Ventoy menu.
+
+* Phase 1 — UEFI setup on velox (BIOS screen, before any boot)
+
+- [ ] Disable Secure Boot. Mandatory — the ZFS kernel modules are unsigned;
+ the new board ships with it enforced by factory default.
+- [ ] Set the system clock. The board swap reset the RTC to 2025-01-01;
+ a wrong clock breaks TLS and pacman signature checks in the live env.
+ Rough accuracy is fine — NTP tightens it once networked.
+- [ ] While you're in setup: check boot-order UI shows the USB.
+
+* Phase 2 — Salvage pass (live ISO, BEFORE running the installer) — REQUIRED
+
+NOT optional insurance. Verified 2026-08-13: velox's newest truenas backup is
+DAILY.0 = 2026-07-06 — five weeks stale. The backup timer on velox broke
+around Jul 6 (truenas itself only went dark Jul 24, and it's back now; ratio
+and mybitch backed up today). Everything since Jul 6 exists only on the old
+SSD — including =wolf.conf.gpg= (created Jul 29), which is therefore in NO
+backup at all. This pass also keeps the repair fallback alive until
+partitioning starts.
+
+- [ ] Network up (=nmtui= or ethernet), then confirm clock: =timedatectl=.
+- [ ] Import the old pool read-only and unlock:
+ #+begin_src sh
+ zpool import -N -o readonly=on -R /mnt zroot
+ zfs load-key zroot # passphrase prompt
+ zfs mount zroot/ROOT/default
+ zfs mount -a 2>/dev/null # home datasets etc.; ignore failures
+ #+end_src
+- [ ] Push a full fresh backup to truenas over the LAN — mirror the layout
+ the backup job uses (etc + home), into a clearly-named one-off dir:
+ #+begin_src sh
+ rsync -aHAX --info=progress2 /mnt/etc /mnt/home \
+ truenas:/mnt/vault/backups/velox/pre-reinstall-2026-08-13/
+ #+end_src
+ (=/usr= is in the regular backups but is all reinstallable — skip unless
+ paranoid. The 96GB-RAM board will not be the bottleneck; the LAN is.)
+- [ ] Spot-check the copy landed: =wolf.conf.gpg=, =.ssh=, =.gnupg=, newest
+ files in =~/documents= and =~/downloads=.
+- [ ] Check for uncommitted repo work and either push or note it:
+ =~/.emacs.d= (known: the auto-dim-other-buffers.el unresolved merge),
+ =~/.dotfiles=, anything under =~/code=.
+- [ ] Export cleanly: =cd /; zfs unmount -a; zpool export zroot=.
+
+* Phase 3 — Install (the actual DR test)
+
+- [ ] Review the profile, then run the installer:
+ #+begin_src sh
+ less /root/velox-zfs.conf # FILESYSTEM=zfs, HOSTNAME=velox, single nvme
+ archangel --config-file /root/velox-zfs.conf
+ #+end_src
+ Note: the profile's ZFS_PASSPHRASE / ROOT_PASSWORD are the =welcome=
+ placeholders — fine for install; both change post-install (=zfs change-key
+ zroot= for the pool, =passwd= for root).
+- [ ] Record every rough edge as a DR-test finding — that's the point of
+ running it this way. Anything that needs a manual nudge gets a todo entry
+ in archangel or archsetup afterward.
+- [ ] Reboot into ZBM → boot the new environment.
+
+* Phase 4 — archsetup (first boot of the installed system)
+
+- [ ] Log in as root, network up, then verify the clock synced.
+- [ ] Get archsetup — two paths, test the offline one since this is a DR
+ drill (the online curl path is the everyday alternative):
+ #+begin_src sh
+ # offline: mount the install USB and copy the baked tree
+ mount /dev/disk/by-label/ARCHANGEL* /mnt 2>/dev/null || mount /dev/sdX1 /mnt
+ cp -r /mnt/code/archsetup /root/archsetup && cd /root/archsetup
+ ./archsetup
+ #+end_src
+- [ ] Expected on the new board: =install_cpu_microcode= detects
+ AuthenticAMD and installs amd-ucode (verified 2026-08-13, 7/7 tests).
+ Podman socket, camera udev rule, tlp radio state, ZFS /tmp mask are all
+ in the installer now — none need manual application afterward.
+- [ ] archsetup clones + stows dotfiles. The velox host tier has no Intel
+ assumptions (swept 2026-08-13); maint's capability probe runtime-detects
+ amd-pstate.
+
+* Phase 5 — Post-install restore + verification
+
+- [ ] Restore from backup (credentials, ssh keys, gpg, user data). The
+ secrets-bundle-in-ISO design is not built yet — manual restore is the
+ known gap, not a test failure.
+- [ ] WireGuard: decrypt + re-place =wolf.conf.gpg= at =~/.config/wireguard/=;
+ re-import the NM profile (autoconnect off, as before).
+- [ ] Change the placeholder passwords: =passwd=, =zfs change-key zroot=.
+- [ ] PSR workaround — REQUIRED on this board. The Ryzen AI 300 has a known
+ idle instability (Panel Self Refresh hangs/reboots the machine; hit during
+ the live session 2026-08-13). Add =amdgpu.dcdebugmask=0x610= to the
+ installed system's kernel command line — velox boots via ZBM, so set it on
+ the pool: =zfs set org.zfsbootmenu:commandline="... amdgpu.dcdebugmask=0x610" zroot/ROOT/default=
+ (keep the existing args; append). Revisit after a BIOS update ≥3.05 or a
+ kernel that fixes PSR on Strix Point — track via the Framework issue
+ tracker (SoftwareFirmwareIssueTracker #110).
+- [ ] New-hardware spot-checks:
+ - =journalctl -k | grep -i microcode= — amd-ucode applied.
+ - =cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_driver= — expect
+ amd-pstate(-epp).
+ - wifi + bluetooth up (new board radios), touchpad behavior, camera.
+ - =glxinfo -B= / =vulkaninfo --summary= — Radeon 890M on RADV.
+- [ ] Fresh clones automatically carry the post-purge rewritten git history —
+ closes the clone-reconcile rider from 2026-08-11 without action.
+- [ ] Fix and verify the backup timer on the fresh install — it was silently
+ broken since ~Jul 6. After the first manual run succeeds, confirm a new
+ DAILY.0 appears under =truenas:/mnt/vault/backups/velox/=. Diagnose why it
+ broke (timer unit dead? mount failure? credential?) if the old journal
+ survives in the salvage copy.
+- [ ] Update the machine-identity memory: velox is now AMD (amd-pstate),
+ both daily drivers AMD. Fix the stale =intel_pstate= comment in
+ =airplane-mode= line 6 while at it (cosmetic).
+- [ ] File every DR-test finding in the owning project's todo.
+
+* Timing
+
+Today is Thursday; the flight is Sunday. Target: Phases 0–4 tonight or
+Friday, leaving Saturday as pure buffer. If the install stalls past Friday
+evening, cut losses to the manual-install floor.
diff --git a/docs/2026-08-15-velox-uefi-boot-entry-reference.org b/docs/2026-08-15-velox-uefi-boot-entry-reference.org
new file mode 100644
index 0000000..1eacfe5
--- /dev/null
+++ b/docs/2026-08-15-velox-uefi-boot-entry-reference.org
@@ -0,0 +1,108 @@
+#+TITLE: Velox UEFI Boot Entry — Recovery Reference
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-08-15
+
+Captured 2026-08-15 before a BIOS update (03.05 → 04.02) as insurance against
+the update clearing NVRAM. Velox's mainboard swap on 2026-08-13 left exactly
+this kind of empty NVRAM, which is what forced the reinstall — so a cleared
+boot entry is the specific failure worth being able to undo in one command
+rather than reconstruct.
+
+* Before recreating anything: check Secure Boot first
+
+The 04.02 update landed on 2026-09-12 (staged via fwupd, flashed on the next
+reboot). It did NOT clear NVRAM: Boot0001 survived with its command line
+intact. What it did was re-enable Secure Boot (Enforce Secure Boot =
+Enabled), so the unsigned ZBM loader was rejected and the Framework BIOS
+reported it as "Default Boot Device Missing / no bootable drive" rather than
+a security violation. The symptom is indistinguishable from the NVRAM wipe
+this document was written for.
+
+The tell: booting the Ventoy stick shows shim's MOK management screen.
+
+Fix: F2 → Security → Secure Boot → Enforce Secure Boot = Disabled, F10. The
+machine then boots straight into ZBM. No efibootmgr needed.
+
+So when velox says no bootable device after a firmware update, check Secure
+Boot before touching the boot entries. Only if Secure Boot is already off
+and =efibootmgr -v= (from the stick) shows Boot0001 gone does the recreate
+below apply.
+
+Before any future firmware update, record both =efibootmgr -v= and the
+Secure Boot state so the post-reboot diagnosis is a comparison, not a guess.
+The pre-firmware-update checklist in
+[[file:workflows/system-health-check.org][docs/workflows/system-health-check.org]]
+(Phase 3) carries the steps.
+
+Still open: velox's ESP has no removable-media fallback (=/efi/EFI/BOOT=
+does not exist), so a real NVRAM wipe would still need the stick. Copying
+=zfsbootmenu.efi= to =/efi/EFI/BOOT/BOOTX64.EFI= would let it boot unaided.
+That belongs to archangel's ZBM install, where it is filed as [#C]
+"Installed systems have no removable-media boot fallback on the ESP"
+(2026-09-12) and ships with the next ISO rebuild after it lands.
+
+* State at capture
+
+- BIOS: 03.05 (2025-10-30)
+- BootCurrent: 0001
+- BootOrder: 2001,0001,2002,2003 (USB ahead of ZBM — why the Ventoy stick
+ boots when it's inserted)
+- Timeout: 0 seconds
+
+* The entry that matters
+
+=Boot0001* ZFSBootMenu=
+
+| field | value |
+|----------------+----------------------------------------------|
+| ESP part GUID | 8e51b680-f90a-444f-8da5-7e4f93625775 |
+|----------------+----------------------------------------------|
+| partition | 1 (GPT), start 0x800, size 0x100000 |
+|----------------+----------------------------------------------|
+| loader path | =\EFI\ZBM\zfsbootmenu.efi= |
+|----------------+----------------------------------------------|
+| cmdline (data) | =spl_hostid=0x22f8a7a1 zbm.timeout=3= |
+| | =zbm.prefer=zroot zbm.import_policy=hostid= |
+|----------------+----------------------------------------------|
+
+The =data= field is that command line in UTF-16LE, which is how efibootmgr
+passes it as optional data. Recreate with =-u= and the plain string; efibootmgr
+does the encoding.
+
+* Recreating it
+
+From a booted system (or the archangel ISO), with the ESP identified as
+=/dev/nvme0n1p1= or whatever it enumerates as:
+
+#+begin_src bash
+efibootmgr --create \
+ --disk /dev/nvme0n1 --part 1 \
+ --label "ZFSBootMenu" \
+ --loader '\EFI\ZBM\zfsbootmenu.efi' \
+ --unicode 'spl_hostid=0x22f8a7a1 zbm.timeout=3 zbm.prefer=zroot zbm.import_policy=hostid'
+#+end_src
+
+Confirm the disk/part against =lsblk -o NAME,PARTUUID,PARTTYPENAME= first —
+the partition GUID above is the authoritative identifier, not the device name,
+which can enumerate differently.
+
+Then set the order so ZBM is reachable:
+
+#+begin_src bash
+efibootmgr --bootorder 0001,2001,2002,2003
+#+end_src
+
+(The original order put USB first. Keep whichever you prefer; what matters is
+that the ZBM entry exists and is in the list.)
+
+* Other entries (firmware-generated, recreate themselves)
+
+| Boot2001 | EFI USB Device |
+|----------+----------------|
+| Boot2002 | EFI DVD/CDROM |
+|----------+----------------|
+| Boot2003 | EFI Network |
+|----------+----------------|
+
+These are stock firmware entries and come back on their own. Only Boot0001
+carries anything unique.
diff --git a/docs/design/2026-07-10-audio-failure-taxonomy.org b/docs/design/2026-07-10-audio-failure-taxonomy.org
index efb6e0a..bea0f65 100644
--- a/docs/design/2026-07-10-audio-failure-taxonomy.org
+++ b/docs/design/2026-07-10-audio-failure-taxonomy.org
@@ -29,6 +29,7 @@ Built 2026-07-10 from a parallel research sweep across eight layers (kernel/driv
- AMD ACP6x DMIC needs a DMI quirk, not firmware (both). Ryzen laptop internal array mic absent or silent; sof-firmware changes nothing. The acp6x mic array needs the laptop's DMI entry in the =SND_SOC_AMD_YC_MACH= quirk table. Fix: newer kernel with the board's DMI quirk, or add the entry and rebuild. [[https://bbs.archlinux.org/viewtopic.php?id=311093][arch 311093]]
- Missing NHLT ACPI table in firmware (both). No DMIC; dmesg shows "NHLT table not found". The platform firmware omits the NHLT table, so SOF can't discover the digital-mic count. Fix: flash firmware that supplies NHLT, or override the dmic count via SOF modprobe params. [[https://github.com/MrChromebox/firmware/issues/920][mrchromebox 920]]
- Brand-new AMD ACP7.0 (Strix Halo) has no mic driver/UCM yet (both). Internal DMIC non-functional on very recent AMD hardware. The kernel ACP7.0 SOF driver and UCM profile weren't upstreamed yet. Fix: wait for the kernel + alsa-ucm-conf that add ACP7.0 support. [[https://github.com/alsa-project/alsa-ucm-conf/issues/745][ucm-conf 745]]
+- AMD ACP PDM digital mic emits a full-scale rail, Framework 13 on Ryzen AI 300 (yes). Internal mic dead in every app; a raw capture of =hw:acppdmmach= reads rms 0.99999 and peak 1.0 on every sample, which is a rail, not silence and not the privacy switch. A still-open kernel bug in the ACP PDM path on Strix Point and Krackan Point (no AMD SOF firmware exists for the platform); with UCM on, alsa-ucm-conf links the broken =acp-pdm-mach= card into the ALC285 codec card, so that DMIC is the only internal mic PipeWire offers. Fix: =alsa.use-ucm = false= plus a =monitor.alsa.rules= entry disabling the =acp-pdm-mach= card, shipped by archsetup as =/etc/wireplumber/wireplumber.conf.d/50-disable-ucm.conf= on this board; the ALC285 falls back to analog-stereo, whose Internal Microphone pin carries real audio (drop the source volume, the analog path stacks +30 dB capture on +30 dB boost). Re-test the DMIC after kernel updates; once upstream fixes it, remove the file and UCM can go back on. Found on velox 2026-10-06. [[https://community.frame.work/t/81866][framework 81866]], [[https://community.frame.work/t/75748][framework 75748]]
** ALSA (profiles, UCM, mixer, pins, state)
@@ -180,6 +181,7 @@ Probe: kernel-vs-graph (the mic-unrecognized rule) plus WirePlumber liveness. Th
- ACP vs UCM detection mismatch :: AUTO — set =use-acp=/=use-ucm= plus restart wireplumber.
- Missing UCM capture profile :: AUTO — fall back to ACP; GUIDE the UCM patch/install.
+- AMD ACP PDM rail (Framework 13 on Ryzen AI 300) :: PRIV — install the =50-disable-ucm.conf= drop-in (UCM off plus the =acp-pdm-mach= card disabled) under =/etc= and restart wireplumber; archsetup ships it on this board. The two use-ucm remedies above and the drop-in removals in this cluster must treat that installed file as the fix, not as an offending config: flipping =use-ucm= back on, or deleting the file, puts the railing DMIC back as the only internal mic. The doctor should recognise the rail itself (a raw =hw:acppdmmach= capture at rms 0.99999) before it touches UCM on this hardware.
- WirePlumber fails to start on old config :: AUTO — remove the user drop-in plus restart (PRIV if the file is under =/etc=).
- pipewire-pulse dead or wrong socket :: AUTO — restart the user services; check XDG_RUNTIME_DIR.
- Broken ~/.config/wireplumber override :: AUTO — remove/repair the drop-in plus restart.
diff --git a/docs/design/2026-07-10-net-bt-failure-taxonomy.org b/docs/design/2026-07-10-net-bt-failure-taxonomy.org
index 74790c6..4d57b86 100644
--- a/docs/design/2026-07-10-net-bt-failure-taxonomy.org
+++ b/docs/design/2026-07-10-net-bt-failure-taxonomy.org
@@ -96,6 +96,7 @@ Six layers, mirroring the net doctor's probe ladder (link → IP/DHCP → gatewa
- Another daemon overwrites resolv.conf (yes). DNS works then breaks (or breaks after VPN up/down) as dhcpcd/openvpn/openresolv rewrites resolv.conf. Multiple tools claim it with no coordination. Fix: pick one manager (openresolv =resolvconf=NO=, dhcpcd =nohook resolv.conf=), point resolv.conf at the stub, restart resolved. [[https://github.com/adrienverge/openfortivpn/issues/674][openfortivpn 674]]
- nsswitch.conf hosts line broken (yes). All resolution fails, or LAN/mDNS names never resolve; the hosts line lacks =resolve=/=dns= in the right order or references an uninstalled nss module. Fix: set =hosts: mymachines resolve [!UNAVAIL=return] files myhostname dns=. [[https://man.archlinux.org/man/nss-resolve.8.en][nss-resolve]]
- Avahi/.local mDNS not resolving (yes). *.local names don't resolve though unicast DNS works. nss-mdns not wired in, or resolved's built-in mDNS collides with avahi. Fix: install nss-mdns, add =mdns_minimal [NOTFOUND=return]= before =resolve=, enable avahi-daemon, disable resolved MulticastDNS if both run. [[https://wiki.archlinux.org/title/Avahi][archwiki avahi]]
+- Clock skew breaks DNS itself, and NTP cannot recover it (yes; field-observed 2026-08-19, velox, not from the 2026-07-10 sweep). Nothing resolves at all — not a slow lookup, a dead one — after a boot with a wrong clock. =DNSSEC=yes= validates RRSIG inception/expiry windows against the wall clock, so a clock weeks off fails every query before it leaves the machine. Measured on velox 2026-08-19 with the clock wound back 27 days: resolved logged =signature-expired= against the root DNSKEY and every DS beneath it, and resolution died outright. =DNSOverTLS=yes= is *not* what bites, despite being the obvious suspect — the DoT handshake to =1.1.1.1:853= verified clean at that same clock, because a resolver certificate is good for about a year while an RRSIG window is days to weeks. A skew large enough to break DNSSEC normally leaves the certificate valid. The trap is the recovery path: NTP daemons name their servers by hostname (=pool 2.arch.pool.ntp.org=, =NTP=time.cloudflare.com=), so the daemon that would fix the clock needs the DNS that the clock is breaking. Neither side moves and the machine cannot self-heal — diagnosis needs a second device. Distinguish from the plain clock-skew entry in the egress layer by where it bites: that one has working DNS and failing HTTPS, this one has no DNS at all. Confirm with =dig @1.1.1.1 example.com +short=, which goes out plain UDP/53 and bypasses resolved entirely; an answer there with resolved still failing puts the fault in the validation layer, not the network. Fix: set the clock by hand (=timedatectl set-time=), then =resolvectl flush-caches=. =DNSSEC=allow-downgrade= does *not* help here, which is worth knowing because it is the obvious reach: resolved downgrades when a server lacks DNSSEC support, and a signature-window failure is a validation failure rather than a support failure, so no downgrade fires. Measured on velox: six retries over eighteen seconds, plus =resolvectl reset-server-features=, all dead. The only cure is correcting the clock, which is why the NTP source has to be reachable without DNS. Prevent by giving the NTP daemon at least one source addressed by IP, which needs neither DNS nor a certificate — =server 162.159.200.1 iburst= in a chrony drop-in. Note =timedatectl set-ntp true= is *not* a fix here: it starts a daemon that still cannot resolve its pool.
** Egress / captive portal / MTU / proxy / clock / upstream
@@ -107,7 +108,7 @@ Six layers, mirroring the net doctor's probe ladder (link → IP/DHCP → gatewa
- PPPoE / VPN link with a lower MTU not clamped (no). Browsing works but big transfers / some HTTPS hang. A PPPoE (1492) or VPN path has a smaller MTU and the too-large segments get dropped. Fix: set the tunnel/link MTU down (=.mtu 1420= for VPN, 1492 for PPPoE) or MSS-clamp on the gateway. [[https://thelineman.ca/articles/article-8-mtu-vpn-mss][vpn mtu/mss]]
- Stale http_proxy env var points at a dead proxy (no). Every curl/wget/pacman fails though the network is fine; browsers may work. A leftover =http_proxy= points at an offline/off-network proxy. Fix: unset the vars, remove the export from =~/.profile= / =/etc/environment=. [[https://everything.curl.dev/usingcurl/proxies/env.html][curl proxy env]]
- Unreachable PAC file off the corporate network hangs everything (no). Away from the office the browser stalls with no error. A system proxy set to "automatic" with a PAC URL that only resolves on the corporate LAN blocks waiting instead of falling back to DIRECT. Fix: switch system proxy to None (=gsettings … org.gnome.system.proxy mode 'none'=) or clear the PAC URL. [[https://bugzilla.mozilla.org/show_bug.cgi?id=1121800][ff pac hang]]
-- Clock skew breaks every TLS handshake (yes). "Your connection is not private" on every HTTPS site though ping/DNS work; the clock is hours/years off. A dual-boot Windows RTC-localtime, unsynced NTP, or a dead CMOS battery leaves the clock wrong. Fix: =timedatectl set-ntp true= (=set-local-rtc 0= on dual-boot), replace the CMOS battery if it recurs. [[https://wiki.archlinux.org/title/System_time][archwiki system time]]
+- Clock skew breaks every TLS handshake (yes). "Your connection is not private" on every HTTPS site though ping/DNS work; the clock is hours/years off. A dual-boot Windows RTC-localtime, unsynced NTP, or a dead CMOS battery leaves the clock wrong. Fix: =timedatectl set-ntp true= (=set-local-rtc 0= on dual-boot), replace the CMOS battery if it recurs. This entry assumes DNS still works; when the resolver runs DoT or DNSSEC the same skew kills DNS first and =set-ntp true= cannot recover it — see the clock/DNS deadlock in the DNS layer. [[https://wiki.archlinux.org/title/System_time][archwiki system time]]
- Firewall default-deny drops all egress (yes). No traffic leaves right after enabling a firewall, or after both ufw and firewalld are on; even DNS fails. A default outgoing-deny policy, or two firewalls fighting over nftables. Fix: allow egress (=ufw default allow outgoing=) and run only one firewall. [[https://wiki.archlinux.org/title/Uncomplicated_Firewall][archwiki ufw]]
- VPN kill-switch / leftover iptables rule strangles egress after VPN drops (yes; distinct from the route-capture case). Internet dies the moment the VPN disconnects and never returns until reboot. A kill-switch rule pinned traffic to tun0 and the leftover rule keeps dropping everything on the real interface. Fix: flush the stale rules (=iptables -F; iptables -P OUTPUT ACCEPT=, or restart the firewall), reconnect. [[https://bbs.archlinux.org/viewtopic.php?id=300104][arch ufw killswitch]]
- IPv6 egress broken while IPv4 works (no; the egress angle of the broken-v6 family). Pages load slowly/intermittently; IPv4-only hosts are fine. The network advertises IPv6 with no working route and Happy Eyeballs keeps trying the dead AAAA path. Fix: =nmcli con modify <con> ipv6.method disabled= until the network's IPv6 is fixed. [[https://help.ubuntu.com/community/WebBrowsingSlowIPv6IPv4][ubuntu slow ipv6]]
@@ -309,6 +310,7 @@ Probe: dns-config + resolver-health + dns-resolve + the doctor's dns-test (which
- VPN split-DNS not applied :: AUTO — =resolvectl domain/default-route= on the VPN link.
- IPv6 AAAA lookups stall :: AUTO — disable IPv6 on the link (or the single-request option). Also cluster 8.
- Another daemon overwrites resolv.conf :: PRIV — pick one manager, point resolv.conf at the stub.
+- Clock skew breaks DNSSEC validation, NTP deadlocked behind it :: PRIV — set the clock by hand, flush caches; prevent with an IP-addressed NTP source. The doctor must reach this verdict *before* any resolved restart, which cannot help and reads as a loop.
- nsswitch.conf hosts line / avahi mDNS broken :: PRIV — fix the hosts line, install nss-mdns.
** Cluster 6 — names resolve, egress blocked
diff --git a/docs/design/2026-07-15-velox-boot-failure-handoff.org b/docs/design/2026-07-15-velox-boot-failure-handoff.org
new file mode 100644
index 0000000..5ec996f
--- /dev/null
+++ b/docs/design/2026-07-15-velox-boot-failure-handoff.org
@@ -0,0 +1,61 @@
+#+TITLE: Velox boot failure — ZBM found no bootable kernel; diagnosis in progress, recovery plan attached
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-15
+
+* Why this is coming to archsetup
+
+Velox fails to boot: ZFSBootMenu reports it can't find a bootable environment with a kernel. Craig reports the last working velox session was an archsetup health-check run that included the pacman upgrade — so the breakage most likely happened inside archsetup's own workflow, and Craig wants the diagnosis + retrospective to continue here with full context. The .emacs.d session (where this was triaged, only because that's where Craig was sitting) hands off everything below.
+
+A phone photo of the zfs list output from velox's ZBM recovery shell accompanies this note in the inbox.
+
+* Timeline
+
+- 2026-07-13 ~23:50 CDT — velox last seen on the tailnet (per tailscale status read 2026-07-14 ~17:50).
+- During that last session: archsetup health-check workflow ran, including a pacman upgrade (Craig's recollection — pacman.log will confirm exact times).
+- 2026-07-14 late evening — Craig boots velox; ZBM: no bootable environment with a kernel.
+- 2026-07-14/15 — triage from the ZBM recovery shell, Craig driving, guided from the .emacs.d session.
+
+* Facts established so far (from the ZBM recovery shell)
+
+- zroot imported, health ONLINE. Every dataset's keystatus is "available" — encryption unlocked, not a key problem.
+- Layout confirmed from zfs list: zroot/ROOT/default (mountpoint /), separate datasets for home, home/root, media, var, var/cache, var/lib, var/lib/docker plus many docker layer children (legacy mountpoints). NOTE: no separate zroot/var/log dataset — /var/log lives inside zroot/var. That differs from the sanoid dataset list in archsetup's configure_zfs_snapshots (which configures zroot/var/log and zroot/var/lib/pacman as their own datasets) — worth reconciling in the retrospective.
+- Mounted the BE read-only style: mkdir -p /mnt/be && mount -t zfs -o zfsutil zroot/ROOT/default /mnt/be.
+- THE FINDING: /mnt/be/boot contains ONLY intel-ucode.img. vmlinuz-linux, initramfs-linux.img, and initramfs-linux-fallback.img are all gone.
+
+* Working hypothesis
+
+A kernel upgrade during the health-check run removed the old kernel files and never completed installing the new ones (interrupted transaction, mkinitcpio failure, or a /boot shadowing issue), and the machine was powered off with /boot empty. Arch's upgrade removes the running kernel's files at package-replace time, so a failure between "remove old" and "install new + mkinitcpio" leaves exactly this state: microcode present, kernel and initramfs absent.
+
+* Remaining diagnosis steps (not yet run — velox is sitting at the ZBM shell)
+
+1. Read pacman's log (on the zroot/var dataset):
+ #+begin_src sh
+ mkdir -p /mnt/var
+ mount -t zfs -o zfsutil zroot/var /mnt/var
+ tail -60 /mnt/var/log/pacman.log
+ #+end_src
+ Expect the failed/interrupted kernel transaction near the end; note its timestamp.
+2. List recovery candidates:
+ #+begin_src sh
+ zfs list -t snapshot zroot/ROOT/default | tail -20
+ #+end_src
+ Sanoid is configured for hourly=6/daily=7 on the ROOT dataset, so a pre-damage snapshot should exist. Check whether any pre-pacman_* snapshots appear — that tells us whether the 2026-06-29 pre-pacman hook design is actually installed on velox.
+
+* Recovery plan (agreed with Craig, pending the log read)
+
+1. Pick the newest zroot/ROOT/default snapshot that predates the failed transaction.
+2. If the pool is imported read-only (zpool get readonly zroot): zpool export zroot && zpool import -f -N zroot.
+3. zfs rollback -r zroot/ROOT/default@<snapshot> (the -r discards snapshots newer than the target; home/var/media are separate datasets and untouched).
+4. zpool export zroot, reboot — ZBM should now see the kernel.
+5. After first boot: re-run pacman -Syu attended, and confirm /boot holds vmlinuz-linux + initramfs-linux.img before any shutdown.
+
+* Retrospective candidates for archsetup
+
+- Does the health-check / upgrade flow verify /boot contents (kernel + initramfs present, mkinitcpio exit status) after a kernel upgrade? This failure would have been caught by a one-line post-upgrade assertion.
+- Is the pre-pacman snapshot hook (2026-06-29 design, zroot/ROOT/default@pre-pacman_<ts>) installed on velox? The snapshot listing in step 2 above answers this empirically.
+- The sanoid config vs actual dataset layout mismatch (var/log, var/lib/pacman) noted above.
+- Whether the upgrade step should refuse to end the session (or page Craig) when a kernel transaction errors.
+
+* Related loose end already in your inbox
+
+A separate note (2026-07-14-1751) asks to add inetutils to the install base; velox also still needs that package installed once it boots again.
diff --git a/docs/design/2026-08-07-podman-socket-and-camera-udev.md b/docs/design/2026-08-07-podman-socket-and-camera-udev.md
new file mode 100644
index 0000000..f9477a2
--- /dev/null
+++ b/docs/design/2026-08-07-podman-socket-and-camera-udev.md
@@ -0,0 +1,85 @@
+# Enable podman.socket at install time (and one related USB rule)
+
+From winvm, 2026-08-07 on ratio.
+
+## 1. The ask: enable the rootless podman socket
+
+ systemctl --user enable --now podman.socket
+
+**What was wrong.** On ratio this was `disabled` / `inactive`, and
+`$XDG_RUNTIME_DIR/podman/podman.sock` did not exist at all. Podman's CLI does
+not need it, so nothing surfaces the gap until something speaks the REST API.
+
+**Why it matters.** Every podman GUI and API client connects over that socket
+rather than by shelling out. I installed Pods (`com.github.marhkb.Pods`,
+flathub) to manage the Windows VM container, and without the socket it opens to
+an empty window with no useful error — it looks like the app is broken or has no
+containers, when in fact there is nothing listening. That is a bad first-run
+experience and a hard one to diagnose from the symptom.
+
+It is socket-activated, so enabling it costs nothing while idle: podman starts
+on demand when a client connects, and `enable` brings the socket back on login
+without a daemon sitting resident. This is the ordinary way to run the podman
+API rootless.
+
+**Verified after enabling:** socket present at
+`/run/user/1000/podman/podman.sock`, the API answers
+`/v5.0.0/libpod/containers/json`, and it reported the `WinVM` container.
+
+**One Flatpak note worth recording**, because it looks like a problem and is
+not. The Pods flatpak ships `xdg-run/podman:ro` in its filesystem permissions,
+which reads as though a client could not connect. Tested from inside the
+sandbox: the socket is visible, and both readable *and writable*, so the
+read-only directory mount does not block the connection. No `flatpak override`
+is needed.
+
+**Client-side, not your problem but context:** Pods stores connections in
+`~/.var/app/com.github.marhkb.Pods/config/pods/` and only writes that file after
+the user adds a connection through the UI, so it can't be pre-seeded from an
+install script without inventing the format. The one-time step is adding
+`unix:///run/user/1000/podman/podman.sock` as a connection; a
+`last-used-connection` gsetting makes it stick after that. So the *socket* is
+the part worth automating, and the connection is not.
+
+## 2. Related, same category — a udev rule for USB camera passthrough
+
+Filing this alongside because it is the same class of one-time machine-level
+setup and it will bite on velox the same way. Take it or leave it separately
+from item 1.
+
+`/etc/udev/rules.d/99-usb-passthrough-cameras.rules`:
+
+ SUBSYSTEM=="usb", ATTR{idVendor}=="3564", ATTR{idProduct}=="ff02", GROUP="video", MODE="0660", TAG+="uaccess"
+ SUBSYSTEM=="usb", ATTR{idVendor}=="046d", ATTR{idProduct}=="085e", GROUP="video", MODE="0660", TAG+="uaccess"
+
+(3564:ff02 is the OBSBOT, 046d:085e the Logitech BRIO.)
+
+**Why.** Streaming a USB camera into the VM runs `usbredirect`, which must open
+the device read-write to claim it and detach the kernel drivers. The node
+defaults to `crw-rw-r-- root:root`, so a normal user has read but no write and
+the attach fails with a bare "Failed to open device!".
+
+**The part worth writing down:** `TAG+="uaccess"` alone is NOT enough, though it
+looks like the right answer and `udevadm test` confirms the tag is applied.
+logind does not turn that tag into an ACL on a raw `/dev/bus/usb/*` node — it
+manages ACLs for a narrower set of seat devices. The `GROUP`/`MODE` grant is
+what actually works. The tag is kept because it is harmless and correct in
+principle; it just isn't sufficient. I lost time on that, so it is recorded
+rather than left to be rediscovered.
+
+Both items are live on ratio and neither exists on velox.
+
+## Correction (winvm, 2026-08-08)
+
+The udev mechanism claim in section 2 above is wrong and stands corrected by
+the sender: `TAG+="uaccess"` does apply to raw `SUBSYSTEM=="usb"` devices
+(`70-uaccess.rules` tags them in several places). The real failure was rule
+ordering — the ACL is applied by `73-seat-late.rules:16`, and a rule file
+numbered `99-` adds the tag after that test has run. Every distro rule adding
+the tag sorts at or below 70.
+
+The `GROUP="video", MODE="0660"` grant remains the verified working fix.
+Untested hypothesis from the sender: a rule file numbered below 73 would
+likely make uaccess work on its own (a tighter grant than the group). The
+installer task ships the rule numbered below 73 with both mechanisms and
+leaves the uaccess-alone test for when the camera is attached.
diff --git a/docs/design/2026-08-14-velox-reinstall-gaps-1.org b/docs/design/2026-08-14-velox-reinstall-gaps-1.org
new file mode 100644
index 0000000..0f8b3d9
--- /dev/null
+++ b/docs/design/2026-08-14-velox-reinstall-gaps-1.org
@@ -0,0 +1,137 @@
+#+TITLE: What the velox reinstall left behind — four gaps the install could close
+#+AUTHOR: Craig Jennings
+
+* Heads-up: this was found from a .emacs.d session
+
+I opened a .emacs.d session on velox this morning, two days after the fresh
+Arch install, and the first thing it did was fail: there was no =.ai/=
+directory to read. Chasing that turned up four separate things the reinstall
+did not restore. Three I repaired from the session; one needs me at my phone.
+
+None of this is a .emacs.d bug. They are all install-side gaps, which is why
+they are landing in your inbox. Machine is velox; ratio was the reference for
+every comparison below.
+
+* Gap 1 — the gitignored tooling layer does not survive a reinstall
+
+=~/.emacs.d= was re-cloned on 2026-08-13. Git brought back every tracked file
+and none of the agent tooling, because =.gitignore= deliberately excludes it:
+=.ai/=, =.claude/=, =CLAUDE.md=, =todo.org=, and =inbox/= were all simply
+absent. That is the correct ignore policy — this repo relays to a public
+mirror — but it means a reinstall silently drops the entire working state of
+every gitignore-mode project.
+
+The damage on velox was total rather than partial: 374 files, 4.5 MB,
+including =todo.org= (556 KB) and 184 archived session files. Nothing carries
+it. Not git, not stow, not the bootstrap.
+
+I recovered it by rsyncing the set from ratio over the tailnet. Ratio was
+authoritative and velox held nothing, so there was no merge to adjudicate —
+which is luck, not design. Had velox held a few days of divergent state, this
+would have been a hand reconciliation. It has been one before: 2026-07-31, when
+the two machines' =.ai/= trees had forked to zero files in common.
+
+Worth knowing: this is fleet-general. Every project on the box that gitignores
+its =.ai/= has the same hole, not just =.emacs.d=.
+
+What the install could do: after cloning a project, check whether a sibling
+daily driver holds a =.ai/= for it, and offer to pull it across. Or at minimum,
+list the projects whose tooling layer is missing so the gap is visible on day
+one instead of at the first session that trips over it.
+
+* Gap 2 — stowed user timers come back linked but not enabled
+
+The unit files all arrived correctly through the dotfiles stow, symlinked into
+=~/.config/systemd/user/= and resolving fine. But being present is not being
+enabled, and the reinstall enabled only some of them:
+
+| unit | velox after reinstall | ratio |
+|---------------------------+-----------------------+----------|
+| calendar-sync.timer | enabled, active | enabled |
+| agenda-render-cache.timer | enabled, active | enabled |
+| roam-sync.timer | *linked, inactive* | enabled |
+| signal-receive.timer | *linked, inactive* | enabled |
+| emacs.service | linked, inactive | linked |
+
+=emacs.service= reads the same on both machines, so I take that one as
+intentional and left it alone. The other two are real drift: =systemctl --user
+enable= writes a =timers.target.wants= symlink into =~/.config/systemd/user/=,
+and that symlink is not stow-managed, so nothing in the dotfiles repo carries
+it. A stowed unit file is inert until something enables it.
+
+I enabled both with =systemctl --user enable --now=. Both fired immediately and
+exited clean, and both now show a next elapse.
+
+What the install could do: enable the units it stows, explicitly, as a named
+step. The inconsistency is the tell — two of four came back enabled, which
+suggests something enables a subset and nothing enumerates the rest.
+
+* Gap 3 — the roam clone was stale, and held a diff that would have destroyed data
+
+This one has an ordering constraint, so it matters more than its size suggests.
+
+velox's =~/org/roam= was ten commits behind ratio, stuck at the 2026-08-04
+auto-sync while ratio was at 2026-08-14 — a direct consequence of gap 2, since
+=roam-sync.timer= was never enabled here.
+
+The dangerous part: velox's clone also carried an *uncommitted* =inbox.org=
+that had been emptied. Seventeen deletions, file down to zero bytes, holding a
+pre-2026-08-04 state whose captures were long since processed on ratio.
+
+So the naive repair — enable =roam-sync.timer= and let it catch up — would have
+committed that emptying and pushed it, deleting the four live inbox items on
+ratio. The timer is the repo's only committer and it commits whatever it finds.
+
+I checked ratio's =inbox.org= first and confirmed it was a strict superset of
+velox's HEAD version (same three items plus an 2026-08-09 capture), which made
+the local change provably worthless. Then discarded it, fast-forwarded to
+=a411b43=, and only then enabled the timer. Clone is clean and current, first
+sync ran green.
+
+What the install could do: if it ever enables =roam-sync= on a rebuilt machine,
+reconcile the clone *before* enabling, not after. An auto-committing timer
+pointed at a stale dirty clone is a data-loss path, and the failure is silent
+and remote — it lands on the *other* machine.
+
+* Gap 4 — signal-cli lost its registration, and that breaks the whole fleet
+
+=signal-receive.service= ran for the first time and reported:
+
+: signal-receive: <the agent's Signal number> not registered on this machine — nothing to do
+
+velox's signal-cli data dir holds a 39-byte empty =accounts.json=. Ratio still
+has both numbers. So the reinstall wiped the registration, and per the design
+notes velox was supposed to be the *primary* — ratio is the linked device.
+
+The effect is wider than velox, because of how =agent-text= dispatches: if the
+local signal-cli holds the account it sends directly, otherwise it ssh-relays to
+a hardcoded velox. Velox no longer holds it, so a send from here relays to
+itself and fails; a send from any third machine relays to velox and fails the
+same way. Only ratio still works, and only via the direct branch. The error text
+blames "velox down or unreachable", which is misleading — velox is up and on the
+tailnet, it just is not registered.
+
+This is the one I could not repair from the session: re-linking needs me at my
+phone (Signal → Settings → Linked Devices, scanning the QR from =signal-cli
+link -n velox=). Filed in .emacs.d's todo.org as [#B].
+
+What the install could do: verify =signal-cli listAccounts= is non-empty after a
+rebuild and say so loudly if it is not. Silent loss of the phone channel is
+exactly the kind of thing nobody notices until the page that mattered never
+arrives.
+
+* Summary of what I changed on velox
+
+- Restored =.ai/=, =.claude/=, =CLAUDE.md=, =todo.org=, =inbox/= to
+ =~/.emacs.d= by rsync from ratio.
+- Discarded the stale local =inbox.org= diff in =~/org/roam= and fast-forwarded
+ the clone to current.
+- Enabled and started =roam-sync.timer= and =signal-receive.timer=.
+
+Left alone, deliberately: =emacs.service= (matches ratio), and velox's Signal
+registration (needs the phone).
+
+One unrelated thing I noticed while comparing the machines: ratio's signal-cli
+warns its messages were last received twelve days ago, even though its
+=signal-receive.timer= is enabled and active. That may be nothing, but the
+receive cadence there is worth a look.
diff --git a/docs/design/2026-08-14-velox-reinstall-gaps-2.org b/docs/design/2026-08-14-velox-reinstall-gaps-2.org
new file mode 100644
index 0000000..95842ac
--- /dev/null
+++ b/docs/design/2026-08-14-velox-reinstall-gaps-2.org
@@ -0,0 +1,63 @@
+#+TITLE: Fifth reinstall gap — machine-local .local.el config, and a general shape
+#+AUTHOR: Craig Jennings
+
+* Follow-up to this morning's handoff
+
+Sent you four gaps an hour ago
+([[file:2026-08-14-velox-reinstall-gaps-1.org][the first report]]). Here is a fifth,
+found straight afterwards when I noticed calendar sync was dead on velox.
+
+* What was broken
+
+=calendar-sync.timer= was enabled and firing every fifteen minutes, and failing
+every time with exit 255:
+
+: calendar-sync: No calendars configured (set calendar-sync-calendars)
+
+The three output files sat at zero bytes. The cause is that
+=~/.emacs.d/calendar-sync.local.el= is gitignored, so the reinstall deleted it
+along with everything else untracked, and the module's loader treats a missing
+file as a *silent* no-op. So the config vanished quietly and the only symptom
+was a failing unit nobody was watching.
+
+Cheap to fix once found: the repo tracks =calendar-sync.local.el.example=, and
+that template already encodes the shape velox uses — feeds resolved by
+=:secret-host= against =authinfo.gpg= rather than inlined. The authinfo entries
+had survived, because =~/.authinfo.gpg= is a stow symlink into the dotfiles repo.
+So rebuilding was one copy, and all three feeds now sync clean and land
+byte-identical to ratio's.
+
+* The general shape, which is the part worth acting on
+
+This is the same failure as gap 1, one layer down, and it is worth stating
+generally because the install can act on it:
+
+- A tracked =*.local.el.example= template plus a gitignored =*.local.el= is a
+ deliberate pattern in this config, not a one-off. =.gitignore= lines 56-58
+ list three of them: =calendar-sync.local.el=, =signal-config.local.el=,
+ =google-keep.local.el=. Every one of those is gone on velox right now. I have
+ only repaired the calendar one.
+- Secrets held *by reference* survive a rebuild; secrets held *inline* do not.
+ The calendar config came back for free because the tokens were in
+ =authinfo.gpg=, which is stow-managed and therefore travels. Ratio's copy of
+ the same file inlines its URLs, and had ratio been the machine rebuilt, those
+ three feed tokens would simply have been gone.
+- The failure was silent by design. A missing local config is a no-op, which is
+ right for a machine that never configured the feature and wrong for one that
+ just lost it.
+
+* What the install could do
+
+- After a rebuild, enumerate every tracked =*.local.el.example= in a project and
+ report which have no corresponding =*.local.el=. That is a one-line find and it
+ turns a silent no-op into a visible checklist item.
+- Same for any =*.local.*= convention elsewhere in the fleet — the pattern is not
+ specific to Emacs.
+- Worth pairing with gap 2: a unit that is enabled and failing every fifteen
+ minutes for two days is its own signal. A post-rebuild pass over
+ =systemctl --user list-units --state=failed= would have caught this one
+ without knowing anything about calendars.
+
+That last one generalizes best. Of the five gaps I have sent you, three were
+things that *looked* fine — a stowed unit file, an enabled timer, a present
+clone — and were not.
diff --git a/docs/design/2026-08-14-velox-reinstall-gaps-3.org b/docs/design/2026-08-14-velox-reinstall-gaps-3.org
new file mode 100644
index 0000000..9675973
--- /dev/null
+++ b/docs/design/2026-08-14-velox-reinstall-gaps-3.org
@@ -0,0 +1,98 @@
+#+TITLE: Reinstall gaps, part three — per-install certs and credentials, and one failure that hid the others
+#+AUTHOR: Craig Jennings
+
+* Third handoff today
+
+Two earlier notes covered five gaps
+([[file:2026-08-14-velox-reinstall-gaps-1.org][the first report]] and
+[[file:2026-08-14-velox-reinstall-gaps-2.org][the follow-up]]).
+Email was the last thing broken on velox after the 2026-08-13 rebuild, and it
+turned up two more — both the same shape, and one of them with a property worth
+generalizing.
+
+Email is fully working now: three accounts, 21,853 messages, 4.0 GB indexed.
+
+* Gap 6 — the Proton Bridge TLS cert is per-install, and its absence disabled every account
+
+=~/.mbsyncrc= carries =CertificateFile /home/cjennings/.config/protonbridge.pem=.
+That file did not exist after the rebuild, and it cannot be restored from backup
+or copied from the other machine: Proton Bridge generates a fresh self-signed
+cert per installation. Velox's is issued 2026-08-13 23:44 with a different
+fingerprint from ratio's 2026-01-30 one.
+
+*The part worth acting on is the blast radius.* mbsync parses its entire config
+before doing any work, so a missing =CertificateFile= referenced by *one* account
+aborts the run for *all* of them. Gmail and dmail need no bridge and no cert, and
+both were dead anyway. The error names only the missing pem, so the symptom
+("no mail at all") and the message ("this one file is missing") look unrelated.
+
+Recovery does not need the bridge GUI. The running bridge presents the cert on
+its own IMAP port, so it can be pulled straight off the handshake:
+
+: openssl s_client -connect 127.0.0.1:1143 -starttls imap -showcerts </dev/null \
+: | sed -n '/BEGIN CERTIFICATE/,/END CERTIFICATE/p' > ~/.config/protonbridge.pem
+
+That is a two-second, fully scriptable step, which makes it a good candidate for
+the install rather than a runbook line.
+
+* Gap 7 — the bridge password is per-install too, and reports a stale value misleadingly
+
+=~/.mbsyncrc= resolves the cmail password with =cat ~/.config/.cmailpass=. That
+file is plaintext and, unusually for my setup, a real file rather than a stow
+symlink — so it is not in the dotfiles repo, not encrypted, and not carried to a
+new machine.
+
+The file survived the rebuild but held the *previous* install's password, because
+the bridge regenerates it per installation. Ratio's and velox's differ by sha256,
+confirmed today.
+
+*The diagnostic trap:* Proton Bridge answers a wrong password with =no such
+user=. I read that as "the bridge has no account signed in" and went looking for
+a login problem. The account was configured the whole time. If the install ever
+validates bridge connectivity, it should not treat =no such user= as evidence
+about account state.
+
+* The generalization
+
+Gaps 6 and 7 are the same as 1 through 5, sharpened. Everything that broke in
+this rebuild was *generated on the machine by an application* rather than carried
+by git, stow, or the dotfiles repo:
+
+| gap | artifact | why it did not travel |
+| 1 | =.ai/=, =todo.org=, =CLAUDE.md= | gitignored |
+| 2 | =timers.target.wants= symlinks | written by systemctl enable |
+| 3 | roam clone state | local working tree |
+| 4 | signal-cli registration | per-device identity |
+| 5 | =*.local.el= configs | gitignored |
+| 6 | bridge TLS cert | per-install, regenerated |
+| 7 | bridge password | per-install, regenerated |
+
+Gaps 6 and 7 add a distinction the earlier note missed. For 1, 3 and 5 the old
+value is still correct, so *restoring* fixes them. For 4, 6 and 7 the old value is
+*worthless* — the application has generated a new one, and only *re-deriving*
+from the live system fixes them. An install that tries to restore these will
+produce exactly what happened here: a file that exists, looks right, and
+authenticates against nothing.
+
+So the install's post-rebuild checklist wants two columns, not one: what to
+restore, and what to re-derive.
+
+* What the install could do
+
+- Re-derive the bridge cert from the running bridge with the =openssl s_client=
+ line above. Scriptable, no GUI, no secrets.
+- Re-derive the bridge password from the bridge rather than expecting the file to
+ be right, and rewrite =.cmailpass=. (I have filed a task on my side to make
+ =PassCmd= ask the bridge directly, which would remove the file entirely.)
+- Add a cheap post-rebuild validation that =mbsync --list= parses. Config-parse
+ failures disable every account at once and say nothing about mail, so they are
+ worth catching explicitly rather than via "no new mail" hours later.
+- More generally: keep the restore list and the re-derive list separate, per the
+ table above.
+
+* Unrelated, but noticed while comparing the machines
+
+=~/.config/.gmailpass.gpg= and =~/.config/.dmailpass.gpg= resolve to mode 777 in
+the dotfiles repo, on both machines. They are gpg-encrypted so the contents are
+safe, but world-writable is wrong for a credential file. That is a dotfiles fix,
+not an archsetup one — noting it here only because it surfaced in the same pass.
diff --git a/docs/post-install-checklist.org b/docs/post-install-checklist.org
new file mode 100644
index 0000000..8c48938
--- /dev/null
+++ b/docs/post-install-checklist.org
@@ -0,0 +1,108 @@
+#+TITLE: Post-Install Checklist
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-08-08
+
+* About
+
+Manual steps a fresh archsetup install cannot automate — things that are
+inherently interactive (pairing, account logins) or that need judgment on the
+specific machine. Work through this after first boot. When a new manual step
+is discovered during a machine setup, add it here rather than leaving it in a
+session note; the installer's outro already points at the Proton Bridge steps,
+and this file is the home for the rest.
+
+Steps that CAN be automated don't belong here — file them as installer tasks
+instead. The 2026-04-10 velox setup notes were triaged exactly that way:
+microcode, radio state, and the ZFS /tmp mask went into the installer;
+bluetooth pairing landed below.
+
+* Checklist
+
+** Run the post-rebuild check first
+
+Before working through the manual steps below, run:
+
+#+begin_src sh
+~/code/archsetup/scripts/post-rebuild-check
+#+end_src
+
+It runs the five checks a rebuilt machine actually needs — failed units,
+user units that are present but never enabled, =*.example= configs whose
+real sibling is missing, gitignore-mode projects missing the working state
+their own =.gitignore= names, and the signal-cli registration. Each prints
+a line whether or not it finds anything; exit 1 means something needs
+attention.
+
+These are the gaps velox hit within two days of its 2026-08-13 reinstall,
+and three of the five looked fine on casual inspection: a stowed unit file,
+an enabled-looking timer, a present git clone. Run it again a day or two
+after the install, once timers have had a chance to fail.
+
+It normally finishes in a second or two. On a machine whose user systemd is
+wedged it takes a couple of minutes instead, because every =systemctl= call
+is bounded at five seconds and check 2 makes one per unit. That is the slow
+case working as intended: it reports what it could not read rather than
+hanging. Set =PRC_SYSTEMCTL_TIMEOUT= lower to cut the wait.
+
+** Pair bluetooth peripherals
+
+Pairing is inherently interactive (scan, pick the device, confirm), so it
+can't ride the installer. For a mouse (e.g. the Logi M650):
+
+#+begin_src sh
+bluetoothctl
+# inside the prompt:
+# scan on — wait for the device to appear
+# pair <MAC>
+# trust <MAC> — trust makes it auto-reconnect at boot
+# connect <MAC>
+# scan off
+#+end_src
+
+Trusted devices reconnect on their own after reboot; if one doesn't, check
+=rfkill list= first (radios should be unblocked — TLP owns radio state and
+enables bluetooth/wifi at startup per =/etc/tlp.d/01-custom.conf=).
+
+** Brightness control on a machine installed before 2026-08-13
+
+Only for a machine whose install predates =configure_backlight_access=. New
+installs get the rule automatically, and a re-run of =archsetup= skips the
+step because =essential_services= is already marked complete, so an older
+machine needs one of these by hand:
+
+#+begin_src bash
+# either drop the rule in directly...
+sudo tee /etc/udev/rules.d/90-backlight.rules > /dev/null << 'EOF'
+ACTION=="add", SUBSYSTEM=="backlight", RUN+="/usr/bin/chgrp video /sys/class/backlight/%k/brightness"
+ACTION=="add", SUBSYSTEM=="backlight", RUN+="/usr/bin/chmod g+w /sys/class/backlight/%k/brightness"
+ACTION=="add", SUBSYSTEM=="leds", KERNEL=="*kbd_backlight", RUN+="/usr/bin/chgrp video /sys/class/leds/%k/brightness"
+ACTION=="add", SUBSYSTEM=="leds", KERNEL=="*kbd_backlight", RUN+="/usr/bin/chmod g+w /sys/class/leds/%k/brightness"
+EOF
+sudo udevadm control --reload
+sudo udevadm trigger --subsystem-match=backlight --action=add
+sudo udevadm trigger --subsystem-match=leds --action=add
+
+# ...or clear the marker and re-run just that step
+sudo rm /var/lib/archsetup/state/essential_services
+#+end_src
+
+Verify: =ls -l /sys/class/backlight/*/brightness= shows group =video= with
+=g+w=, and =brightnessctl -c backlight set 70%= succeeds without sudo.
+
+Only laptops have these devices; on a desktop the rule is inert and nothing
+needs doing.
+
+** Proton Mail Bridge (cmail)
+
+The installer's completion message carries the steps; recorded here too so
+the checklist is complete:
+
+1. Run =protonmail-bridge --cli=, log in, then quit.
+2. Run =~/code/archsetup/scripts/cmail-setup-finish.sh=.
+3. First mail sync: =mbsync cmail && mu index=.
+
+Sending mail also needs =cmail-action= on PATH, which rulesets owns: clone it
+to =~/code/rulesets= and run =make install=. That is not a prerequisite for the
+steps above — the setup script warns and carries on — but =mbsync= is the first
+thing that wants it. An agent session runs =make install= at startup, so on a
+machine that runs them the link appears on its own.
diff --git a/docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org b/docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org
new file mode 100644
index 0000000..ae456a5
--- /dev/null
+++ b/docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org
@@ -0,0 +1,5632 @@
+#+TITLE: Guarded-Upgrade Completion — keeping topgrade freshness honest
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-08-25
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* DOING Guarded-upgrade completion
+:PROPERTIES:
+:ID: 81cdfd72-db96-43d3-aa03-779878c99f3e
+:END:
+- [2026-10-05 Mon @ 13:00 -0500] READY → DOING. Decomposed into build tasks under the archsetup todo.org parent "Topgrade guarded-upgrade build" (SPEC_ID 81cdfd72-db96-43d3-aa03-779878c99f3e), with the manual tests filed under Manual testing and validation.
+- [2026-10-05 Mon @ 12:10 -0500] DRAFT → READY. Three review rounds incorporated (48, 59 and 22 findings; blocking went 13, 4, 0), all 129 dispositioned. 9 non-blocking residuals from the last repair pass stay open under Review findings and are carried into the build tasks.
+- [2026-10-05 Mon @ 11:10 -0500] spec-review round 3 on the consolidated spec: no blocking findings. 22 non-blocking findings recorded and being resolved before READY.
+- [2026-10-05 Mon @ 10:40 -0500] round-2 review incorporated: all 59 findings dispositioned, the exact contract consolidated into Implementation phases, and machinery without a caller cut.
+- [2026-10-05 Mon @ 09:45 -0500] spec-review round 2 on the revised spec: Not ready. 59 new findings recorded, 4 of them blocking.
+- [2026-10-05 Mon @ 09:30 -0500] reworded the bodies of Decisions 2 to 7 to match the rewritten design; I reopened none of them, and Decision 1 is unchanged. Decision 2: the TTY fallback holds the kernel and DKMS sets. Decision 3: the TOML is a divergent list, rewritten to equal the hook and pinned by an archsetup test, and the rollout is three code commits. Decision 4: it names the record, which is always written (except on the EUID 0 and lock-held refusals), states the stamp predicate per form, gives each test suite a copy of one fixture, and its example is now 6 deferred. Decision 5: the DKMS set joins the hold, with =zfs-utils= joining through the closure; the gate covers every kernel whose modules the run changed, plus open failures; ratio has three kernels; the DKMS hook timing is corrected. Decision 6: the boot form is =upgrade-guarded --apply-armed=, which installs the flag's list from the cache that arming pre-downloaded, with no refresh and no network, and the armed everyday run's disarm cases are split by cause. Decision 7: the flag is root-owned, carries the armed =<name>=<version>= list, and =ExecStartPre= moves it off its path at the start of the attempt.
+- [2026-10-05 Mon @ 06:05 -0500] spec-review: Not ready. 48 findings recorded under Review findings, 13 of them blocking; status stays DRAFT until they are dispositioned.
+- [2026-10-05 Mon @ 05:14 -0500] named the split script =upgrade-guarded= and added =containers= to its topgrade =--disable= list (the containers step fails on locally built images, hit again on velox 2026-10-04).
+- [2026-08-25 Tue @ 18:45 -0600] decisions closed 7/7. The kernel decision reversed on the velox DKMS failure chain: held on every everyday run, landed only in the dedicated session behind a DKMS/initramfs/snapshot gate.
+- [2026-08-25 Tue @ 18:30 -0600] redirected: the everyday path is a live split upgrade (apply everything the guard would not block, defer the rest); the boot-time oneshot becomes the completion step for the deferred set. Decided while running exactly that by hand on ratio.
+- [2026-08-25 Tue @ 06:39:42 -0600] drafted. Grounded in a live read of the maint engine, the pacman hooks, and the boot path on velox, not memory. The topgrade-freshness diagnosis that motivates it is in this session's log.
+
+* Metadata
+
+| Status | doing |
+|----------+-------------------------------------------------------------|
+| Owner | Craig Jennings |
+|----------+-------------------------------------------------------------|
+| Reviewer | Craig Jennings |
+|----------+-------------------------------------------------------------|
+| Related | maint =topgrade_age= metric; =hypr-live-update-guard= hook |
+
+* Summary
+
+The waybar maintenance module shows topgrade freshness as permanently stale. The cause is a real one: on a machine running Hyprland, a full =topgrade= almost never exits 0, because its system step upgrades GPU/compositor libraries that the =hypr-live-update-guard= pacman hook correctly refuses to swap under a live session. The freshness stamp is gated on topgrade's exit code, so a correct, protective refusal reads as "you never run updates."
+
+This spec designs a safe path to actually complete a guarded upgrade, and makes that completion record the freshness stamp, so the metric tracks the true state of the system. The everyday path is a live split run (=upgrade-guarded=) that applies everything outside its held set and records what it deferred: the GPU/compositor set while Hyprland runs and the kernel and DKMS sets always, together with any pending package that cannot move without them. The deferred set lands in a dedicated =upgrade-guarded --complete= session. The kernel and DKMS sets go in behind the =kernel-modules-check= gate, and the GPU/compositor set installs directly when Hyprland is stopped, or, when Hyprland is live, is pre-downloaded and finished by a boot-time oneshot from the package cache.
+
+Implementation phases is the one exact statement of behavior. Every other section explains the shape or the reasons and cites the phase that holds the detail.
+
+* Problem / Context
+
+The waybar maintenance module's topgrade freshness comes from one cache key, =topgrade_run= (=~/.local/state/maint/topgrade_run.json=), read by =topgrade_freshness= in dotfiles =maint/src/maint/probes/updates.py:114=. It grades the stamp's age in days against =topgrade_warn_days = 14=. With no stamp at all it returns WARN, "no topgrade run recorded", a branch that only fires on a machine that has never stamped. Two writers stamp it today: the =topgrade= PATH wrapper (=~/.dotfiles/hyprland/.local/bin/topgrade=) when the real binary exits 0, and the panel's TOPGRADE lever (=doctor.py=), which returns before its stamp on any non-zero exit.
+
+The read path is sound: a sandboxed =maint stamp topgrade= writes the file, and =maint status= then reads freshness 0. The fault is that the file is almost never refreshed. On ratio it was last written 2026-07-08, the day the wrapper landed, so on 2026-10-05 the probe graded an age of just under 90 days and warned.
+
+Topgrade rarely exits 0 on a machine running Hyprland, and the reason is specific rather than flaky. The =hypr-live-update-guard= pacman hook is a =PreTransaction=/=AbortOnFail= hook: when Hyprland is running and an upgrade changes the on-disk version of a GPU/compositor library, it prints a BLOCKED banner and exits 1, aborting the whole transaction before any file is swapped. Its 15 =Target= patterns (mesa and its subpackages, wayland, libdrm, libglvnd, hyprland and some of its core libraries, the Vulkan and NVIDIA userspace packages, xorg-xwayland) are the list =upgrade-guarded= later reads as its GPU patterns. The guard exists for a proven failure: replacing those libraries under a live compositor makes the next GPU call hit a now-deleted mapping and SIGABRT, taking every Wayland client down (hit on ratio 2026-06-07).
+
+The installer writes the hook as =/etc/pacman.d/hooks/10-hypr-live-update-guard.hook=, but both daily drivers still carry it under the legacy unprefixed name. Phase 4's rollout moves each machine to the =10-= name before its levers route through the script.
+
+So when any of those libraries has an update pending, which is often, topgrade's =system= step (it runs =yay=) aborts, topgrade exits non-zero, and neither writer stamps. The observed case: on 2026-08-24 topgrade ran at 17:49, hit the guard on =mesa= (26.1.7 → 26.2.1) and failed. The upgrade was then finished by hand with the guard's sentinel override, outside the wrapper, so nothing stamped. The metric had already gone stale in late July and stayed stale.
+
+The fix has two framings in tension. If the metric means "how recently did you run the sweep" (recency), the stamp should decouple from topgrade's exit. If it means "is the system up to date" (state), staying stale while an upgrade is deferred is correct, and the only real defect is that safely completing the deferred upgrade doesn't stamp. I take the state framing (Decision 1, "Metric means state, not recency"), so the rest of the spec builds a safe completion path and makes that completion the stamp.
+
+* Goals and Non-Goals
+
+** Goals
+- An everyday UPDATE or TOPGRADE that completes live. It applies everything outside its held set and defers the GPU/compositor set (while Hyprland runs), the kernel and DKMS sets (always), and any pending package that can't move without them. A deferral alone never fails the run, and the deferred set stays visible in maint until it lands.
+- A safe, low-friction way to land the deferred set: a dedicated =upgrade-guarded --complete= session that lands the kernel and DKMS sets behind the =kernel-modules-check= gate, offers no reboot while that gate is open, and applies the GPU/compositor set with Hyprland not live at swap time (at the next boot, or directly from a console with Hyprland stopped).
+- =upgrade-guarded= writes the =topgrade_run= freshness stamp only when a run leaves nothing deferred and no gate open, so the metric clears exactly when the system is current and stays honestly stale while a protective deferral is outstanding. The per-mode predicate is in Phase 1.
+- A boot-time upgrade path that can never lock the machine out of its session, however it fails.
+- The installer owns the durable pieces, so a rebuilt machine has them without hand-setup.
+
+** Non-Goals
+- Weakening or bypassing the =hypr-live-update-guard= hook. I keep it exactly as strict and build around it, not through it.
+- Running the topgrade ecosystem sweep (vim, npm, ...) at boot. None of it needs a stopped compositor, so it stays on the live path.
+- Changing how the kernel hazard is guarded. The hook stays silent on kernels. Holding the kernel and DKMS sets on every everyday run is a deferral policy inside =upgrade-guarded=, a separate list with its own reasons (Design), not a guard. An AUR upgrade that needs a newer kernel-set or DKMS-set package is the one ungated path; the everyday run detects it afterwards and opens the gate entry (Risks).
+- A general offline-update system for pacman. The boot form installs only the armed list (the GPU/compositor packages pre-downloaded at arming) from the package cache. Everything else lands live.
+
+** Scope tiers
+- v1: everything in Implementation phases. That is the two scripts and their installer step (Phase 1), maint's repointed levers with the deferred row and its APPLY action (Phase 2), the =archsetup-boot-upgrade.service= oneshot (Phase 3), and the docs and rollout on both daily drivers (Phase 4).
+- Out of scope: everything under Non-Goals.
+- vNext: a stale-kernel age in maint (how long the kernel set has been held, graded so an overdue dedicated session shows up), filed in =todo.org= as a [#D] task when the phases are decomposed.
+
+* Design
+
+The shape follows one principle: the only packages that need a stopped compositor are the GPU/compositor libraries the guard protects, and the only ones that need a moment I chose are the kernel and its DKMS modules. Everything else runs fine live and rarely fails. So the safe path is small and targeted: land the kernel and DKMS sets behind a gate in a session I start on purpose, apply the GPU/compositor set once with Hyprland down, and stamp only when nothing is left deferred. Everything else, the ecosystem sweep included, runs live through the split script, which leaves those sets behind.
+
+Three pieces, but the everyday gesture is not a reboot. The split script's everyday run is a normal live update that leaves the dangerous few behind. Its dedicated =--complete= session lands them on a day I choose. A boot-time oneshot runs its =--apply-armed= form to finish the GPU/compositor part before anything maps those libraries. This section gives the shape and the reasons. Implementation phases holds the exact behavior, each piece below names the phase subsection that governs it, and where the two differ, the phase bullet is normative.
+
+*The sets.* Every set the script works with is derived from the system at run time, never hardcoded. The pending set is what pacman reports as upgradable, minus IgnorePkg rows. The GPU patterns are the =Target= lines of =/etc/pacman.d/hooks/10-hypr-live-update-guard.hook=, taken verbatim with their globs (15 today), so the guard and the script share one source of truth. The script reads only the =10-= name, because the legacy unprefixed name sorts after =60-mkinitcpio-remove=, so a blocked bare =pacman -Syu= could delete the initramfs before the guard aborts. A missing hook, or one with no Target lines, makes every mode but the boot form refuse before any transaction rather than run with an empty list, which is deliberately unlike maint's =guard.trips=.
+
+The kernel set is the packages that own an installed kernel image, plus each kernel's headers where installed; firmware and API-header packages are never members. It is held whole, so a kernel and its headers move together, and only pending members ever move, so a foreign kernel such as ratio's =linux-lts-strix= (a local build with no headers and no zfs module, and the GRUB default) is never touched. The DKMS set is the packages that ship a =dkms.conf=, today =zfs-dkms=. Its exact pin, =zfs-utils=, isn't listed by name: the dependency closure adds it exactly when an upstream bump breaks the pin, and never for a pkgrel-only bump. I hold the kernel and DKMS sets on every everyday run, on both machines, because a failed DKMS rebuild on velox's ZFS root leaves the machine unbootable (see the kernel decision).
+
+Live means Hyprland is running, by the guard's own =pgrep -x Hyprland= test, so a Ctrl+Alt+F2 console beside a live session counts as live. Every held entry is kernel-kind (the kernel and DKMS sets and whatever the closure ties to them) or GPU-kind (the pending GPU-pattern matches and whatever the closure ties to them). The everyday run always holds the kernel side, and holds the GPU side only when live. The deferred set is the held entries still pending when a run ends. It is exactly the record's package list, and the record, the panel row and the armed list each cover the whole closure, not only the pattern matches. Exact behavior: Phase 1, Sets.
+
+*The split script.* A pacman =PreTransaction= hook can only abort or allow the transaction it is handed; it can't drop targets from it. So "upgrade everything except the held sets" can't live in the hook. It lives one layer up, in =upgrade-guarded= (archsetup =scripts/upgrade-guarded=), which the installer step that installs =hypr-live-update-guard= puts in =/usr/local/bin= along with its gate, =kernel-modules-check=. The script runs as the invoking user and refuses root, because the record and the stamp live in that user's =$HOME= and yay refuses root. Every root step the script itself runs goes through =sudo -n=, so a machine without its NOPASSWD rule fails the step with a reason rather than wedge on a password prompt the panel's runner can't answer. yay and the sweep escalate through their own sudo, but only after the refresh has succeeded under =sudo -n=. Each invocation runs exactly one mode: the everyday run, =--complete=, =--apply-armed= (the boot unit's alone) or =--dry-run=. The only option is =--no-topgrade=, and only in the everyday run.
+
+=--dry-run= is the preview. It prints what a run would hold, defer and execute, writes nothing, uses no sudo and takes no lock, and it resolves against a private sync db of its own, so it never touches the system db or the shared one another =checkupdates= caller may be using. Every other mode takes an exclusive lock beside the record, so a second run refuses instead of interleaving, and refuses while pacman's =db.lck= exists. The script never deletes =db.lck=: a stale lock can belong to a pacman that is still running, and only a person can tell. Exact behavior: Phase 1, Identity and privilege; Phase 1, Modes and options; Phase 1, Preconditions.
+
+*The everyday run.* Both panel levers call it. It captures the unread Arch news first, because yay judges news against installed build dates, and after the upgrade the news would read as old. It then clears informant's news hook, which would otherwise abort a =--noconfirm= transaction. The clear needs root, =--all= to run without prompting, and a timeout, because informant's feed fetch has none of its own. A failed clear is tolerated and leaves informant's hook live for that run, a residual I accept. Then come one refresh, the dependency closure over the held sets, and one =pacman -Su= against that same db with an =--ignore= per held entry and no second =-y=. yay upgrades the AUR packages next, and the ecosystem sweep follows unless =--no-topgrade= is given. The sweep calls =/usr/bin/topgrade= by absolute path, so the stamping =~/.local/bin/topgrade= PATH wrapper is never in the chain. It disables topgrade's =system= step (the script has already done that work), =git_repos= and =containers= (whose step fails on locally built images), each as its own argv element, because topgrade 17.12.2's parser rejects the comma-joined list, and =--no-ask-retry= keeps a failed step from waiting on stdin, since the panel's runner has no tty. A deferral alone never makes the exit non-zero; a failed step or a refusal does. Exact behavior: Phase 1, Everyday sequence.
+
+The guard hook stays installed, unchanged, as the backstop for a bare =pacman -Syu= typed at a shell. On the driven path it can still fire inside yay's own pacman call; Phase 1, Everyday sequence, says when, and Risks names the kernel-side case that yay can install ungated. Proof of concept: the guard-set half of this run, done by hand on ratio on 2026-08-25 while Hyprland was live, before the kernel and DKMS hold and the containers disable were added, resolved 724 of 730 pending packages (Emacs 31.1 among them) with the six guard hits deferred, after one unrelated fix, an orphaned =qemu-block-gluster= that had been dropped from the repo. That run upgraded linux, linux-lts, their headers, zfs-dkms and zfs-utils live, so under v1 the same day would have deferred at least twelve, and the orphan would have made the closure refuse with its name before any transaction.
+
+*The dependency closure.* =--ignore= alone isn't enough. pacman still enforces declared dependencies, so a pending package that needs a held package's new version, or whose upgrade breaks a versioned dependency of a held package, makes the transaction unresolvable, and under =--noconfirm= one unresolvable dependent fails the whole transaction, because pacman's skip prompt defaults to no. So before any transaction the script asks pacman what else has to wait, through a read-only print-mode probe against the run's one refreshed db, and holds those packages too until the probe resolves. When the answer isn't something it can safely hold around, such as an upgrade that would break an installed package that isn't pending (an orphan, foreign or AUR package, like =qemu-block-gluster= above), it refuses before any transaction and names what it found, rather than hold a package and its dependents indefinitely without saying why. A refusal never removes or forces anything. Exact behavior: Phase 1, Dependency closure.
+
+*The record and the stamp.* A deferred set nobody surfaces is a set that silently never lands, so the script records it durably in =${MAINT_STATE_DIR:-$HOME/.local/state/maint}/upgrade_deferred.json= (maint cache key =upgrade_deferred=), in maint's cache envelope, written atomically. =upgrade-guarded= is its only writer. maint's =upgrade_deferred= probe, =topgrade_freshness= and the script's own next run read it. Every mode but =--dry-run= writes it once as it finishes, refusals and failures included, so the panel always reflects the last run. =--complete= and =--apply-armed= also write it before their risky transactions, and the interrupt trap writes it too, so a kill or a power cut still leaves an accurate record with the remedy. A usage error, a root invocation and a held lock never touch it. The gate entry and the held snapshot carry forward through every mode, and only =--complete= changes them, except that an everyday run whose AUR step moved a kernel-side package opens the entry, holds its snapshot and writes the record at once (Phase 1, Invariants). "The gate is open" means the record holds a gate entry. Exact behavior: Phase 1, The record; Phase 1, Finish.
+
+=topgrade_run= keeps meaning "the system is current", so on the driven path =upgrade-guarded= is its only writer, and it stamps only when a run ends with nothing deferred and no gate open. The everyday run also needs the refresh, the upgrade, yay and the sweep all to have succeeded, so UPDATE, which skips the sweep, never stamps. =--complete= stamps only when it had something to complete, and never when it arms the boot unit, because the GPU set is still pending then. The stamp goes through maint by absolute path, because a system unit's PATH lacks =~/.local/bin=, and a missing maint or a failed stamp fails the run and is recorded, never silenced. doctor.py's lever stamp is deleted. The PATH wrapper keeps stamping a bare-shell =topgrade=, and the script never goes through it. Exact behavior: Phase 1, Stamp predicate.
+
+*For the user.* UPDATE runs =upgrade-guarded --no-topgrade= and TOPGRADE runs =upgrade-guarded=, and the =sysupgrade= alias runs the script wherever it is installed. On the common day both levers succeed. The panel's deferred row counts what the script held back, QUEUE tags the held names, and the strip's pending cell adds the held count. A deferral alone doesn't raise the row's severity or colour the waybar glyph, but freshness keeps aging until the deferred set lands, and after a TOPGRADE that left anything deferred the wall note says so and points at APPLY. A failed step, a refusal or an interruption shows on the row at WARN with its reason, and so do fixable advisories on deferred packages, which count against the row rather than against UPDATE. Arch news the run captured shows in the row's evidence until the row's DISMISS key clears it. While Hyprland isn't running (a TTY with no compositor, not a Ctrl+Alt+F2 console beside a live session), the everyday run holds only the kernel side, and the GPU/compositor set lands in the same run.
+
+Because the levers no longer arm a live swap, the guard's press-again refusal and the =--force= override go away. A tripped guard read only annotates the arm line with what the run will at least defer. The panel reads everything here from the record and the flag on its next probe, never from an exit code. Exact behavior: Phase 2 (Levers, Guard UX, The deferred row, DISMISS, Freshness and REBOOT, and CVE, QUEUE and the strip).
+
+*The dedicated session.* Landing the deferred set is =upgrade-guarded --complete=, a session I start on purpose in a terminal: the one APPLY opens, detached so closing the panel can't kill or orphan the kernel transaction, or any terminal or TTY. APPLY is offered on the deferred row while anything is deferred or the gate is open, on the freshness card while anything is deferred, and through REVIEW & FIX, and every route opens that same detached terminal. The session's first stage lands the kernel and DKMS sets live, with the desktop up for diagnosing, along with every pending package outside the GPU closure. Old modules are removed PreTransaction and rebuilt PostTransaction, so a failed DKMS build aborts nothing; the gate after stage 1 is what catches it. If the GPU closure would reach a kernel-side package, the session refuses before any transaction, because landing it would put a kernel behind the boot form or ahead of the gate.
+
+Two things happen before stage 1 touches anything, and on a ZFS root a third, the snapshot hold described below. Right after its refresh, the session removes any arm flag, and only its own GPU step can re-arm. Then, when a kernel-side package is pending or a gate is already open, it records the gate entry: which kernels are unverified, by pkgbase, and when the entry opened. A kill, a crash or a power cut mid-transaction therefore leaves the record naming the unverified kernels. From that moment until a gate passes, the deferred row is CRIT with REBOOT hidden, no arm flag exists, and the session never offers a reboot. The entry closes only when a gate passes, or when no kernel it names is still installed.
+
+The entry names every kernel whose modules the session could change: each kernel whose package or headers is pending, every kernel with a built DKMS module when a DKMS package is pending, and any kernel still unverified from an earlier session. Because it is keyed by pkgbase and resolved to module trees only after stage 1, a kernel version the session removed can never reach the gate, while the new version, and any sibling kernel whose DKMS module was rebuilt, is checked. The gate runs once, whatever stage 1's exit, against the time the entry opened. The exception is a session that opened the entry itself and whose stage 1 changed no kernel-side version, as when stage 1 fails on a download, signature or conflict error before committing anything. =/boot= is then intact and its initramfs predates the entry, so the time checks would hold the row at CRIT on a machine that boots fine. The gate runs its structural form instead, where a missing kernel image or initramfs, an unreadable image or a missing =zfs.ko= still fails it.
+
+A failed gate stops the session with the failing item named and exit 4. The machine keeps running the old kernel, the new one is installed but not booted, and the desktop is there for the fix; a later =--complete= re-runs the gate and closes the entry when it passes. Only a stage 1 that succeeded, with no gate left open, goes on to the GPU step. With Hyprland not running, the session applies the GPU/compositor set directly. With Hyprland live and the boot unit enabled, it pre-downloads that set while the network is up and arms the flag with its exact versions. With the unit not enabled, it says so and leaves the set for a console run with Hyprland stopped. It offers a reboot only from a tty, after a clean finish that armed or passed a time-checked gate. With nothing pending and no gate open, it reports that there is nothing to complete. Exact behavior: Phase 1, =--complete= sequence; Phase 1, Invariants.
+
+On a ZFS root, the fallback for a kernel that won't boot is the pre-pacman snapshot that ZFSBootMenu can boot. So on a ZFS root a session that opens the gate entry snapshots the root and places a ZFS user hold on that snapshot before stage 1. When no valid hold exists, a time-checked gate falls back to the oldest pre-pacman snapshot taken no earlier than a minute before the entry opened. The session records the held name. =zfs-pre-snapshot= prunes only unheld snapshots, so its routine prune can't destroy the one snapshot holding the old kernel before the new kernel has proven itself. The hold moves only when a later session, or an everyday run whose AUR step opens the entry, holds a newer snapshot, and a failed hold is reported without failing the run. The recorded name is the snapshot recovery boots and rollback releases. Exact behavior: Phase 1, The snapshot hold; Phase 4, Recovery.
+
+*The gate.* =kernel-modules-check= (archsetup =scripts/kernel-modules-check=) answers one question: would each named kernel boot with its modules? It takes the entry's pkgbases, writes nothing, makes no pacman call, and only =--complete= calls it (a by-hand run, as in Phase 4, only diagnoses). For each kernel it requires the module trees and the =/boot= image to exist, every DKMS module to be installed, and an initramfs newer than the image. On a ZFS root that initramfs must carry =zfs.ko=, read with =sudo -n lsinitcpio= because the images are root-only. The time-checked form also requires the initramfs to postdate the entry's opening and, on a ZFS root, a pre-pacman snapshot of the root dataset taken no more than a minute before it, the window =zfs-pre-snapshot='s 60 s skip leaves. The initramfs is never compared with the module tree's =vmlinuz=, whose mtime is its build date. Kernels outside the entry are never checked, so ratio's =linux-lts-strix=, where zfs is only 'added', never fails it. There is no standalone mode; the supported re-check is =upgrade-guarded --complete=. Exact behavior: Phase 1, The gate.
+
+*The arm flag.* The guard's =/run= sentinel is tmpfs and can't carry an intent across a reboot, so the arm flag lives at =/var/lib/archsetup/apply-upgrade-on-boot=, root-owned, and carries the exact =<name>=<version>= list to install. Arming first checks that the list resolves without pulling in a kernel-side package, so no kernel can ride in ungated as a dependency, then pre-downloads it with =pacman -Sw= while the network is up. A download-only run returns before PreTransaction hooks, so neither the guard nor informant's hook fires under a live Hyprland. Only =upgrade-guarded= writes the flag, atomically through =sudo -n=, and the boot unit consumes it. =--complete= clears it after its refresh and re-arms only from its GPU step. While armed, every everyday run whose refresh succeeded either re-arms on the current GPU-kind entries, so the armed versions follow the db that run synced, or disarms when there is nothing valid to arm, including on a machine whose boot unit isn't enabled, so no flag is left that nothing will consume. There is no =--disarm=, and the panel reads only the flag's presence and entries. Exact behavior: Phase 1, The arm flag; Phase 1, Everyday sequence.
+
+*The boot unit.* =archsetup-boot-upgrade.service= is a system oneshot that runs only when the flag exists, so an unarmed boot skips it at no cost. It is ordered before =getty@tty1.service=, so it finishes before the tty1 login and so before =~/.profile.d/99-hyprland-autostart.sh= can start Hyprland. That ordering is mandatory: a parallel run would let Hyprland start mid-swap and reintroduce the exact crash the guard prevents. It is wanted by =multi-user.target= but required by nothing and not ordered after the network, so its failure or timeout fails no target the session needs, and boot proceeds past it regardless. It runs as the installing user, so =$HOME= points at that user's maint state, and a root =ExecStartPre= moves the flag into the unit's runtime directory before the attempt. Success, failure, a timeout, SIGKILL and power loss therefore all disarm: the intent survives exactly one reboot, and a failed attempt never wedges later boots. Output goes to tty1 rather than the console, because ratio's =/dev/console= is ttyS0. The 20-minute start timeout is sent as SIGINT, which pacman honors at a package boundary, releasing =db.lck=; the unit never deletes it. A failed or timed-out run leaves the unit failed, and maint's failed-units row names it. Exact behavior: Phase 3, The unit.
+
+*The boot form.* =--apply-armed= runs only from the boot unit. It installs the armed list from the package cache in one transaction, with no refresh and no network, so it applies exactly the versions checked at arming, and the libraries are current before anything maps them, with no second reboot. Entries already installed at or above their armed version are dropped, so it never downgrades, and it refuses if the list would pull in a kernel-side package. Before the transaction it writes an interrupted record naming the remedy, so even SIGKILL or power loss leaves the next step on record, and its first tty1 line says an upgrade is running and not to power off. It also clears informant before the transaction: offline, the clear and informant's hook both fail fast and see an empty feed, so neither blocks. A network that comes up between the clear and the transaction can still let the hook abort it, which fails the attempt with the arm already consumed. If the run fails or is cut off, the tty1 login still appears and the record names what happened. An interrupted run can leave part of the set upgraded, so its remedy is =upgrade-guarded --complete= from a console with Hyprland stopped. There is no news capture, kernel step, gate, yay or sweep at boot. Exact behavior: Phase 1, =--apply-armed= sequence.
+
+*Interruption and exit codes.* Until its finish writes the record, every mode traps INT, TERM and HUP, waits for the running child, records the interruption with its step and remedy (=--dry-run= records nothing), keeps any open gate entry as written, and exits 130. A signal after that write rewrites nothing: at =--complete='s reboot prompt it counts as no and the exit stays 0. A failed step, a refusal before any transaction, a failed gate and an interruption each have their own exit code, and on any non-zero exit the last stderr line names the reason. Exact behavior: Phase 1, Exit codes and interrupts.
+
+=upgrade-guarded= is the one code path that completes an upgrade and records it. A by-hand sentinel-override upgrade outside the script doesn't stamp, though the panel's row still clears, because the probe drops entries already installed. =upgrade-guarded --complete= from a console replaces that ritual.
+
+* Alternatives Considered
+
+** A. Decouple the stamp from topgrade's exit code (stamp on any real run)
+- Good, because it is a one-line change to the wrapper and needs no boot machinery.
+- Bad, because it throws away honest signal: a topgrade that was blocked from applying a real upgrade would read as "fresh," so the metric stops meaning "up to date." On both daily drivers the blocked case is the common case, so the metric would be fresh precisely when an upgrade is outstanding.
+- Neutral, because the failed steps still surface elsewhere (pending-updates count), so freshness would become redundant rather than wrong.
+
+** B. Run the full topgrade live with the guard overridden, then reboot
+- Good, because it needs no new unit: set the guard's =/run= override sentinel, run, reboot.
+- Bad, because the dangerous window is the whole rest of the run: mesa swaps early, then topgrade spends minutes on other ecosystems while the live compositor is one new GL context (a new window, the wallpaper daemon) away from SIGABRT. topgrade's own reboot-at-end is that window, not a fix for it. It would also land the kernel and DKMS sets with no module check.
+- Neutral, because it would stamp naturally on success, if it survived.
+
+** C. Manual TTY ritual only (log out, run topgrade at the console, reboot), plus stamp
+- Good, because it is the safest path for the GPU/compositor set and needs almost no code, just the completion stamp.
+- Bad, because it is all manual, every guarded-upgrade day; the friction is why it won't happen consistently, which is how the metric got stale in the first place. A console topgrade also lands the kernel and DKMS sets with no module check.
+- Neutral, because it survives as the fallback: =upgrade-guarded --complete= from a console with Hyprland stopped is this ritual through the same script, gate included, and D automates its GPU/compositor half.
+
+** D. Boot-time armed oneshot, pacman-only (the GPU/compositor half of this spec's completion step)
+- Good, because the GPU/compositor swap happens with nothing live, as one bounded transaction from the package cache with a tiny prompt surface. Arming pre-downloads the exact versions, so the boot run needs no network. It stamps when it leaves nothing deferred, and a failure or interruption degrades to a normal boot with the flag already consumed, finished by running =upgrade-guarded --complete= again.
+- Bad, because it puts a unit on the boot critical path, which must be bounded and non-fatal with care, and it is the most to build. The flag also has to follow the sync db between arming and the reboot.
+- Neutral, because it composes with C: the same =upgrade-guarded= script serves both, =--complete= from a console and =--apply-armed= from the boot unit. Kernels never ride it: they land in =--complete='s gated first stage, before anything arms, and both arming and the boot run refuse a target list that names a kernel-set or DKMS-set member.
+- Exact behavior: Phase 1 (the split-upgrade script) and Phase 3 (the boot-time unit).
+
+** E. Split the live run: apply the non-blocked remainder now, defer the rest (this spec's everyday path)
+- Good, because it is what a careful operator does by hand anyway, and did, on ratio, the day this was decided. The live run succeeds on the common day, the sweep exits 0, the AUR and every other ecosystem stay current, and the guard trips only in the rare case of an AUR upgrade that needs a held GPU/compositor package while Hyprland is live.
+- Bad, because Arch calls any =--ignore= run a partial upgrade. In practice pacman still enforces declared dependencies, so a package needing the newer mesa never installs against the old one. But under =--noconfirm= one unresolvable dependent fails the whole transaction (pacman's skip prompt defaults to no), which is why the script has pacman compute the dependency closure first, and refuses with the blocker named rather than force anything. The residual exposure is a package with an *unversioned* dependency built against a new ABI, which for mesa/wayland/libdrm is rare. Named, accepted.
+- Bad, because a deferred set nobody surfaces is a set that silently never lands, the same trap as the freshness stamp one layer down. So the script records the deferred set durably and the panel shows it, and this is why D stays in the design as the completion step rather than being replaced.
+- Neutral, because it does not change the guard at all; it changes who decides the transaction's contents: the script holds the GPU/compositor set while Hyprland is live, and the kernel and DKMS sets on every run.
+- Exact behavior: Phase 1 (the split-upgrade script).
+
+* Decisions [7/7]
+
+** DONE Metric means state, not recency
+CLOSED: [2026-08-25 Tue 18:45]
+- Owner / by-when: Craig / 2026-08-25
+- Context: the stamp gate can mean "ran the sweep" or "system is current." The whole fix differs by which.
+- Decision: We will keep the state meaning. Freshness stays stale while a guarded upgrade is genuinely un-applied, and the fix is to make *safe completion* stamp — not to loosen the gate.
+- Consequences: easier — the metric stays trustworthy as an is-current signal, and Alternative A is off the table. Harder — completion now needs a real safe path (the rest of this spec) rather than a one-line wrapper change.
+
+** DONE Everyday mechanism is the split live run (Alternative E); the boot oneshot (D) completes the deferred set
+CLOSED: [2026-08-25 Tue 18:30]
+- Owner / by-when: Craig / 2026-08-25
+- Context: the first draft made D the primary gesture, which means every guarded-library day is a reboot day. My read while watching the ratio run: when the guard would trip, the rational move is to upgrade everything *except* the guarded and kernel items, then run the rest of topgrade without the yay piece — and that logic should be a script we can keep editing, not something baked into the panel.
+- Decision: I will build E as the path UPDATE and TOPGRADE always take, and keep D as the way the deferred set completes. =upgrade-guarded --complete= lands the kernel and DKMS sets behind the gate (the kernel decision below), then, while Hyprland is live, arms the boot oneshot to land the GPU/compositor entries on the next boot. C remains the manual fallback through the same script from a console with Hyprland stopped: an everyday run there holds only the kernel and DKMS sets, and =--complete= there applies the GPU/compositor set directly instead of arming. B stays rejected on the live-swap risk.
+- Consequences: easier — the common day is one live run that succeeds; reboots are reserved for the days I land the deferred set, and even then the machine keeps working until the reboot is convenient. Harder — two sources to keep straight (the guard's list, read from the hook; the kernel and DKMS sets, which the script derives from the installed system), a record the panel must render, and the partial-upgrade caveat in Alternative E, which the script meets by computing the dependency closure of everything it holds and refusing before any transaction when that closure can't be resolved.
+- Exact behavior: Phase 1, Everyday sequence and =--complete= sequence.
+
+** DONE The script lives in archsetup beside the guard, and maint calls it
+CLOSED: [2026-08-25 Tue 18:45]
+- Owner / by-when: Craig / 2026-08-25
+- Context: the panel (dotfiles =maint=) and the guard (archsetup =scripts/hypr-live-update-guard=, installed to =/usr/local/bin=) live in different repos, and maint already carries its own, divergent pattern list as =[updates] guard_patterns= in the thresholds TOML (seeded from archsetup's =configs/maintenance-thresholds.toml=).
+- Decision: ship the split script, =upgrade-guarded=, in archsetup next to the guard, with its gate, =kernel-modules-check=, beside it; the installer step that installs =hypr-live-update-guard= installs both to =/usr/local/bin=. The script reads the GPU patterns from the installed hook's =Target= lines, so the guard and the script can never disagree, and refuses to run without them. maint's UPDATE and TOPGRADE levers change their =argv= to the script: UPDATE runs =upgrade-guarded --no-topgrade=, TOPGRADE runs =upgrade-guarded=. The TOML patterns become the panel's display-side mirror, read only for the arm line that warns a press will defer at least the matched packages. They are rewritten to equal the hook's 15 =Target= patterns and pinned by an archsetup test in =tests/installer-steps/= that compares the seed TOML with the hook heredoc.
+- Consequences: easier — one owner for "which libraries are dangerous," and a rebuilt machine gets the script with the guard. Harder — a cross-repo change (archsetup ships it, dotfiles wires it), so the rollout is three ordered commit groups: archsetup Phase 1 (both scripts and the TOML rewrite), dotfiles Phase 2 (the levers and the panel), then archsetup Phase 3 (the boot unit). Each machine installs the archsetup scripts and re-copies the rewritten TOML before any Phase 2 commit reaches it.
+- Exact behavior: the Implementation phases intro (commit groups and ordering invariants); Phase 2, Levers and Guard UX; Phase 4, Rollout.
+
+** DONE What a split run stamps
+CLOSED: [2026-08-25 Tue 18:45]
+- Owner / by-when: Craig / 2026-08-25
+- Context: under the state framing, a run that deferred six packages left the system *not* current, yet the sweep ran and every other ecosystem is fresh.
+- Decision: the script stamps =topgrade_run= only when the system is current: nothing left deferred, no open kernel gate, and every step it ran succeeded. On the driven path it is the only writer. doctor.py's TOPGRADE-lever stamp is deleted, and the script calls =/usr/bin/topgrade= by absolute path, so the stamping PATH wrapper (which still stamps a bare =topgrade= typed at a shell) is never in its chain. UPDATE never stamps, and neither does a run that arms the boot oneshot or finds nothing to complete. A stamp that fails fails the run rather than passing silently. The deferred half goes to the script's own record, =${MAINT_STATE_DIR:-$HOME/.local/state/maint}/upgrade_deferred.json= (maint cache key =upgrade_deferred=), which it rewrites after every run, refusals, failures and interruptions included (Phase 1, The record). maint renders the record as its own state ("6 deferred", with APPLY to start the completion) rather than as stale freshness. Freshness keeps meaning "current"; the deferred row carries the other half.
+- Consequences: easier — no signal is thrown away, and the deferred row has a precise count behind it. Harder — one more cache key and one more probe in maint, and a record format two repos must agree on. archsetup holds the canonical fixture, bound to a fake run's actual output, and dotfiles holds a byte-identical copy whose test names its source, so a field change updates both in the same rollout. And because UPDATE never stamps, freshness clears only through a TOPGRADE with nothing deferred or a completion that ends empty.
+- Exact behavior: Phase 1, Stamp predicate and The record.
+
+** DONE Kernel set is held on every everyday run and lands only in the dedicated session, gated on the DKMS result
+CLOSED: [2026-08-25 Tue 18:45]
+- Owner / by-when: Craig / 2026-08-25
+- Context: I first wrote this as "install the kernel live at apply-on-reboot," on the reasoning that a kernel swap crashes nothing and the modules-vanish window ends with the reboot. Then I asked what happens on velox when the DKMS rebuild fails, and the answer changed the decision. Velox is an encrypted ZFS root with =/boot= inside the root dataset, one kernel (=linux-lts=), and =zfs-dkms=. On a kernel upgrade the old modules are removed PreTransaction (=70-dkms-upgrade=) and the rebuild runs PostTransaction (=70-dkms-install=), after the kernel is swapped; the alpm dkms script returns 0 even when a build fails, so nothing aborts. A failed build leaves a new kernel beside an initramfs that mkinitcpio wrote without =zfs.ko= (it only warns), and the next boot can't import the pool. The same chain runs with no kernel move at all when a DKMS package itself upgrades (today =zfs-dkms=, whose upgrade removes and rebuilds the module for every installed kernel). It is survivable — ZFSBootMenu can boot the pre-pacman snapshot, which holds the old kernel, initramfs, and modules — but it is a recovery session, not an update. Ratio (btrfs root, zfs only for a data pool) is exposed only at the pool; it has three kernels, including =linux-lts-strix=, a foreign local build with no headers and no zfs module, which is the GRUB default. The realistic triggers are a kernel major outrunning OpenZFS's supported range, a kernel upgraded without its headers, a toolchain regression, or a full disk.
+- Decision: the script holds the kernel set and the DKMS set on every everyday run, on both machines, whether or not Hyprland is running, so there is one rule rather than a per-host exception. Both sets are derived from what is installed, never from a fixed list. The kernel set is every installed kernel with its headers, held whole so a kernel and its headers always move together. The DKMS set is every package that ships a DKMS module (today =zfs-dkms=); its exact =zfs-utils= pin isn't listed by name, because the dependency closure adds =zfs-utils= exactly when an upstream bump breaks the pin. Only pending members ever move, so a foreign kernel (ratio's =linux-lts-strix=) is never touched. Both sets land only in the dedicated session, =upgrade-guarded --complete=, in its first stage, live on the running system (usually with the desktop up for diagnosing), and =kernel-modules-check= gates everything after that stage. Before the stage touches anything kernel-side, the session opens a gate entry in the record covering every kernel whose modules it could change, identified by pkgbase rather than by kernel version, so the gate checks whatever version stage 1 leaves installed. The gate checks that each of those kernels can boot with its modules: built, in a fresh initramfs, and on a ZFS root with a fresh pre-pacman snapshot behind it. The entry closes only when a gate passes or every kernel it names has been removed, so an interrupted or failed stage 1 leaves it open. While it is open no arm flag exists and nothing offers a reboot, and a failed gate exits 4 with the failure named. The GPU closure follows only after the gate passes: armed for the boot oneshot while Hyprland is running, or applied directly by a second stage when it isn't. If that closure would include a kernel-set or DKMS-set member, =--complete= refuses before any transaction. Kernels stay off the guard's list (the hook would block a TTY kernel upgrade for no reason). "Install the kernel live at apply-on-reboot" is withdrawn.
+- Consequences: easier — an everyday UPDATE can never put velox into the unbootable state, because no kernel and no DKMS module moves outside the gated session, and the day they move is one I chose, sitting at the machine, expecting to handle issues. Harder — the kernel deferral is now standing, so the dedicated session has to happen on a cadence (security fixes ride the kernel), and the panel's deferred count carries a kernel most days. From the start of a kernel-side stage 1 until a gate passes, the panel holds the deferred row at CRIT with REBOOT hidden, so a failed gate leaves the new kernel installed but not booted, with the desktop available for the fix, until a later =--complete= passes. On a ZFS root the session places a ZFS hold on the oldest pre-pacman snapshot taken since a minute before the entry opened, so the pre-snapshot prune can't destroy the fallback before it's needed; the hold moves only when a later session holds a newer one. And the gate is one more script to test.
+- Exact behavior: Phase 1, Sets, =--complete= sequence, Invariants, The gate and The snapshot hold.
+
+** DONE Boot run applies exactly the deferred GPU/compositor set, nothing else
+CLOSED: [2026-08-25 Tue 18:45]
+- Owner / by-when: Craig / 2026-08-25
+- Context: the only packages that need a stopped compositor are the guard's trigger set and what the dependency closure ties to it; the rest run fine live and are what usually fail. With the kernel decision above, the kernel and DKMS sets are not part of what boot applies either. A boot run can't count on the network, and a refresh at boot would install versions nobody checked when arming.
+- Decision: the boot oneshot runs the script's boot form, =upgrade-guarded --apply-armed=: exactly the armed GPU/compositor set, closure members included, in one pacman transaction, with no ecosystem sweep and no kernel. It installs the exact versions recorded at arming from the package cache, where arming pre-downloaded them while the network was up, so it needs no refresh and no network, and the unit has no network-online ordering. It skips news capture, the kernel step, the gate, yay and topgrade, never downgrades, and refuses rather than install anything that would bring in a kernel-set or DKMS-set member. The full topgrade sweep stays a normal live run through the everyday path.
+- Consequences: easier — the boot path is fast (one transaction from the cache, no network wait), has a tiny interactive-prompt surface (=--noconfirm=, no stdin, unread news cleared first with =informant read --all=), applies exactly the versions checked at arming, and rarely fails. Harder — the flag has to stay in step with the sync db, so every everyday run while armed whose refresh succeeds re-checks, re-downloads and rewrites it, or disarms; freshness after a boot run means the pacman side is current, not that the ecosystem sweep ran recently, which the stamp decision above accepts; and a network that comes up between the boot run's news clear and its transaction can let informant's hook abort the transaction, which the record names as a failed boot transaction with the arm already consumed.
+- Exact behavior: Phase 1, The arm flag and =--apply-armed= sequence; Phase 3, The unit.
+
+** DONE Arm flag lives on a persistent path and is one-shot
+CLOSED: [2026-08-25 Tue 18:45]
+- Owner / by-when: Craig / 2026-08-25
+- Context: the guard's =/run= sentinel is tmpfs and cleared on reboot, so it cannot carry an intent across the reboot. A boot that retries forever on failure is its own outage, and a flag removed at the end of the attempt is never removed when the attempt is killed or loses power.
+- Decision: We will use a persistent, root-owned flag, =/var/lib/archsetup/apply-upgrade-on-boot= (root:root 0644), that carries the armed list: one =<name>=<version>= line per GPU-kind upgrade target. Only =upgrade-guarded= writes it, atomically through =sudo -n=. =--complete= clears any flag right after its refresh and re-arms only from its GPU step, after the gate has passed, so no flag exists while a kernel gate is open. The boot unit moves the flag off its persistent path into its runtime directory at the start of its attempt, in =ExecStartPre=, before the boot form runs, so success, failure, a timeout, SIGKILL or power loss all disarm. The boot form reads the moved copy and never touches the flag.
+- Consequences: easier — the intent survives exactly one reboot, a failed or killed attempt never wedges subsequent boots, and the boot run applies exactly the versions checked at arming. Harder — a failed attempt needs re-arming by running =upgrade-guarded --complete= again, which is correct (a human decides to try again) but is a manual step. An attempt cut short by the unit's 20-minute timeout (sent as SIGINT, so pacman stops at a package boundary and releases =db.lck=) can leave part of the GPU/compositor set upgraded, so the boot form pre-writes an interrupted record naming the remedy (=upgrade-guarded --complete= from a console with Hyprland stopped) before its transaction. The panel only reads the flag's presence and entries.
+- Exact behavior: Phase 1, The arm flag and Invariants; Phase 3, The unit.
+
+* Review findings [129/138]
+Recorded by the 2026-10-05 spec-review (rubric: Not ready). Each finding names the spec passage, what it says or what the code does today, the risk, and the smallest change that closes it. =:blocking:= findings hold the rubric at Not ready until dispositioned. The evidence each finding was verified against is folded in its =EVIDENCE= drawer. Problem texts in the last nine (round 3 residual) items refer to earlier repair items as "gap N" or by short labels such as T11 or G6; those labels were working names and aren't defined elsewhere, so each item's title states the gap.
+
+** DONE Existing stamp writers mark a deferred run fresh :blocking:
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Problem L32; Design L65; Decision 4 'What a split run stamps' L124-129; Phase 1 L155; Phase 2 L158; AC L168
+
+Decision 4 says only the script stamps topgrade_run, and only when the deferred set is empty. However, the script calls bare =topgrade --disable ... -y= and exits 0 'whatever was deferred'. Phase 2 only points the TOPGRADE/UPDATE levers' argv at the script. Neither phase retires the two existing writers that the Problem section names.
+
+Risk: On every run that defers guarded packages or the kernel, topgrade_run gets stamped twice, once by the wrapper and once by doctor. topgrade_age then reads fresh while the upgrade is still outstanding. That is the Alternative A outcome (L77) that Decisions 1 and 4 reject. The panel shows a deferred badge next to a fresh age, and AC L168 fails both through the panel and from a shell.
+
+Recommended change: Phase 1 (L155): the script invokes the packaged binary by absolute path (/usr/bin/topgrade, owned by the topgrade package), never =topgrade= through PATH, so the stamping wrapper is never in the chain. The script remains the only thing that runs =maint stamp topgrade=, and only when the deferred set is empty. Add a test: put a stamping fake =topgrade= ahead on PATH, defer a non-empty set, and assert that topgrade_run is untouched.
+
+Phase 2 (L158): delete doctor.py:169-170's =if rid == "topgrade"= stamp, because the script owns the stamp. Update the wrapper's header comment and test_topgrade_wrapper.py so they no longer describe the lever as stamping through the wrapper. Add a test: a zero-exit TOPGRADE lever whose script deferred something leaves topgrade_run unchanged.
+
+Acceptance criteria: extend L167 with "...and topgrade_run is not written when anything was deferred, whether the run starts from the panel or a shell."
+
+Blocking. Verification: confirmed.
+Disposition: accepted, folded into Design, Implementation phases, Acceptance criteria, Testing.
+:EVIDENCE:
+Spec (docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org):
+- L32 names the two writers: the PATH wrapper on rc 0, and the TOPGRADE lever in doctor.py.
+- L65 and L155: the script runs bare =topgrade --disable system,git_repos,containers -y= and "exits 0 when the live part succeeded, whatever was deferred".
+- L128 (Decision 4): "the script stamps topgrade_run only when the deferred set is empty".
+- L136: the deferred count "carries a kernel most days".
+- L158: Phase 2 only repoints argv and removes the sentinel wrap.
+
+Code and system:
+- ~/.dotfiles/hyprland/.local/bin/topgrade:28-43: stamp=1 unless -n/--dry-run/--version/-V/--help/-h; ="$real" "$@"=, then =maint stamp topgrade= on rc 0.
+- The wrapper header comment (L5-6) treats the two writers as intended: "The maint TOPGRADE lever resolves this wrapper too; doctor's own stamp is redundant but harmless."
+- =which -a topgrade= lists ~/.local/bin/topgrade before /usr/bin/topgrade. ~/.local/bin/topgrade is a symlink to ../../.dotfiles/hyprland/.local/bin/topgrade.
+- /proc/<pid>/environ PATH for waybar (2361) and the panel's sh (2086) starts ~/.local/bin:...:/usr/local/bin:/usr/bin.
+- ~/.dotfiles/maint/src/maint/doctor.py:166-170: any returncode 0 leads to =if rid == "topgrade": cache.put("topgrade_run", {"at": time.time()})=.
+- ~/.dotfiles/maint/src/maint/remedies.py:303-308: the "topgrade" lever, whose argv Phase 2 repoints at the script.
+- ~/.dotfiles/tests/maint/test_topgrade_wrapper.py:51-56 asserts the stamp on rc 0.
+- =pacman -Qo /usr/bin/topgrade= reports topgrade 17.12.2-1, which owns the real binary.
+- No bypass exists today: grep for upgrade-guarded, /usr/bin/topgrade and MAINT_NO_STAMP in maint/, hyprland/ and tests/ finds nothing.
+:END:
+
+** DONE topgrade --disable comma list is rejected by the installed topgrade :blocking:
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Design L65; Phase 1 L155; Readiness External deps L189; history L11, L204
+
+The spec fixes the argv as =topgrade --disable system,git_repos,containers -y= and relies on it exiting 0. Line 189 still lists =topgrade --only system= as the verified dependency.
+
+Risk: Copied as written, the sweep fails argument parsing on every run before any step runs. No ecosystem is ever swept and topgrade never exits 0. A Phase 1 test with a faked binary that copies the spec's argv would pass and lock the bug in.
+
+Recommended change: 1. In Design L65 and Phase 1 L155, replace "topgrade --disable system,git_repos,containers -y" with "topgrade --disable system git_repos containers -y". Add a note that each step name is its own argv element, because the installed topgrade's clap parser rejects a comma-joined list with exit 2.
+2. Add to the Phase 1 tests: assert that the topgrade argv has each disabled step as a separate element. Add one check against the real binary: "/usr/bin/topgrade <the script's argv minus -y> --version" exits 0, so the faked harness cannot hide a parse error.
+3. In L65, say what a non-zero topgrade exit does. Either it counts against the script's exit and blocks the stamp, or the spec states that it doesn't, so a skipped sweep is never stamped silently.
+4. Replace the stale "topgrade --only system" in L189 and L193 (and the boot ExecStart in L69) with the script's --complete form, matching Decision L142.
+
+Blocking. Verification: confirmed.
+Disposition: modified, folded into Design, Implementation phases, Readiness dimensions, Risks, Testing. Accepted, with two additions. First, the binary is called by absolute path, per F01 and my lever call. Second, the argv gains --no-ask-retry. The script ships from archsetup and runs under the panel's no-tty runner, so whether a failed step waits on stdin shouldn't depend on the user's topgrade.toml (no_retry = true today). Item 4's replacement for the stale --only system text names the boot form --apply-armed rather than --complete, per F08.
+:EVIDENCE:
+Commands run on ratio (read-only):
+- pacman -Q topgrade -> "topgrade 17.12.2-1"
+- /usr/bin/topgrade --disable system,git_repos,containers --version -> "error: invalid value 'system,git_repos,containers' for '--disable <STEP>...' ... tip: a similar value exists: 'system'", exit=2
+- /usr/bin/topgrade --disable system git_repos containers --version -> "topgrade 17.12.2", exit=0
+- /usr/bin/topgrade --disable system --disable git_repos --disable containers --version -> exit=0
+- /usr/bin/topgrade --disable system git_repos containers -y --version -> exit=0
+- topgrade --help line 40: "--disable <STEP>..." (the possible values include system, git_repos and containers)
+
+Spec (docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org):
+- L65 and L155 give the comma-joined argv.
+- L65: "exits 0 when the live part succeeded, whatever was deferred".
+- L128/L155: the stamp is gated only on an empty deferred set.
+- L141-142: the decision says the "--only system" wording is stale.
+- L69, L189 and L193 still say "topgrade --only system".
+
+Code:
+- ~/.dotfiles/maint/src/maint/remedies.py:308: ["topgrade", "--disable", "git_repos", "-y"]
+- todo.org (~L3129): "TOPGRADE exited 2 on every launch ... passed --disable git, rejected by clap before any step ran."
+:END:
+
+** DONE --noconfirm installs ignored packages, so soname cascades abort the split run :blocking:
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Design L65 ('on the driven path it never fires'); Alternative E L97 ('fails resolution instead of installing broken'); Phase 1 L155; AC L167
+
+The spec assumes --ignore cleanly removes the blocked set, and that anything needing a newer blocked package fails resolution. On that basis it says the guard never fires on the driven path. It specifies no behaviour for two cases: a pending package that needs a held package's new version, and a held package that pins an old version of a pending one.
+
+Risk: Soname bumps in the hypr* family are routine on Hyprland release days. On those days the everyday UPDATE either re-pulls hyprutils (and the guard aborts) or fails with 'breaks dependency ... required by hyprland'. Nothing is applied, AC L167 can't be met, and the guard's abort becomes the common path again. If a prebuilt-module package like zfs-linux-lts is ever installed, the kernel hold is silently defeated.
+
+Recommended change: Phase 1 (L155): define the deferred set as a dependency closure rather than "hook targets ∪ kernel set". Start from the version-changing hook targets plus the kernel set, then repeat until nothing new is added:
+- Add any pending package whose new version needs a held package's new version (forward).
+- Add any pending package whose upgrade would break a versioned dependency of a held installed package (reverse).
+
+The simplest way to get this is to let pacman be the resolver. After the -Sy, run read-only =pacman -Sup --ignore=<set>=. Add each package named in "unable to satisfy dependency ... required by X" (add X) or in "installing X (...) breaks dependency ... required by <held>" (add X). Repeat until it exits clean. If it doesn't converge, or names a package outside the pending set (the L195 orphan case), refuse with the names. Never start a live -Syu that is known to fail.
+
+Related edits:
+- Change the L155 test to "the --ignore list is blocked ∪ kernel ∪ dependency closure". Add fixtures for both directions: hyprutils so=12→13 with hyprlang depending on =13 (forward), and an unguarded so bump that a held hyprland depends on (reverse).
+- State that the panel's deferred row (L128/L158) and the boot --complete transaction (L142) cover the whole closure in one transaction.
+- Correct L97: under --noconfirm, one unresolvable dependent fails the whole transaction (pacman's skip prompt defaults to no), which is why the closure exists.
+
+Drop the finding's "--noconfirm re-installs ignored packages" and zfs-linux-lts claims. pacman doesn't behave that way on this path.
+
+Blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: modified, folded into Design, Alternatives, Decisions, Implementation phases, Risks, Testing. Partially confirmed. This follows the narrowed finding and my loop, and pins down what the recommendation left loose: locale and flags, both message forms, the iteration bound, the single refresh from F14, and how closure members get a kind (stage A is kernel-kind, stage B is GPU-kind).
+
+One rule is made exact. A reverse 'breaks dependency' line adds the upgrading package only when the requirer is already held. When the requirer is unheld and not pending, the line names a package outside the pending set: the orphan case I want refused. Adding the upgrading package there would silently hold it, and its dependents, indefinitely, instead of naming the orphan.
+:EVIDENCE:
+Spec: L65 (-Syu --noconfirm --ignore=<blocked set>; "on the driven path it never fires"; the 724/730 proof of concept on a mesa day). L97 ("fails resolution instead of installing broken"). L142 (boot run applies exactly the deferred set, one transaction). L155 ("the --ignore list is exactly blocked ∪ kernel set"). L167 (AC: applies everything else, exits 0). L195 (orphan case: detect, name, stop).
+
+pacman v7.1.0 source (lib/libalpm deps.c and sync.c are byte-identical at installed commit 54d9411):
+- deps.c:829: spkg = resolvedep(handle, missdep, handle->dbs_sync, *packages, 0). Prompt is 0, so ignored packages are skipped without the INSTALL_IGNOREPKG question (deps.c:662-667, 693-698).
+- deps.c:762: prompt=1 only in alpm_find_dbs_satisfier.
+- sync.c:439-468: unresolvable packages raise ALPM_QUESTION_REMOVE_PKGS. With no skip, GOTO_ERR(ALPM_ERR_UNSATISFIED_DEPS).
+- callback.c:509: q->skip = noyes(...). util.c:1736-1738: under noconfirm the preset (0) is returned, so the transaction fails.
+- sync.c:654-668 with deps.c:353-380: reverse-dependency break fails prepare, no prompt.
+- src/pacman/sync.c:750/754: messages "unable to satisfy dependency '%s' required by %s" and "installing %s (%s) breaks dependency '%s' required by %s".
+
+Live system and cache on ratio:
+- The installed hook /etc/pacman.d/hooks/hypr-live-update-guard.hook targets hyprutils and aquamarine but not hyprlang or hyprtoolkit.
+- bsdtar .PKGINFO: hyprutils-0.13.1-1 provides libhyprutils.so=12-64, 0.14.0-1 provides =13-64. hyprlang-0.6.8-4 depends on =12-64, 0.6.8-5 on =13-64.
+- aquamarine-0.14.0-1 provides libaquamarine.so=13-64, 0.15.0-2 provides =14-64. hyprtoolkit-0.5.4-4 depends on =13-64, 0.5.4-6 on =14-64.
+- pacman.log: the whole hypr family co-upgraded with pkgrel-only rebuilds on 2026-04-05, 05-03, 07-21 and 09-09.
+- expac -S: hyprlang, hyprlock, hyprwire, hyprpaper, hyprtoolkit, hyprland-guiutils and xdph all depend on libhyprutils.so=13-64. hyprland depends on libhyprlang.so=2-64, libhyprwire.so=3-64 and libhyprcursor.so=0-64 (all unguarded).
+- No mesa-family package carries a versioned dependency on a guarded package.
+- zfs-linux-lts is not installed (zfs-dkms 2.4.4-1 is).
+:END:
+
+** DONE zfs-dkms and its pinned zfs-utils bypass the kernel hold :blocking:
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Decision 5 L134-136 (consequence: 'an everyday UPDATE can never put velox into the unbootable state'); Design L65; Phase 1 L155; Risks L196
+
+Only the kernel set (kernels plus -headers) is held on everyday runs, and the DKMS failure chain is treated only as a consequence of a kernel upgrade. Other DKMS-triggering packages go through the live --ignore run.
+
+Risk: An everyday run that moves zfs-dkms removes the working zfs.ko before the transaction and rebuilds it afterward, with nothing able to abort. A failed build (toolchain regression or full disk, both triggers Decision 5 lists) leaves velox with an initramfs that lacks zfs.ko. That is the unbootable state the kernel decision exists to prevent, reached on a run nobody chose. Holding zfs-dkms alone doesn't work either, because the exact zfs-utils pin makes the --ignore run fail dependency resolution.
+
+Recommended change: Recommended (needs my call because it widens Decision 5's held set): define a DKMS set and hold it next to the kernel set.
+- L135 (Decision 5) and L65 (Design): add "plus the DKMS set: every package owning /usr/src/*/dkms.conf (pacman -Qoq), together with any dependency it pins with '=' (today zfs-dkms + zfs-utils), held whole on every everyday run and applied in --complete with the kernel set, before kernel-modules-check runs."
+- L155 (Phase 1): change "the --ignore list is exactly blocked ∪ kernel set" to "... blocked ∪ kernel set ∪ DKMS set". Add a test that a pending zfs-dkms/zfs-utils upstream bump is held as a pair, and that a zfs-utils pkgrel-only bump is not held.
+- L168: change "everything but the kernel set" to "everything but the kernel and DKMS sets".
+
+If I decline to widen the set, make two smaller edits instead:
+- Rewrite L136 to "an everyday UPDATE never moves the kernel; a zfs-dkms move on an everyday run remains exposed, with the 05-zfs-snapshot/ZFSBootMenu fallback."
+- Have the everyday run call kernel-modules-check whenever it moved a dkms.conf-owning package, and surface any failure as a named, do-not-reboot warning.
+
+Either way, fix L196: "the DKMS hooks are PostTransaction" becomes "old modules are removed PreTransaction and the rebuild runs PostTransaction".
+
+Blocking. Verification: confirmed.
+Disposition: modified, folded into Decisions, Design, Implementation phases, Acceptance criteria, Risks, Testing. Confirmed and accepted. This widens Decision 5's held set, recorded as a body rewording plus a history line, not a reopening. The '= pin' half comes through F03's closure instead of a separate parse. On ratio, installed zfs-dkms 2.4.4-1 depends on zfs-utils=2.4.4. With zfs-dkms held, pacman reports that an upstream zfs-utils bump breaks that pin, so the closure adds zfs-utils exactly then and leaves a pkgrel-only bump alone. One mechanism produces the pair and pkgrel test outcomes the recommendation asks for.
+:EVIDENCE:
+- Spec L65: blocked set = guard list "plus the kernel set (every installed kernel with its -headers ...)". Spec L155: kernel set = "every linux* kernel package and its -headers"; test: "the --ignore list is exactly blocked ∪ kernel set". L136: "an everyday UPDATE can never put velox into the unbootable state". L168: TTY run "applies everything but the kernel set". L196: "(the DKMS hooks are PostTransaction)".
+- /usr/share/libalpm/hooks/70-dkms-upgrade.hook: Operation=Upgrade, Target=usr/src/*/dkms.conf, When=PreTransaction, Exec=/usr/share/libalpm/scripts/dkms -D remove.
+- 70-dkms-install.hook: same targets, When=PostTransaction, dkms install.
+- 90-mkinitcpio-install.hook: Target=usr/src/*/dkms.conf, PostTransaction.
+- /usr/share/libalpm/scripts/dkms main(): collects ERROR_MESSAGES and ends with "return 0".
+- /usr/bin/mkinitcpio:409: warning "errors were encountered during the build. The image may not be complete." (the image is still written). /usr/lib/initcpio/install/zfs: map add_module ... zfs spl.
+- pacman -Qoq /usr/src/*/dkms.conf: zfs-dkms.
+- pacman -Si zfs-dkms: Repository archzfs, Depends On zfs-utils=2.4.4 lsb-release dkms. zfs-utils is installed at 2.4.4-3 (a pkgrel bump allowed by the pin).
+- /var/log/ratio-upgrade.log:704-705: "dkms remove --no-depmod zfs/2.4.3 -k ...". Lines ~1487-1488: "dkms install --no-depmod zfs/2.4.4 -k ...".
+- /var/log/pacman.log:20503-20504: zfs-utils and zfs-dkms upgraded 2.4.3 to 2.4.4 in the same run.
+- Mitigation that makes it survivable rather than fatal: archsetup:2421-2432 writes 05-zfs-snapshot.hook (PreTransaction, Target=*), which runs before the 70 dkms hooks.
+:END:
+
+** DONE kernel-modules-check fails permanently on ratio :blocking:
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Phase 1 L155 ('for each kernel under /usr/lib/modules'); Decision 5 L134-135; Design L67; Risks L196; Phase 4 L164
+
+Phase 1's gate requires that, for each kernel under /usr/lib/modules, dkms status reports every registered module as installed. Decision 5 and Design scope the check to 'the new kernel version'. The spec describes ratio as having two kernels.
+
+Risk: Every --complete run on ratio stops at the gate, whatever it installed, so ratio can never arm and the GPU/compositor set never lands through the designed path. Phase 4's 'confirm the ratio path matches' cannot pass, and the deferred row and stale freshness become permanent on one of the two daily drivers.
+
+Recommended change: Edit L155 (Phase 1) to match Decision 5.
+
+Replace "for each kernel under =/usr/lib/modules=, =dkms status= reports every registered module =installed= for it, and its initramfs is newer than its =vmlinuz=" with:
+
+"for each kernel version this =--complete= run installed or changed (its =/usr/lib/modules/<ver>= directory, mapped to =/boot/vmlinuz-<pkgbase>= and its initramfs through =<ver>/pkgbase=), =dkms status -k <ver>= reports every registered module =installed=, and that kernel's initramfs is newer than its =vmlinuz=. Kernels the run did not touch are not checked. When the run changed no kernel, the DKMS and initramfs checks pass vacuously."
+
+Add one test to the Phase 1 test list: "an untouched kernel that is foreign, has no headers, and has DKMS modules only 'added' (ratio's =linux-lts-strix= shape) does not fail the gate."
+
+Correct "two kernels" at L134 and L196 to "three kernels, including =linux-lts-strix=, a foreign local build with no headers and no zfs module, which is the GRUB default."
+
+Related, optional: say in Phase 1 that the derived kernel set excludes packages absent from the sync repos, because "applied whole" cannot apply =linux-lts-strix= (pacman -Si: not found).
+
+Blocking. Verification: confirmed.
+Disposition: modified, folded into Design, Decisions, Implementation phases, Risks, Testing. Confirmed. Scoping the gate to kernels this run changed is right, but on its own it leaves two holes, and both are closed here.
+- F04 puts the DKMS set into --complete's stage 1. A zfs-dkms-only move rebuilds modules for kernels that were already installed, so those kernels join the check list.
+- After a failed gate the new kernel is already installed. A second --complete changes no kernel, so it would pass vacuously, clear the CRIT row, and arm. The open failure's kernels are therefore re-checked against the recorded since.
+:EVIDENCE:
+Spec lines in docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org:
+- L155: "for each kernel under =/usr/lib/modules=, =dkms status= reports every registered module =installed= for it"
+- L135: "=dkms status= reports every DKMS module installed for the new kernel version"
+- L67: "every DKMS module built for the new kernel"
+- L134 and L196: "Ratio (btrfs root, two kernels, ...)"
+- L164: "Confirm the ratio path matches."
+
+Live checks on ratio:
+- uname -r: 6.18.25-1-lts-strix
+- /proc/cmdline: BOOT_IMAGE=/@/boot/vmlinuz-linux-lts-strix
+- /etc/default/grub:2: GRUB_DEFAULT="gnulinux-linux-lts-strix-advanced-..."
+- ls /usr/lib/modules: 6.18.25-1-lts-strix, 6.18.54-1-lts, 7.2.7-arch1-1
+- pkgbase files: linux-lts-strix (no build/ dir), linux-lts (build/ present), linux (build/ present)
+- pacman -Qm: linux-lts-strix 6.18.25-1, Packager "Unknown Packager"
+- pacman -Si linux-lts-strix and pacman -Si linux-lts-strix-headers: "error: package ... was not found"
+- dkms status: "zfs/2.4.4, 6.18.54-1-lts, x86_64: installed" and "zfs/2.4.4, 7.2.7-arch1-1, x86_64: installed"
+- dkms status -k 6.18.25-1-lts-strix: "zfs/2.4.4: added"
+- modinfo -n zfs: "ERROR: Module zfs not found."
+- pacman -Qi zfs-dkms: 2.4.4-1 installed
+- /var/log/ratio-upgrade.log:1536: "Building image from preset: /etc/mkinitcpio.d/linux-lts-strix.preset", written 2026-08-25, the same day the decisions were closed.
+- docs/workflows/strix-soak-watch.org says the strix kernel is a standing local build, pinned as the GRUB default, until it is retired.
+
+Not a problem for the initramfs half: /boot/initramfs-linux-lts-strix.img (Sep 29) is newer than /boot/vmlinuz-linux-lts-strix (Apr 30), so that check passes.
+:END:
+
+** DONE Phase 1 and Decision 2 hold the kernel only when a compositor is live :blocking:
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Phase 1 L155; Decision 2 L114; Non-Goals L51; Decision 5 L135; AC L168
+
+Phase 1 computes 'blocked set ... ∪ held-kernel set when a compositor is live'. Decision 2 calls a TTY run 'no compositor → nothing blocked → a full run', and Non-Goals L51 says 'holds them back on a live run'. Decision 5 and AC L168 hold the kernel on every everyday run, TTY included, and Phase 1's own test list says 'held whole on every everyday run'. The spec never says how liveness is detected.
+
+Risk: Read literally, the documented TTY fallback (C) moves velox's kernel with no DKMS gate. That is the unbootable path Decision 5 removes, and it would ship in v1 code paths and tests. If the script checks for a TTY instead of the process, a tty2 run with Hyprland still up skips the guarded patterns and the guard aborts.
+
+Recommended change: Five small text edits, no decision change:
+1. L155: replace "blocked set (hook =Target= lines, version-aware) ∪ held-kernel set when a compositor is live" with "blocked set (hook =Target= lines, version-aware; only when Hyprland is running, tested exactly as the guard tests it: =pgrep -x Hyprland=) ∪ kernel set (always, on every run except =--complete=)".
+2. L155 tests: add "with a liveness seam forced on and off (as =HYPR_GUARD_RUNNING= does for the guard), =--ignore= contains the kernel set on both branches and the blocked set only on the live branch."
+3. L114: change "(no compositor → nothing blocked → a full run)" to "(no compositor → no guarded library blocked → everything but the kernel set; the kernel still lands only through =--complete=)".
+4. L51: change "on a live run" to "on every run outside the dedicated =--complete= session".
+5. Optional, one clause at L67 or L168: "'no compositor' means Hyprland is not running, not merely a console; Ctrl+Alt+F2 leaves it live on tty1."
+
+Blocking. Verification: confirmed.
+Disposition: modified, folded into Goals and Non-Goals, Decisions, Design, Implementation phases, Acceptance criteria, Testing. Confirmed. The DKMS set (F04) joins the kernel set wherever this finding says 'kernel set'.
+
+The seam is the script's own name. The guard reads HYPR_GUARD_RUNNING from pacman's environment, which sudo resets, so sharing the name would suggest one variable drives both.
+
+--complete's stage 1 holds the GPU closure even from a TTY, because Decision 5 puts the GPU set after the gate.
+:EVIDENCE:
+Spec docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org:
+- L155: "pending set → blocked set (hook =Target= lines, version-aware) ∪ held-kernel set when a compositor is live"; the same paragraph's tests say "held whole on every everyday run".
+- L114 (Decision 2, CLOSED 18:30): "C remains the manual fallback through the same script from a TTY (no compositor → nothing blocked → a full run)."
+- L51: "the split script holds them back on a live run".
+- L135 (Decision 5, CLOSED 18:45): "holds the kernel set ... on every everyday run, on both machines ... The kernel set lands only in the dedicated session".
+- L168 (AC): "The same run with no compositor live (a TTY) applies everything but the kernel set".
+- L12/L208: the 18:45 reversal to "held on every everyday run".
+- No line in the spec names a liveness test (grep for compositor/live/tty/pgrep).
+
+Guard scripts/hypr-live-update-guard:
+- L57-62: hyprland_running() uses the HYPR_GUARD_RUNNING override (documented at L32), else pgrep -x Hyprland.
+- L67: hyprland_running || exit 0.
+- L130-131: the banner says "from a TTY with Hyprland stopped: 1. Log out of Hyprland, or switch to a console (Ctrl+Alt+F2)". The Ctrl+Alt+F2 option leaves Hyprland running on tty1, so "TTY" and "no compositor" are not the same.
+:END:
+
+** DONE Phase 3 panel action is the withdrawn ungated kernel path :blocking:
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Scope tiers v1 L55; Phase 3 L161; against Decision 5 L135, Design L67, Phase 1 L155, history L208
+
+L55 describes an 'apply on reboot' affordance that 'installs the held kernel live and arms the boot-time oneshot'. L161 says 'The panel action installs the held-kernel set live, writes the persistent arm flag, and offers to reboot', with 'the arm action's tests in maint'. Neither mentions kernel-modules-check.
+
+Risk: An implementer following Phase 3 builds a maint-side button that swaps the kernel, writes the flag and offers a reboot, with no DKMS, initramfs or snapshot check. On velox, a failed zfs-dkms build then reboots into a kernel whose initramfs can't import the pool. That is the exact failure Decision 5 was closed to prevent.
+
+Recommended change: L55: replace "an 'apply on reboot' affordance that installs the held kernel live and arms the boot-time oneshot for the GPU/compositor set" with "an 'apply on reboot' panel action that runs upgrade-guarded --complete (kernel set live, then the kernel-modules-check gate, then arm the boot-time oneshot for the GPU/compositor set)".
+
+L161: replace the first sentence with "The panel action runs upgrade-guarded --complete in the foreground (tty PROMPT mode). The script does every step: kernel set live, the gate, the arm flag and the reboot offer. maint never installs packages or writes the flag itself, and on a non-zero exit it shows the named gate failure and offers no reboot." Replace "the arm action's tests in maint" with "maint tests that the action's argv is upgrade-guarded --complete and that a non-zero exit leaves no reboot offer".
+
+L155: add one clause: "--complete exits non-zero when the gate fails."
+
+Optionally add an acceptance line after L169: "The panel's apply-on-reboot action, on a failed gate, neither arms nor offers a reboot."
+
+Blocking. Verification: confirmed.
+Disposition: modified, folded into Goals and Non-Goals, Design, Implementation phases, Acceptance criteria, Testing. Confirmed, and reconciled with F40, which is also accepted. The action opens a detached terminal rather than using the lever runner's tty PROMPT mode. The panel then can't read --complete's exit code, so 'a non-zero exit leaves no reboot offer' becomes state-driven: F12's gate record hides REBOOT, and F34's arm flag shows it. foot gets --hold so the gate verdict stays readable after the script exits. The action moves into Phase 2 with its row (F16).
+:EVIDENCE:
+Spec docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org:
+- L55 (v1 scope): "an 'apply on reboot' affordance that installs the held kernel live and arms the boot-time oneshot for the GPU/compositor set". No gate.
+- L161 (Phase 3): "The panel action installs the held-kernel set live, writes the persistent arm flag, and offers to reboot. ... the arm action's tests in maint". No --complete, no kernel-modules-check.
+- L134-135 (Decision 5, closed): "I first wrote this as 'install the kernel live at apply-on-reboot'... A failed gate stops with the failure named and never reboots. The GPU/compositor set follows only after the gate passes... 'Install the kernel live at apply-on-reboot' is withdrawn."
+- L208 (history, 18:45): "withdrew 'install live at apply-on-reboot'... added the kernel-modules-check gate to Phase 1 and the acceptance criteria".
+- L67 (Design): the dedicated session is "run in the foreground from the panel's action or upgrade-guarded --complete in a terminal: first the kernel set, live... then the gate... If the gate fails the script stops there... and does not reboot... If it passes, the script arms a persistent flag... and offers to reboot". This contradicts L161.
+- L155 (Phase 1): --complete runs the kernel set, then the gate, then arms; "--complete refuses to arm or reboot on that exit". Only kernel-modules-check is stated to exit non-zero. --complete's own exit code on a failed gate is never specified.
+- L169 (acceptance): the DKMS-failure criterion covers only "A --complete run", not the panel action.
+- Current maint (~/.dotfiles/maint/src/maint/doctor.py:161-176) runs lever steps by argv and has a tty PROMPT mode. Routing the action to a script argv fits the existing mechanism with no new maint-side package logic.
+:END:
+
+** DONE Boot run's ExecStart and scope contradict the boot-scope decision, and no boot form is defined :blocking:
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Design 'For the implementer' L69; Decision 4 L128; Decision 6 L138-143; Phase 1 --complete L155; Phase 3 L161; Readiness L189; Risks L193
+
+L69: ExecStart runs, as the user, 'informant read ..., then =topgrade --only system= (or the equivalent =yay -Syu=), then =maint stamp topgrade= on success, then removes the flag'. Phase 3 L161: ExecStart 'runs the script's --complete form'. Phase 1 defines --complete as 'apply the kernel set live, run the gate, then arm ... or, with no compositor live, apply it directly'. Decision 6 L142: the boot run applies exactly the deferred GPU/compositor set in one pacman transaction, with no sweep and no kernel. L69 stamps on any success, whereas Decision 4 stamps only when the deferred set is empty. No text says whether the boot run reads the set recorded at arm time or re-derives it with a sync refresh, or that it rewrites the deferred-set record.
+
+Risk: Whichever text the implementer follows, the boot run does one of two bad things. It may install a kernel (including one published since arming) at the console, with no gate and no desktop to diagnose from: the velox unbootable chain Decision 5 forbids. Or it runs the gate at boot and never applies the GPU set on ratio. A refresh at boot can also pull in packages that were never armed. The stamp can fire while a kernel is still deferred, and the panel keeps the pre-boot 'N deferred' count after a successful run.
+
+Recommended change: Define one boot-only form in Phase 1 (L155) and point every boot reference at it.
+
+1. Add to L155's flag list: =--apply-armed=, the boot form. It reads the GPU/compositor package list that --complete's arm step writes into the flag file. It runs =informant read= if installed, then a single =sudo pacman -S --needed --noconfirm <that list>=. It does not use -y. A refresh followed by a targeted -S is itself a partial upgrade and could pull versions that were never armed. It runs no kernel set, no kernel-modules-check, no yay, and no topgrade. On success it rewrites the deferred-set state file and stamps topgrade_run if the deferred set is now empty. Add one sentence to --complete: "arming writes the GPU/compositor package list into the flag file".
+2. Replace L69's ExecStart sentence, from "then =topgrade --only system=" through "on success", with: "runs =upgrade-guarded --apply-armed= as the user". Keep the unconditional flag removal.
+3. In L142 and L161, change "the script's =--complete= form (scoped to the deferred GPU/compositor set)" to "the script's =--apply-armed= form".
+4. In L71 and L93, replace "=maint apply-upgrade=" as the boot unit's path with "=upgrade-guarded=" (the archsetup script, per Decision 3). Its TTY form is --complete and its boot form is --apply-armed.
+5. In L189, drop "topgrade =--only system=". In L193, change "The =--only system= scope" to "The recorded-set scope".
+6. Add these Phase 1 tests: the --apply-armed transaction targets exactly the recorded list, never includes a kernel-set package, and never passes -y.
+
+Blocking. Verification: confirmed.
+Disposition: modified, folded into Design, Decisions, Implementation phases, Readiness dimensions, Risks, Testing. Confirmed and adopted, reconciled with two of my calls.
+- Disarm-at-ExecStartPre means the boot form reads the copy that ExecStartPre moved into /run, not the flag path.
+- Exact versions in the flag mean the transaction uses versioned targets, filtered so it never downgrades.
+
+One safety check is added. pacman -S resolves dependencies from the sync db, so a GPU target needing a newer kernel-set or DKMS-set package would pull that package in at boot, ungated. A read-only, offline pacman -Sp of the exact targets runs before the transaction and refuses that case.
+:EVIDENCE:
+Spec text:
+- L69: "Its =ExecStart= runs, as the user: =informant read= …, then =topgrade --only system= (or the equivalent =yay -Syu=), then =maint stamp topgrade= on success, then removes the flag unconditionally".
+- L71: "a small =maint apply-upgrade= … which both the boot unit and an interactive TTY run call".
+- L93: "the same =maint apply-upgrade= path serves both an interactive TTY run and the boot unit".
+- L141: "The first draft phrased this as =topgrade --only system=; with the split script that wording is stale".
+- L142: "the boot oneshot runs the script's =--complete= form scoped to the deferred GPU/compositor set: one pacman transaction, no ecosystem sweep, no kernel".
+- L155: "=--complete= (the dedicated-session form: apply the kernel set live, run the gate, then arm the GPU/compositor set or, with no compositor live, apply it directly; stamp when the deferred set is empty)".
+- L161: "=ExecStart= runs the script's =--complete= form as the user".
+- L189: "External APIs & deps: topgrade =--only system=".
+- L193: "The =--only system= scope … shrink this to near zero".
+
+Git history:
+- =git show a50fb3b:…spec.org= L64 has the identical ExecStart sentence, so it was never revised.
+- The uncommitted diff touches only the script name and the containers flag, not L69.
+
+Live system:
+- ~/.dotfiles/common/.config/topgrade.toml:131 sets =arch_package_manager = "yay"=, and L134 =# yay_arguments= is commented out.
+- /etc/pacman.conf:25 has =IgnorePkg = bridge-utils= and nothing else, so no kernel is ignored.
+- =pacman -Q= shows linux 7.2.7, linux-lts 6.18.54, and linux-lts-strix 6.18.25 (no headers).
+- =dkms status= shows zfs installed for 6.18.54-1-lts and 7.2.7-arch1-1 only.
+- =topgrade --help= confirms =--only <STEP>=.
+:END:
+
+** DONE Boot run's package source and network dependency are undefined :blocking:
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Design L65-69; Decision 6 L138-143; Phase 3 L161; Readiness Performance L182
+
+The spec never says whether the boot run refreshes the sync db, downloads packages or waits for the network. Nothing pre-downloads the GPU set at arm time, and the unit declares no network ordering. The spec also never covers a stale arm (flag set, then days of further everyday runs before the reboot) or a flag that is still set when nothing is pending any more.
+
+Risk: If the boot run refreshes and downloads, an armed boot depends on pre-login network and mirrors, network-online delays the login prompt on every boot, and a refresh can pull new versions (including a kernel) into the boot transaction. If it doesn't, there is no rule for where packages come from. With no network the run fails every time and disarms (Decision 7), so the GPU set never lands.
+
+Recommended change: 1. In Design L69, replace "then =topgrade --only system= (or the equivalent =yay -Syu=)" with "then =upgrade-guarded --complete= in its boot form". In L189 and L193, drop the =--only system= references the same way.
+
+2. Add one paragraph to Decision 6 (or Phase 3):
+
+"The boot form never refreshes the sync db and has no network dependency; the unit declares no network-online ordering. When --complete arms, it runs =pacman -Sw --noconfirm <GPU set>= while the network is up and writes the exact pkg=version list into the arm flag. While armed, each everyday run re-runs -Sw for the deferred set after its own refresh and rewrites that list. At boot the run skips the kernel step and the gate. It installs the recorded list with =pacman -S --noconfirm= against the existing db, so pacman takes every file from the cache. A missing cache file fails fast and names the package. If every recorded package is already installed at or past its recorded version, the run is a no-op: it clears the flag, rewrites the deferred-set record, and stamps only if Decision 4's empty-deferred-set rule is met."
+
+3. Add a Phase 1 test that the boot form makes no -y/refresh call. Add an acceptance criterion that an armed boot with networking down still applies the set.
+
+Blocking. Verification: confirmed.
+Disposition: modified, folded into Design, Decisions, Implementation phases, Acceptance criteria, Readiness dimensions, Testing. Confirmed; this matches my boot-source call, including the recommendation's re-download while armed. That keeps the flag's versions equal to whatever the system db each everyday run synced. Three gaps are closed:
+- An armed everyday run that can't compute the closure, pass the check, or re-download disarms. Otherwise it would leave a list the db no longer matches.
+- The boot form never downgrades. A recorded version below the installed one would downgrade under --noconfirm, so such entries are dropped.
+- F08's dependency check runs at arm time and again at boot, so no kernel can ride in as a dependency.
+:EVIDENCE:
+Spec:
+- L69 names =topgrade --only system= / =yay -Syu= as the boot ExecStart.
+- L141 (Decision 6 context) calls that wording "stale". L142 scopes the run to =--complete= with "no kernel".
+- L155 defines =--complete= as starting with "apply the kernel set live".
+- L189 and L193 still cite =--only system=.
+- L182 claims negligible cost when the flag is absent.
+- A grep of the spec for Sw|download|network|online|mirror|refresh|offline finds nothing about the boot run's package source or network. The only refresh mention is the live run at L65.
+
+Live system (ratio):
+- =checkupdates= shows guarded packages pending: mesa 1:26.2.4-1, vulkan-radeon, hyprland 0.56.2-4, and kernels linux 7.2.8 and linux-lts 6.18.55. None of them is in /var/cache/pacman/pkg.
+- maint uses plain =checkupdates= with no -d (maint/src/maint/probes/updates.py:152, doctor.py:83). /usr/bin/checkupdates downloads only with -d (L63, L173).
+- No timer pre-downloads. The only timers are snapper, man-db, tmpfiles, shadow, logrotate, keyring-wkd, paccache, reflector and btrfs-scrub.
+- Boot timing (monotonic µs): basic.target 7776379, NetworkManager-wait-online 8975960→14740538, network-online.target 14740879, getty@tty1 35133024.
+- NetworkManager-wait-online.service is enabled, with =ExecStart=/usr/bin/nm-online -s -q= and =NM_ONLINE_TIMEOUT=60=.
+- network-online.target is already pulled in by docker, minidlna, reflector and archlinux-keyring-wkd-sync.
+- systemd.unit(5), Conditions and Asserts: conditions "are checked at the time the queued start job is to be executed. The ordering dependencies are still respected... neither condition nor assertion expressions are suitable for conditionalizing unit dependencies."
+- reflector.timer: OnCalendar=weekly, Persistent=true (tangential).
+:END:
+
+** DONE Timeout is unspecified, and SIGTERM kills pacman mid-transaction and leaves db.lck :blocking:
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Design L69; Phase 3 L161; AC L171; Readiness Errors L179, Performance L182, Config surface L185; Risks L192
+
+The spec treats a timeout as benign: 'worst case is a bounded delay, then a normal session' (L192), and AC L171 says a timeout lets the machine boot into Hyprland. The TimeoutStartSec value is left open (L185). Nothing defines the system's state after pacman is killed mid-commit.
+
+Risk: A timeout during extraction leaves /var/lib/pacman/db.lck behind and a half-extracted guarded set (mixed mesa/libdrm files). getty then starts and Hyprland launches against mixed libraries. Every later pacman, yay or upgrade-guarded run fails with 'unable to lock database' until someone removes the lock by hand, and the panel shows only the old deferred count.
+
+Recommended change: 1. Phase 3 (L161) and Design (L69): give archsetup-boot-upgrade.service KillSignal=SIGINT, so a timeout reaches pacman's interrupt handler, which stops at a package boundary and releases /var/lib/pacman/db.lck itself. Give TimeoutStartSec a concrete, generous value (e.g. 20min) so it fires only on a real hang.
+
+2. Move the disarm out of the tail of ExecStart. Remove the flag with ExecStartPre=+/usr/bin/rm -f /var/lib/archsetup/apply-upgrade-on-boot (or in ExecStopPost=), so success, failure and timeout all disarm. Reword Decision L149 to match ("removed at the start of the attempt" or "in ExecStopPost").
+
+3. Define the interrupted outcome. If the transaction did not complete, or db.lck survives, the unit writes "interrupted" plus the remedy into the deferred-set state file the panel already renders. It never deletes db.lck on its own.
+
+4. Correct the false claims. In Risks L194, replace "one atomic transaction, so there is no half-swapped library" with: pacman is atomic per package only, and only when allowed to stop at a package boundary, which is why the unit uses KillSignal=SIGINT. Drop "worst case is a bounded delay" from L192.
+
+5. AC L171: add that after a timeout no db.lck is left behind, the flag is cleared even though ExecStart was killed, and the panel names the interruption.
+
+Blocking. Verification: confirmed.
+Disposition: modified, folded into Design, Decisions, Implementation phases, Acceptance criteria, Readiness dimensions, Risks. Confirmed; this matches my call: SIGINT, 20min, disarm at the start, an interrupted record, and never deleting db.lck. Two reconciliations:
+- The flag now carries the list, so ExecStartPre moves it into the unit's RuntimeDirectory instead of running rm. That disarms just as completely.
+- The interrupted record is pre-written before the boot transaction, not written only by a signal trap. A trap can't run on the SIGKILL that follows TimeoutStopSec, or on power loss.
+
+The ExecStopPost alternative is dropped.
+:EVIDENCE:
+Spec:
+- L69 / L161: "TimeoutStartSec bounded … ExecStart runs … then removes the flag unconditionally".
+- L149: the flag is removed "at the end of its attempt".
+- L171: the AC requires that on "a timeout … the flag is cleared" and the machine boots into Hyprland.
+- L179: "every failure path lands in … re-arm to retry".
+- L192: "worst case is a bounded delay, then a normal session".
+- L194: "each pacman run is one atomic transaction, so there is no half-swapped library".
+- grep of the spec for killsignal|db.lck|interrupt|SIGTERM|SIGINT|ExecStopPost|atomic finds nothing except the L194 atomicity claim.
+
+systemd (v262):
+- man systemd.service: TimeoutStartSec "Defaults to DefaultTimeoutStartSec= … except when Type=oneshot is used, in which case the timeout is disabled by default". TimeoutStartFailureMode "default to terminate … sending the signal specified in KillSignal= (defaults to SIGTERM)".
+- man systemd.kill: KillMode=control-group kills "all remaining processes in the control group".
+
+pacman 7.1.0.r9:
+- strings /usr/bin/pacman shows only "Interrupt signal received" and "Hangup signal received" handler messages. No SIGTERM handler is present.
+- libalpm.so.16 contains alpm_trans_interrupt and "transaction interrupted". So SIGINT has a graceful path: it stops at a package boundary during commit and releases the lock; during download it unlocks and exits.
+
+Live system: ratio's /etc/pacman.d/hooks holds hypr-live-update-guard.hook and 99-grub-sync-efi.hook, and /usr/share/libalpm/hooks/05-snap-pac-pre.hook runs PreTransaction, so a kill can land while the lock is held before commit even starts.
+:END:
+
+** DONE Bare =informant read= hangs unattended and can't clear the news hook when run as the user :blocking:
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Design L65, L69; Phase 1 L155; AC L172; Reuse L183; Readiness External deps L189
+
+The live script and the boot oneshot (which runs 'as the user') both run a bare =informant read= to clear the news hook before the transaction. L189 lists informant as 'verified present'.
+
+Risk: The boot oneshot has no stdin, so the bare command either blocks until TimeoutStartSec or exits without marking anything read. Run as the user it can't save read state either, so informant's AbortOnFail hook aborts the transaction. The boot run then fails every time Arch has published news, consuming the arm. AC L172 fails, and the panel-driven live run can hang or abort the same way.
+
+Recommended change: L65, L155: replace "=informant read=" with "=sudo informant read --all=" (run only when =command -v informant= succeeds; failure tolerated, as at archsetup:1127-1129). Optionally log =informant list --unread= first so the news isn't dropped unseen.
+
+L69: change "Its =ExecStart= runs, as the user: =informant read= (...)" to say that the news-clear and pacman steps run as root via sudo, and the news-clear step is =sudo informant read --all= when installed. Add the reason: =/var/lib/informant.dat= is 0664 root:informant, the user isn't in that group, and a bare read prompts between items, so it crashes on null stdin or blocks on a tty.
+
+AC L172: change "(=informant read= precedes the transaction)" to "(=sudo informant read --all= precedes the transaction; verified with two or more unread items and no stdin)".
+
+L189 (optional): note that informant is present on velox only; ratio doesn't have it installed.
+
+Blocking. Verification: confirmed.
+Disposition: modified, folded into Design, Implementation phases, Acceptance criteria, Readiness dimensions, Testing. Adopted, with one hardening. informant 0.6.0's feed fetch calls requests with no timeout. On a half-up network, from the panel or early in boot (the unit has no network ordering), the call could hang until the lever's 3600s timeout or the unit's 20-minute timeout. timeout 60 bounds it, and the run carries on either way. The optional 'log informant list --unread first' is superseded by F39's yay -Pwq capture.
+:EVIDENCE:
+- Spec L65 "clears the news hook (=informant read=) where installed"; L69 "Its =ExecStart= runs, as the user: =informant read= ..."; L155 "=informant read= if present →"; L172 AC "Unread Arch news does not wedge the boot run (=informant read= precedes the transaction)"; L189 "all verified present on velox".
+- informant 0.6.0-2 source, taken from the archangel build tree at ~/code/archangel/work/x86_64/airootfs/usr/lib/python3.14/site-packages/informant/:
+ - informant.py:132-145 is the bare-read loop, which calls =ui.prompt_yes_no('Read next item?')= between items.
+ - informant.py:146 is =fs.save_datfile()=, the only save, after the loop.
+ - ui.py:69 is =input(...)=, which raises EOFError on null stdin and blocks on a tty.
+ - informant.py:117-119 is =--all=, which marks everything read without prompting.
+ - config.py:19 sets the save file, =FILE_DEFAULT='/var/lib/informant.dat'=.
+ - file.py:45-53 catches PermissionError on that save, prints an error, and calls =sys.exit(255)=.
+- The rest of the package, from the same tree:
+ - usr/lib/tmpfiles.d/informant.conf: =f /var/lib/informant.dat 0664 root informant=
+ - usr/share/libalpm/hooks/00-informant.hook: =Target = *=, =When = PreTransaction=, =Exec = /usr/bin/informant check=, =AbortOnFail=
+ - informant.py:72-98: =check= exits with the unread count.
+- The installer's user setup (archsetup:1403-1405) adds =sys,adm,network,...,users= and does not add =informant=. The installer's own call, at archsetup:1121-1129 (commit cc0f7b5), is =informant read --all ... || true=, run as root, with the comment "a bare =informant read= is interactive and would hang an unattended run".
+- maint's =cmd.run= (maint/src/maint/cmd.py:22) uses =subprocess.run(..., capture_output=True)= with stdin inherited. A prompt there is either invisible or gets EOF.
+- On ratio, =pacman -Q informant= reports "package 'informant' was not found". =pacman -Si= shows informant 0.6.0-2 in [extra].
+:END:
+
+** DONE Panel offers REBOOT after a failed --complete gate :blocking:
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Design 'For the user' L67; Decision 5 L134-136; Phase 2 L158; Phase 3 L161; AC L169
+
+The spec says a failed gate 'stops there, names what failed, and does not reboot'. That holds only for the script. The spec never says what the panel shows after the kernel set has landed live and the gate has failed.
+
+Risk: On velox, the panel shows a red REBOOT key at the exact moment the gate has said a reboot can't import the pool. The message naming the failure is gone once the wall is cleared or the panel is reopened.
+
+Recommended change: Phase 1 (L155):
+- After the kernel set lands, if kernel-modules-check fails, --complete records the failure in the deferred-state file as gate: {ok: false, kernel: <ver>, failed: <item>, at: <ts>}.
+- It prints the failing item as its last stderr line.
+- The next --complete or kernel-modules-check run that passes clears that record.
+
+Phase 2 (L158) and Phase 3 (L161):
+- While gate.ok is false, the deferred probe renders a CRIT row: "kernel <ver> installed — gate failed: <item> — do not reboot". The usual "apply on reboot" wording is not shown.
+- The panel hides the REBOOT key, overriding reboot_required and offer_reboot.
+- Rewrite Phase 3's panel-action sentence to: runs --complete, which installs the kernel set, runs the gate, and arms and offers reboot only when the gate passes.
+
+Acceptance criteria: add "After a failed gate, the panel names the failing item persistently (it survives a wall dismiss and a panel reopen) and offers no REBOOT until a later gate run passes."
+
+Blocking. Verification: confirmed.
+Disposition: modified, folded into Design, Implementation phases, Acceptance criteria, Testing. Confirmed, with two narrowings.
+- Only upgrade-guarded writes the record. A passing standalone kernel-modules-check therefore doesn't clear the failure, and the gate stays a stateless reader.
+- Clearing requires a real re-check of the recorded kernels against the recorded since. A --complete after a hand fix changes no kernel and would otherwise pass vacuously.
+
+APPLY stays visible while the failure is open, even when nothing else is deferred, so the re-check is reachable from the panel.
+:EVIDENCE:
+Spec:
+- L67: if the gate fails, the script "stops there, names what failed, and does not reboot".
+- L135: "A failed gate stops with the failure named and never reboots."
+- L155: --complete "refuses to arm or reboot on that exit".
+- L128/L158: the panel row reads "N deferred — apply on reboot".
+- L161: the Phase 3 panel action "installs the held-kernel set live, writes the persistent arm flag, and offers to reboot" (no gate, no failure branch).
+- L169: AC on a failed DKMS build.
+- No occurrence of reboot_required anywhere in the spec.
+
+Live system:
+- pacman -Qo /usr/lib/modules/6.18.25-1-lts-strix/modules.alias prints "No package owns".
+- /usr/share/libalpm/hooks/60-depmod.hook (PostTransaction, Remove/Upgrade on usr/lib/modules/*/) runs /usr/share/libalpm/scripts/depmod. That script does rm -f on modules.{alias,dep,...} and then rmdir --ignore-fail-on-non-empty "$f" for a kernel directory that no longer has modules.order.
+- 71-dkms-remove.hook is present.
+
+maint (in ~/.dotfiles/maint/src/maint):
+- probes/packages.py:131-146: required = not os.path.isdir(/usr/lib/modules/<uname -r>).
+- panel.py:506-510: reboot_key_visible() returns the reboot_required value.
+- gui.py:491-495: red REBOOT armed key when that is visible.
+- gui.py:1510-1511: offer_reboot only after an update or topgrade "ok".
+- remedies.py:314-320: the reboot remedy's metric_ids are ["reboot_required"].
+- doctor.py:166: return False, f"exit {proc.returncode}", stderr[-500:].
+- panel.py:308 and 389-396: the wall is a session-only log, and wall_clear() drops it.
+:END:
+
+** DONE UPDATE/TOPGRADE flag mapping and the stamp rule for partial runs are undefined :blocking:
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Scope tiers L55; Design L65, L67; Decision 4 L128; Phase 1 L155 (flags --no-topgrade, --no-aur; 'stamp only when nothing was deferred → exit 0 on a successful live part'); Phase 2 L158
+
+Both levers 'change their argv to the script', with no flags given. Today they are different remedies: UPDATE is a repo+AUR system update, and TOPGRADE is the ecosystem sweep that owns topgrade_age. Phase 1 defines --no-topgrade and --no-aur but names no consumer for them. The stamp predicate is only 'deferred set empty'. Nothing says what a run stamps or exits with when it skipped the sweep or the AUR, or when a sub-step failed (the pacman orphan stop in Risks L195, the AUR build, a topgrade step such as npm).
+
+Risk: There are two plausible implementations. In one, UPDATE silently gains the full ecosystem sweep and the two keys become duplicates. In the other, UPDATE runs --no-topgrade and the script stamps topgrade freshness for a run that never swept the ecosystems. Either way, a failed AUR or topgrade step with an empty deferred set reads 'current' under the state framing of Decision 1.
+
+Recommended change: Phase 2 (L158): state the lever mapping. UPDATE runs =upgrade-guarded --no-topgrade=; TOPGRADE runs =upgrade-guarded=. Add that on the driven path the script is the only thing that writes topgrade_run. That means removing doctor.py's rid=="topgrade" stamp, and having the script call the real topgrade binary (or tell the PATH wrapper not to stamp) so the inner sweep cannot stamp. Decision 4 (L128) and Phase 1 (L155): define the predicate for the everyday run. It stamps topgrade_run only when the sweep ran (no --no-topgrade or --no-aur), every step exited 0, and the deferred set is empty. If pacman fails, it skips the later steps, still writes the state file, exits non-zero, and does not stamp. --complete and the boot oneshot keep their own rule: stamp when their transaction succeeds and the deferred set is empty, with no sweep required. Add a Phase 1 test that a run with a non-empty deferred set leaves topgrade_run unwritten, through both the lever and the inner topgrade call.
+
+Blocking. Verification: confirmed.
+Disposition: modified, folded into Decisions, Implementation phases, Testing. Accepted, with the completion-form rule tightened in two places.
+- A completion form stamps only when it started with something to complete. Otherwise a --complete that finds nothing would mint freshness after an UPDATE that never ran the sweep.
+- No form stamps while a gate failure is open, so TOPGRADE can't mark a do-not-reboot machine fresh.
+:EVIDENCE:
+- Spec L65 and L155: the script exits 0 "whatever was deferred" / "on a successful live part". L155: "stamp only when nothing was deferred". Flags --no-topgrade and --no-aur are defined with no consumer.
+- Spec L55, L67, L114, L121, L158: UPDATE and TOPGRADE both "change their argv to the script", with no per-lever flags.
+- Spec L128 (Decision 4): stamp topgrade_run only when the deferred set is empty. L136: the deferred count "carries a kernel most days".
+- Spec L32: names both existing writers. No phase removes or neutralizes either one.
+- ~/.dotfiles/maint/src/maint/remedies.py:294-312: update = ["yay","-Syu","--noconfirm"] with metric_ids updates_repo/updates_aur/cve_advisories; topgrade = ["topgrade","--disable","git_repos","-y"] with metric_ids topgrade_age.
+- ~/.dotfiles/maint/src/maint/doctor.py:169-170: =if rid == "topgrade": cache.put("topgrade_run", {"at": time.time()})= runs on any exit-0 step.
+- ~/.dotfiles/hyprland/.local/bin/topgrade:38-42: runs the real binary, then =maint stamp topgrade= when rc is 0. Its header says the maint lever resolves this wrapper too.
+- =which -a topgrade= lists ~/.local/bin/topgrade ahead of /usr/bin/topgrade.
+- Spec L142-143 (Decision 6): the boot/--complete path runs no sweep, and its stamp "reflects the guarded set specifically", so a predicate that requires the sweep would contradict it.
+:END:
+
+** DONE Blocked-set computation reads checkupdates' private db, not the db pacman -Syu uses
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Design L65 ('refreshes the sync db and reads the pending set (checkupdates)'; 'version-aware'); Phase 1 L155 and its tests
+
+The script 'refreshes the sync db and reads the pending set (checkupdates)'. It then computes the blocked set 'version-aware the way the guard is', by intersecting with the hook Targets, and passes it to =pacman -Syu --ignore==.
+
+Risk: A guard-style version check that runs before the real refresh leaves mesa, hyprland and vulkan-radeon out of --ignore. -Syu then pulls them in and the live guard aborts the whole transaction, so AC L167 fails in the state ratio is in right now. An update that appears in the second sync but not the first has the same effect. Treating exit 2 as a failure fails every day with nothing pending, and implementers will differ on how =mesa-*= becomes ignore entries.
+
+Recommended change: Design L65:
+- Replace "refreshes the sync db and reads the pending set (=checkupdates=); computes the *blocked set* = the guard's own trigger list (read from the installed hook's =Target= lines, ..., and version-aware the way the guard is — a same-version reinstall is not a swap)" with: "builds the *ignore list* = the installed hook's =Target= patterns passed verbatim to =--ignore= (IgnorePkg accepts shell globs, so =mesa-*= needs no expansion; a pattern with nothing pending is a no-op, and =-Syu= never reinstalls at the same version, so no version lookup is needed here) ∪ the kernel set".
+- After the yay step, add: "the *deferred set* is computed after the transaction from =pacman -Qu=, which reads the db =-Syu= just synced, filtered by the same patterns ∪ the kernel set."
+
+Phase 1 L155:
+- Change "blocked set (hook =Target= lines, version-aware)" to "ignore list (hook =Target= patterns verbatim) ∪ kernel set".
+- Change the test bullet "blocked-set computation against a fixture hook and version map" to "the =--ignore= list is exactly the fixture hook's Target patterns ∪ the kernel set; the deferred set is derived from a fake post-transaction =pacman -Qu=".
+
+Add one sentence: "=checkupdates= is used only by =--dry-run=; its exit 2 means an empty pending set, not a failure."
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: modified, folded into Design, Implementation phases, Testing. Confirmed. The recommended text keeps pacman -Syu, which would sync a second time after F03's closure was computed against the first sync. That is the same race this finding names. Instead: one sudo pacman -Sy, the closure on that db, then sudo pacman -Su with no -y.
+:EVIDENCE:
+Commands below were run on ratio, 2026-10-05.
+- =checkupdates --help= (v1.13.1): the default db is =${TMPDIR:-/tmp}/checkup-db-${UID}=. In /usr/bin/checkupdates, :150 runs =pacman -Sy --dbpath "$CHECKUPDATES_DB"=, :155 drops =[...]= lines, and :181 is =exit 2= when nothing is pending.
+- =checkupdates -n= filtered to hook Targets and kernels lists: =hyprland 0.56.2-3 -> 0.56.2-4=, =mesa 1:26.2.3-1 -> 1:26.2.4-1=, =vulkan-radeon 1:26.2.3-1 -> 1:26.2.4-1=, plus linux, linux-headers, linux-lts and linux-lts-headers.
+- The guard-style lookup (=pacman -Q= vs =expac -S %v=, scripts/hypr-live-update-guard:78-82) reports mesa, hyprland and vulkan-radeon as installed = candidate, i.e. same version.
+- /var/lib/pacman/sync/extra.db is dated 29 Sep 08:21. =pacman -Qu= against the system db shows 0 updates; checkupdates shows 395.
+- tests/hypr-live-update-guard/test_hypr_live_update_guard.py:15-16,52: HYPR_GUARD_VERSIONS is a "pkg installed candidate" map. This is the "version map" the spec's Phase 1 test (L155) mirrors.
+- pacman.conf(5) on IgnorePkg: "Shell-style glob patterns are allowed." pacman(8) =--ignore= adds packages to the same ignore list.
+- /etc/pacman.d/hooks/hypr-live-update-guard.hook Targets: mesa, mesa-*, wayland, libdrm, libglvnd, hyprland, aquamarine, hyprutils, hyprgraphics, vulkan-radeon, vulkan-intel, vulkan-mesa-layers, nvidia-utils, lib32-nvidia-utils, xorg-xwayland. None use =!=.
+:END:
+
+** DONE Flag removal at the end of ExecStart never runs on timeout, kill or power loss
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Design L69; Decision 7 L145-150; Phase 3 L161; AC L171; Readiness L178
+
+L69 and L161: ExecStart runs the steps and then 'removes the flag unconditionally'. The flag directory /var/lib/archsetup is installer-owned (L178), and the unit runs as the user.
+
+Risk: Any timeout, kill, power loss or permissions slip leaves the flag in place, so every later boot re-runs the upgrade ahead of getty. That is the retry-every-boot outage Decision 7 exists to prevent, and it contradicts AC L171. The panel's RESTART key could also re-run the unit under a live session.
+
+Recommended change: In Design L69 and Phase 3 L161, replace "then removes the flag unconditionally" with: "the unit removes the flag before the transaction starts, privileged: ExecStartPre=+/usr/bin/rm -f /var/lib/archsetup/apply-upgrade-on-boot (the + runs it as root regardless of User=); ExecStart never touches the flag, so a timeout kill, SIGKILL, or power loss can't leave it armed."
+
+In Decision 7 L149, change "at the end of its attempt" to "at the start of its attempt". This is the same one-shot intent, and it now holds through a timeout.
+
+In L69 or L178, state that the flag is root-owned and that the panel's arm action creates it via sudo.
+
+In Phase 3 L161, add a forced-timeout case to the manual boot test: a short TimeoutStartSec plus a hung step, then check that the flag is gone, the next boot skips the unit, and Hyprland starts.
+
+If removal at the end is kept instead, move it to ExecStopPost=+/usr/bin/rm -f <flag> (that covers timeouts and failures, but not power loss).
+
+Should-fix, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: modified, folded into Design, Decisions, Implementation phases, Readiness dimensions, Testing. Partially confirmed; this follows the narrowing and my disarm-at-start call, with two reconciliations.
+- The flag now carries the package list (F09), so ExecStartPre moves it rather than deleting it.
+- maint never writes the flag (F07), so 'the panel's arm action creates it via sudo' becomes 'upgrade-guarded creates it via sudo'.
+:EVIDENCE:
+Spec L69: "Its ExecStart runs, as the user: informant read ..., then topgrade --only system ..., then maint stamp topgrade on success, then removes the flag unconditionally". L161: "ExecStart runs the script's --complete form as the user and removes the flag unconditionally". L149: "a persistent flag (/var/lib/archsetup/) that the boot unit removes unconditionally at the end of its attempt — success or failure disarms". L171: "A boot-upgrade failure (a failed step, a timeout, an aborted transaction) never blocks the session ... the flag is cleared".
+
+man systemd.service:
+- TimeoutStartSec: "the service will be considered failed and will be shut down again"
+- TimeoutStartFailureMode: the default "terminate" sends KillSignal (SIGTERM)
+- ExecStopPost: "commands specified with this setting are invoked when a service failed to start up correctly and is shut down again ... recommended ... for clean-up operations"
+
+Live repro: bash -c "sleep 30; rm -f flag", SIGTERM to the child and the shell, exit=143, and the flag still exists.
+
+Ownership: ls -ld /var/lib/archsetup gives drwxr-xr-x root (root-owned, 0755); the installer uses it at archsetup:257 (state_dir="/var/lib/archsetup/state").
+
+Guard backstop: scripts/hypr-live-update-guard:57-67 (hyprland_running uses pgrep -x Hyprland; it exits 0 only when Hyprland isn't running) blocks a live re-run.
+
+The panel paths exist as cited: maint/src/maint/probes/systemd.py:70-100 (failed_units WARN) and remedies.py:323-337 (unit_restart/unit_reset).
+:END:
+
+** DONE Deferred row severity, lever, band and bar impact are undefined
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Decision 4 L128; Decision 5 consequences L136; Phase 2 L158; ACs L166-174
+
+The spec only says the panel renders 'N deferred — apply on reboot' 'as its own row'. It gives no metric id, category or band, severity rule, lever or evidence shape. It also concedes that the count 'carries a kernel most days'.
+
+Risk: A WARN without a lever turns the waybar glyph gold almost every day, which just moves the 'permanently stale' symptom from the Summary (L28) somewhere else. A WARN with a lever adds a permanent ATTN. An OK never escalates the hold. The implementer has to invent all of this.
+
+Recommended change: Add two sentences to the end of Phase 2 (L158):
+
+"The row is metric upgrade_deferred, category updates (so it lands in the PACKAGES band). Its value is the deferred count and its evidence is the deferred rows (name, old, new). It grades OK when the set is empty and [WARN | OK — owner picks] when the set is non-empty. Phase 3's apply action is its lever, registered always:True so the action shows at any severity. A levered row never colours the waybar glyph. Age-based escalation stays the L197 follow-up."
+
+If WARN is chosen, also say that the row must not ship before its lever. Otherwise, in the window between Phase 2 and Phase 3, a lever-less WARN colours the glyph every day. Either land the row and the apply action in the same commit, or grade the row OK until Phase 3.
+
+Add one AC after L167: "With a non-empty deferred set and Hyprland live, the panel's deferred row offers the apply action and the waybar glyph colour is unchanged by the deferral."
+
+Should-fix, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: modified, folded into Implementation phases, Acceptance criteria. Partially confirmed; this follows the narrowing. The open WARN-or-OK choice is resolved as OK for a plain deferral. The kernel is held most days, so WARN on mere presence would colour the glyph daily, which just moves the original stale-metric symptom onto another row; topgrade_age already carries the cadence nag.
+
+WARN is kept for states that need action (F10, F29, F36), and CRIT for a gate failure (F12). Because WARN is still reachable, the row ships in the same commit as its lever.
+:EVIDENCE:
+Spec L128: the deferred set is rendered "as its own state ('6 deferred — apply on reboot')". L158 (Phase 2): "A new probe reads the deferred-set state file and the panel renders 'N deferred — apply on reboot' as its own row". There is no severity, and no AC between L166 and L174 covers it.
+
+The lever does exist in the spec. L67 says "from the panel's action or upgrade-guarded --complete". L161 says "The panel action installs the held-kernel set live, writes the persistent arm flag, and offers to reboot." Escalation is explicitly deferred at L197: "a stale-kernel age in maint is a possible follow-up."
+
+Code:
+- ~/.dotfiles/maint/src/maint/remedies.py:535-545 (attach_levers): a lever is attached when the metric is WARN/CRIT, or when the remedy is marked "always".
+- remedies.py:294-312: the update and topgrade remedies are always:True. updates_repo is graded by grade_high(len, pending_warn=50) in probes/updates.py:53-61, so it reads OK below 50 with an always-lever.
+- ~/.dotfiles/maint/src/maint/indicator.py:41-57: diag is the metrics without levers, and only diag colours the glyph. Levered off-nominal metrics count as "actionable".
+- ~/.dotfiles/maint/src/maint/panel.py:36-47 (BAND_BY_CATEGORY maps updates to packages), 449-452 (band_lamp is the worst severity), 485-489 (attn_count counts every WARN/CRIT).
+
+Live data: ~/.local/state/maint/updates_repo.json (written 04:55 today, 395 rows) contains hyprland, linux, linux-headers, linux-lts, linux-lts-headers, mesa and vulkan-radeon. hyprland, mesa and vulkan-radeon match the /etc/pacman.d/hooks/hypr-live-update-guard.hook Target lines. vulkan-icd-loader and vulkan-mesa-implicit-layers are pending too, but they are not guard targets.
+:END:
+
+** DONE topgrade_age and the deferred row double-nag, and the TOPGRADE lever can't clear its metric
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Decision 4 L128; Decision 5 L136; Phase 2 L158; AC L170
+
+Decision 4 says a deferral renders 'rather than as stale freshness', but topgrade_age still counts days since the last stamp. With the kernel held on every everyday run, a stamp happens only after a dedicated session.
+
+Risk: Once the stamp is honest (see the stamp-writer finding), topgrade_age turns WARN 14 days after the last dedicated session, right next to the deferred row: two off-nominal rows for one fact. Pressing TOPGRADE runs a split run that can't clear it, and the wall reports the successful run as still warn.
+
+Recommended change: Add one sentence to Phase 2 (L158). Do not cap topgrade_age, because that conflicts with Decision 1.
+
+Suggested sentence: "topgrade_age grading is unchanged: per Decision 1 it ages from the last full-current stamp while anything is deferred. The deferred row is informational, carries the count, and hosts the apply-on-reboot / --complete action. While the deferred set is non-empty, the topgrade_age row offers that action instead of TOPGRADE, and the TOPGRADE wall note says 'N deferred — freshness clears on --complete' rather than a bare 're-probed topgrade_age: warn'."
+
+Add a matching acceptance criterion: "With only a kernel held, a successful TOPGRADE press leaves topgrade_age aging, and the wall names the deferred set and the --complete remedy."
+
+Should-fix, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: accepted, folded into Implementation phases, Acceptance criteria.
+:EVIDENCE:
+- Spec L107 (Decision 1): "Freshness stays stale while a guarded upgrade is genuinely un-applied."
+- Spec L77 (Alternative A): rejected because "a topgrade that was blocked from applying a real upgrade would read as 'fresh'."
+- Spec L128 (Decision 4): stamps only when the deferred set is empty; the deferred set is its own row; "Freshness keeps meaning 'current'."
+- Spec L136: "the panel's deferred count carries a kernel most days."
+- Spec L158 (Phase 2): no change to topgrade_age grading or to which lever hangs off it.
+- Spec L161 (Phase 3): the apply-on-reboot action is not bound to any metric.
+- Spec L197: a cadence nag for the kernel deferral is wanted.
+- maint/src/maint/probes/updates.py:114-131: grades days since topgrade_run against topgrade_warn_days.
+- configs/maintenance-thresholds.toml:60: topgrade_warn_days = 14.
+- maint/src/maint/remedies.py:303-312: the TOPGRADE lever has metric_ids and re_probe_ids ["topgrade_age"] and "always": True.
+- maint/src/maint/remedies.py:537-546: attach_levers shows "always" levers at any severity.
+- maint/src/maint/doctor.py:169-170: stamps topgrade_run on any successful rid=="topgrade" run; Phase 2 does not remove this, so the "can't clear" scenario only exists after the separate stamp-writer fix.
+- hyprland/.local/bin/topgrade: the wrapper also stamps on rc 0.
+- maint/src/maint/doctor.py:342-350: the re-probe note goes to the wall.
+:END:
+
+** DONE Kernel-set rule sweeps in firmware and api-headers, and ratio has three kernels
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Phase 1 L155 ('every linux* kernel package and its -headers'); Design L65; Decision 5 L134-135; Risks L196
+
+The kernel set is 'every linux* kernel package and its -headers', 'moved as a set, never one without the other'. Ratio is described as 'btrfs root, two kernels'.
+
+Risk: A literal glob holds linux-api-headers and all firmware, GPU firmware included, until the dedicated session, and inflates the 'N deferred' count. A substring match would hold archlinux-keyring indefinitely and start breaking signature checks. 'Never one without the other' can't hold for strix, and --complete can't pacman-install a locally built kernel, so the implementer has to invent the policy.
+
+Recommended change: L155: replace "(every =linux*= kernel package and its =-headers=)" with "(the packages owning =/usr/lib/modules/*/vmlinuz=, via =pacman -Qqo=, plus =<pkgbase>-headers= where that package is installed; =linux-firmware*=, =linux-api-headers= and other =linux*= names are not kernels)". Add: "the apply step moves only the pending members of the kernel set; a foreign kernel never appears in checkupdates and is left untouched."
+
+In the same paragraph, narrow the gate to match L135: "for each kernel version installed by this run" instead of "for each kernel under =/usr/lib/modules=". As written, the gate fails permanently on any kernel with no headers and no DKMS build.
+
+L134 and L196: change "two kernels" to "three kernels, one a locally built foreign package with no =-headers=".
+
+Phase 1 tests: add a ratio-shaped fixture with three vmlinuz owners, one of them foreign and headerless, linux-firmware* and linux-api-headers installed, and zfs built for two of the three kernels. Assert that firmware is never held and that the gate ignores kernels this run did not touch.
+
+Should-fix, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: accepted, folded into Design, Decisions, Implementation phases, Testing.
+:EVIDENCE:
+Spec text:
+- L155: "The kernel set is derived from what is installed (every =linux*= kernel package and its =-headers=)"
+- L155 (gate): "for each kernel under =/usr/lib/modules=, =dkms status= reports every registered module =installed= for it"
+- L65 and L135: "every installed kernel with its =-headers=" and "moved as a set, never one without the other"
+- L135 (gate): "for the new kernel version"
+- L134 and L196: "Ratio (btrfs root, two kernels ..."
+
+Live checks on ratio (uname -n = ratio):
+- pacman -Qq | grep -c '^linux' returns 24: linux, linux-api-headers, linux-firmware plus 17 linux-firmware-* split packages (amdgpu among them), linux-headers, linux-lts, linux-lts-headers, linux-lts-strix.
+- pacman -Qoq /usr/lib/modules/*/vmlinuz returns linux-lts-strix, linux-lts, linux. Each /usr/lib/modules/*/pkgbase file matches its owner.
+- pacman -Qm lists linux-lts-strix 6.18.25-1. pacman -Qi shows Packager "Unknown Packager". pacman -Si linux-lts-strix gives "error: package 'linux-lts-strix' was not found".
+- pacman -Qq | grep -- '-headers$' returns linux-api-headers, linux-headers, linux-lts-headers. There is no strix headers package.
+- dkms status shows "zfs/2.4.4, 6.18.54-1-lts, x86_64: installed" and "zfs/2.4.4, 7.2.7-arch1-1, x86_64: installed". Nothing is built for 6.18.25-1-lts-strix.
+- findmnt / shows btrfs, which confirms the btrfs-root part of L134.
+- checkupdates lists linux, linux-headers, linux-lts and linux-lts-headers as pending. No firmware and no linux-api-headers are pending today.
+:END:
+
+** DONE The gate's snapshot and initramfs checks pass vacuously
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Phase 1 L155 (kernel-modules-check); Decision 5 L135; AC L169; Risks L196
+
+The gate passes if each kernel's 'initramfs is newer than its vmlinuz' (each kernel under /usr/lib/modules) and, 'on a ZFS root, a pre-pacman_ snapshot of the root dataset exists'. L196 says the 05-zfs-snapshot hook snapshots before every transaction.
+
+Risk: Neither check can catch the failure it exists for. One is a snapshot that wasn't taken for this kernel change (skipped, or failed on a full pool, one of the DKMS triggers the spec lists); rolling back to an older retained snapshot then undoes more than the kernel transaction. The other is a stale initramfs beside a new kernel, which lets the gate wave through an unbootable reboot on velox. The 'fails on snapshot listings' test covers a state that never occurs.
+
+Recommended change: Phase 1, L155. Replace the gate sentence with:
+
+"The script records T0 before the kernel transaction. For each kernel, with pkgbase read from /usr/lib/modules/<ver>/pkgbase: dkms status reports every registered module =installed= for <ver>, and /boot/initramfs-<pkgbase>.img is newer than /boot/vmlinuz-<pkgbase>. For a kernel this run changed, the image must also be newer than T0. Never compare against /usr/lib/modules/<ver>/vmlinuz, whose mtime is the package build date. On a ZFS root, a =pre-pacman_= snapshot of the root dataset (findmnt -no SOURCE /) has a creation time of at least T0 minus 60s (zfs-pre-snapshot's MIN_INTERVAL). An older retained snapshot does not satisfy this."
+
+Phase 1 tests. Replace the snapshot and timestamp fixtures with:
+- snapshot listings where only pre-T0 snapshots exist (gate fails) and one is newer than T0 (gate passes)
+- an initramfs newer than the module-tree vmlinuz but older than T0 (gate fails)
+
+L196. Change "snapshots it before every transaction" to "snapshots it before each transaction (skipped within 60s of the previous snapshot; a failed snapshot only warns and does not abort)".
+
+Optional: on a ZFS root, also check with lsinitcpio that the image contains zfs.ko.
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: modified, folded into Decisions, Implementation phases, Risks, Testing. Confirmed, with the finding's corrections.
+- The gate finds the root dataset itself with findmnt. zfs-pre-snapshot hardcodes $POOL/ROOT/default, so a mismatch fails closed and the failure names the dataset.
+- The freshness and snapshot requirements apply only when the check list is non-empty, so a --complete that moves no kernel isn't failed for want of a snapshot.
+
+The optional lsinitcpio check is made required on a ZFS root. mkinitcpio writes an image without zfs.ko and only warns, and that image is exactly the velox failure.
+:EVIDENCE:
+Spec:
+- L135 (Decision 5): "the initramfs is newer than the kernel image, and on a ZFS root a pre-pacman snapshot exists"
+- L155 (Phase 1): "for each kernel under =/usr/lib/modules= ... its initramfs is newer than its =vmlinuz=; on a ZFS root, a =pre-pacman_= snapshot of the root dataset exists"
+- Phase 1 tests: "fails on ... snapshot listings"
+- L169: "the pre-pacman snapshot it required"
+- L196: "the =05-zfs-snapshot= hook snapshots it before every transaction"
+
+scripts/zfs-pre-snapshot:
+- :10-11 DATASET="${ZFS_PRE_DATASET:-$POOL/ROOT/default}", hardcoded, no findmnt
+- :13, :19-25 return early when the lock file is under MIN_INTERVAL=60s old
+- :14, :35-40 KEEP=10 prune
+- :30, :41-43 on failure, =echo "Warning: Failed to create snapshot"= with exit status 0
+
+archsetup:
+- :2421-2433 the 05-zfs-snapshot.hook heredoc has no AbortOnFail
+- :881-884 is_zfs_root uses =findmnt -n -o FSTYPE /= only
+
+Live system on ratio:
+- =pacman -Qi linux-lts= shows Build Date Fri 25 Sep 2026 11:39:08 and Install Date Tue 29 Sep 2026 08:15:55
+- /usr/lib/modules/6.18.54-1-lts/vmlinuz has mtime 2026-09-25 11:39:08, the build date
+- /boot/vmlinuz-linux-lts is 2026-09-29 08:17:18.09 and /boot/initramfs-linux-lts.img is 08:17:29
+- /usr/lib/modules/7.2.7-arch1-1/vmlinuz is 2026-09-21 13:51:14, matching linux's Build Date
+- /usr/share/libalpm/hooks/90-mkinitcpio-install.hook triggers on usr/lib/firmware/*, usr/lib/systemd/systemd, usr/bin/cryptsetup, usr/lib/modprobe.d/ and other paths
+- /usr/share/libalpm/scripts/mkinitcpio:148-151 runs =install -Dm644 -- "${line}" "${kernel}"= into /boot/vmlinuz-${pkgbase} before the preset build
+- /usr/lib/modules/*/pkgbase gives linux-lts-strix, linux-lts and linux
+:END:
+
+** DONE Ratio's guard hook still has the pre-migration name, and the missing-hook case is unspecified
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Problem L34; Design L65; Decision 3 L121; Phase 1 L155; Phase 2 L158; Phase 4 L164
+
+The spec names /etc/pacman.d/hooks/10-hypr-live-update-guard.hook as the installed hook the script reads, and relies on it as the backstop for a bare pacman -Syu. Phase 4's rollout installs only the new unit on existing machines.
+
+Risk: A script that hardcodes the 10- path finds no hook on ratio and computes an empty blocked set. The old-named guard still fires, so the live transaction aborts, and the Phase 2 equality test misreads or errors. On a machine with no hook at all, reading 'no patterns' would live-swap mesa with no backstop. Separately, on ratio today, a bare =pacman -Syu= under Hyprland with a kernel and a guarded lib both pending deletes /boot/vmlinuz and the initramfs for linux and linux-lts, then aborts, leaving those boot entries without images.
+
+Recommended change: Phase 1 (L155): after "blocked set (hook Target lines, version-aware)", add: "The hook is read from /etc/pacman.d/hooks/10-hypr-live-update-guard.hook. If that file is absent, or no Target lines parse from it, the live run refuses: it exits non-zero and names the path, and never computes an empty blocked set. This is deliberately unlike maint's guard.trips, which treats missing patterns as 'can't trip'." Add a matching test to the Phase 1 list: "a missing or Target-less hook fails closed and runs no pacman transaction."
+
+Phase 4 (L164): add: "The one-time install on existing machines also migrates the guard hook to 10-hypr-live-update-guard.hook and removes the unprefixed hypr-live-update-guard.hook (ratio still has the legacy name; velox's was hand-placed under it). The unprefixed name sorts after 60-mkinitcpio-remove, so a blocked bare pacman -Syu can delete the initramfs before the guard aborts. Confirm with ls /etc/pacman.d/hooks/ on both machines."
+
+Optional: add a post-rebuild-check assertion that the 10- name exists and the unprefixed name does not.
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: modified, folded into Design, Implementation phases, Testing. Confirmed. Under the fail-closed rule the script refuses on any machine that still carries the legacy hook name. Ratio does today (only hypr-live-update-guard.hook is in /etc/pacman.d/hooks), and velox's was hand-placed under it. So the migration can't wait for Phase 4; it moves to checklist step (a), before that machine's levers route through the script. The optional post-rebuild-check assertion is adopted.
+:EVIDENCE:
+- Live ratio: ls /etc/pacman.d/hooks/ shows only 99-grub-sync-efi.hook and hypr-live-update-guard.hook (543 bytes, 4 Jul, same content as the installer heredoc). No 10-hypr-live-update-guard.hook.
+- archsetup:2620-2625 migrates the name ("rm -f /etc/pacman.d/hooks/hypr-live-update-guard.hook", then writes 10-hypr-live-update-guard.hook). The comment at 2622-2624 says the guard must run before 60-mkinitcpio-remove. Commit be2277d (2026-07-18, "fix: order pacman safety hooks") added this.
+- archsetup:41-75: the arguments are only --config-file, --fresh, --status and similar flags. There is no way to run a single step, so updating an existing machine means placing files by hand.
+- /usr/share/libalpm/hooks/60-mkinitcpio-remove.hook is a PreTransaction hook on Path Remove usr/lib/modules/*/vmlinuz. man alpm-hooks says hooks run "in alphabetical order of their file name", so "hypr-live-update-guard" sorts after "60-mkinitcpio-remove". AbortOnFail aborts the transaction. pacman -Q shows linux 7.2.7 and linux-lts 6.18.54, both with headers.
+- tests/installer-steps/test_pacman_hook_order.py:54-76 checks the ordering and the cleanup, but only against the installer source, not a live machine. scripts/testing/tests/test_desktop.py:59 asserts the 10- path, but only in the VM harness. scripts/post-rebuild-check has no hook assertion (grep returned nothing).
+- ~/.dotfiles/maint/src/maint/guard.py:26-30 (trips) is the fail-open precedent: "Tolerant of a missing key — no patterns means the guard can't trip".
+- todo.org:3391-3396: the hook was placed on velox by hand as /etc/pacman.d/hooks/hypr-live-update-guard.hook on 2026-06-28.
+- Spec: L34 names the 10- path. L65 says the blocked set is "read from the installed hook's Target lines" and that the hook stays "the backstop for a bare pacman -Syu". L155 says "hook Target lines, version-aware" and lists no missing-hook test. L164 says "existing machines need the one-time install", with no hook migration. Grepping the spec for absent or missing finds them only for topgrade_run and the arm flag, never for the hook.
+:END:
+
+** DONE maint's guard_patterns is not a mirror of the hook, and the pin test is in the wrong repo
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Decision 3 L120-121; Phase 2 L158; Reuse L183
+
+The spec calls maint's =[updates] guard_patterns= its own copy of the hook's trigger list. It adds a Phase 2 test under the maint harness asserting that the TOML equals the installed hook's Target list.
+
+Risk: The equality test fails on its first run, and the spec doesn't say which side gets reconciled. A dotfiles unit test that reads /etc and ~/.config depends on the host and on how stale its installed copies are. Rewriting the TOML drops hyprlang and hyprcursor, which the --noconfirm closure finding shows belong to the soname family, and the badge under-predicts the deferral.
+
+Recommended change: Edits by location:
+
+- L120: replace "its own copy of the trigger list" with "its own, divergent pattern list".
+- L158: replace the test sentence with: "Rewrite archsetup configs/maintenance-thresholds.toml [updates] guard_patterns to exactly the hook heredoc's 15 Target patterns (the hook is the owner and stays unchanged). This drops hyprland-*, hyprlang, hyprcursor, *wayland*, wlroots*, lib32-mesa*, lib32-vulkan-radeon and lib32-vulkan-intel, and adds wayland, libdrm, libglvnd, vulkan-mesa-layers, nvidia-utils, lib32-nvidia-utils and xorg-xwayland. Update the TOML comment that still describes press-again arming. The pin test lives in archsetup tests/installer-steps/ and compares the sorted seed-TOML guard_patterns with the Target lines extracted from the hook heredoc in the archsetup script (the test_pacman_hook_order.py idiom). It reads neither /etc nor ~/.config."
+- L183: align with the L158 change.
+- L188 and Phase 4: note that existing machines need install_maintenance_thresholds re-run to pick up the new TOML.
+- L187 and L200: fix the phase numbering so each phase's tests are in one consistent place.
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: modified, folded into Decisions, Implementation phases, Readiness dimensions, Testing. Confirmed. Both files the change touches, configs/maintenance-thresholds.toml and the hook heredoc, belong to archsetup. The rewrite and the pin test therefore land in the Phase 1 archsetup commit, not in dotfiles Phase 2. The rewritten TOML has to reach existing machines, so the re-copy at checklist step (a) is mandatory, overriding F30's 'only if Phase 2 adds keys'.
+:EVIDENCE:
+- Spec L120: "maint already carries its own copy of the trigger list as [updates] guard_patterns".
+- Spec L121: "the TOML patterns stay as the panel's display-side mirror ... and gain a test asserting they match the hook".
+- Spec L158: "A test asserts the TOML guard_patterns equal the installed hook's Target list. Tests under the maint fake harness."
+- Spec L183 repeats the claim. L187 ("installer-step pytest for Phase 2") and L200 ("Phase-2 install under tests/installer-steps/") contradict L158.
+- configs/maintenance-thresholds.toml:65-72: mesa, mesa-*, lib32-mesa*, hyprland, hyprland-*, aquamarine, hyprutils, hyprlang, hyprcursor, hyprgraphics, *wayland*, wlroots*, vulkan-radeon, lib32-vulkan-radeon, vulkan-intel, lib32-vulkan-intel. The file is byte-identical to ~/.config/archsetup/maintenance-thresholds.toml (diff clean).
+- archsetup:2629-2643, the hook heredoc's 15 Target lines: mesa, mesa-*, wayland, libdrm, libglvnd, hyprland, aquamarine, hyprutils, hyprgraphics, vulkan-radeon, vulkan-intel, vulkan-mesa-layers, nvidia-utils, lib32-nvidia-utils, xorg-xwayland.
+- The installed hook's Target list matches the heredoc, but it sits at /etc/pacman.d/hooks/hypr-live-update-guard.hook. /etc/pacman.d/hooks/10-hypr-live-update-guard.hook does not exist on ratio (cat: No such file or directory).
+- archsetup:1640-1650: install_maintenance_thresholds copies the seed TOML to ~/.config/archsetup.
+- dotfiles maint/src/maint/thresholds.py:3-4: the TOML is "owned and installed by archsetup".
+- thresholds.py:18-19: env overrides "so tests and fixtures never touch real files".
+- thresholds.py:31-33: shipped_path.
+- maint/src/maint/guard.py:29: maint reads guard_patterns via fnmatchcase.
+- tests/maint/test_remedies_doctor.py:31-60 uses an inline SHIPPED_TOML. No dotfiles maint test references the archsetup checkout. Every test sets MAINT_THRESHOLDS to a temp file.
+- tests/installer-steps/test_pacman_hook_order.py already extracts hook data from the archsetup script source.
+:END:
+
+** DONE maint's guard refusal and press-again arm UX are left in place
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Phase 2 L158; Design 'For the user' L67; AC L167; Decision 3 L121
+
+Phase 2 removes only 'the press-again-to-force sentinel wrap'. Design and the AC say UPDATE/TOPGRADE run the script and succeed with a guarded library pending. The 'will defer' badge is unspecified.
+
+Risk: If only the wrap is removed, the panel still refuses on exactly the guarded days the script exists for, and AC L167 fails. The second press still says the run swaps live, which is no longer true. The badge under-predicts the deferral because it omits the kernel set.
+
+Recommended change: Replace the Phase 2 sentence "the press-again-to-force sentinel wrap goes away (the driven path never trips the guard)" with: "Drop guard: live_update from the update and topgrade remedies. iter_fix no longer refuses them. The --force sentinel wrap, the GUI's _update_force override, and the CLI --force meaning for these two go with it. Rewrite the tests that pin the tag, the refusal, and the wrap. guard.trips stays only as the arm-line annotation that Decision 3 calls the display-side mirror: on a tripped read, arm_line, _rearm_after_guard's text, and the doctor review suffix read 'UPDATE armed — will defer <guard matches> (kernels are always held) — press again to run $ upgrade-guarded', and nothing says live apply or REBOOT required." Don't have maint compute the pending kernel set itself. The post-run "N deferred" row, built from the script's state file, gives the exact count.
+
+Should-fix, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: modified, folded into Implementation phases, Testing. Partially confirmed; this follows the narrowing that maint does no kernel computation of its own. The arm line also names the DKMS set (F04), and it says 'at least', because the closure can hold more than the pattern match predicts.
+:EVIDENCE:
+Spec L158 (Phase 2): "UPDATE and TOPGRADE levers change their argv to the script; the press-again-to-force sentinel wrap goes away (the driven path never trips the guard)." Spec L121 (Decision 3): "the TOML patterns stay as the panel's display-side mirror (the badge that says a run will defer)". Spec L167 (AC): "UPDATE applies everything else, exits 0 ... the guard hook does not fire."
+
+~/.dotfiles/maint/src/maint/remedies.py:298 and :309: both update and topgrade carry "guard": "live_update".
+
+~/.dotfiles/maint/src/maint/doctor.py iter_fix: =if not dry_run and r.get("guard") == "live_update" and not force:= runs guard.trips(pending, th) and yields a "guard" event ("press again to apply live (REBOOT required after), or apply from a TTY"), then returns before any step. The wrap is a separate later line: =wrapped = bool(force and r.get("guard") == "live_update" and not dry_run)= -> _all_steps adds guard_sentinel_set/clear. The review suffix (doctor.py ~L415-421) reads "[live-update guard: ... — arms in the GUI; --force on the CLI]".
+
+viewmodel.py:99-107 arm_line(guard=...) gives "press again to apply live (REBOOT required after), or apply from a TTY".
+
+gui.py:1396-1405 _guard_arm_line sets self._update_force = bool(info["tripped"]). gui.py:1430-1437 fires with force=self._update_force for guarded rids, so the GUI bypasses the refusal on a warm tripped read. gui.py:1518-1535 _rearm_after_guard repeats the live-apply/REBOOT wording.
+
+panel.py:224-237: guarded()/update_guard() key on the same tag.
+
+cli.py:265-267: --force help "run a guarded remedy despite the live-update guard (the CLI's press-again)".
+
+Tests pinning the current behavior: tests/maint/test_remedies_doctor.py:437-438 (assert guard == "live_update" on both), :843-1003 (refusal, force, sentinel order); tests/maint/test_panel_levers.py:404 (asserts "press again to apply live (REBOOT required after)").
+
+configs/maintenance-thresholds.toml:65-72: guard_patterns has no linux*/kernel entries, so a guard-only badge omits the held kernel set the script always defers.
+:END:
+
+** DONE Deferred-set vocabulary, storage and arm-flag contents are undefined across repos
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Design L65, L67; Scope tiers L57; Decision 4 L128; Decision 6 L142; Phase 1 L155; Phase 2 L158; Phase 3 L161; Readiness Data model L178
+
+'blocked set' includes the kernel set in Design (=--ignore=<blocked set>=) but excludes it in Phase 1 ('blocked set ∪ held-kernel set'). The spec also uses 'held set', 'held-kernel set', 'guarded set' and 'GPU/compositor set'. Storage is called 'a state file' (L65, L155, L158) in some places and 'its own cache key' (L128) in another. Phase 1 (archsetup) writes the record and Phase 2 (dotfiles) reads it, but neither names the path, schema or writer. Data model (L178) lists only the arm flag and topgrade_run. The arm flag's contents (which packages the boot run applies) are unspecified, and no phase says that --complete or the boot run rewrites the record. The badge reads 'apply on reboot' even though the kernel portion lands live.
+
+Risk: Phases 1 and 2 land in different repos and sessions, so the implementers will invent incompatible paths and formats. A mismatch shows no deferred row and fails silently, because the 'state file round-trips' test only checks the script against itself. A sudo-invoked run writes to /root/.local/state/maint, which the panel never reads. The boot run has no defined source for which set to apply, and a record that is never cleared leaves a permanent 'N deferred' badge.
+
+Recommended change: 1. Definitions. Add one sentence to Design after the L65 set computation: "GPU/compositor set = pending hook Targets whose on-disk version changes, computed only when a compositor is live; kernel set = every installed linux* kernel with its -headers, held on every everyday run whether or not a compositor is live; deferred set = their union, which is exactly the --ignore list." Then:
+- In L65, change "--ignore=<blocked set>" to the deferred set.
+- In L155, rewrite "blocked set (...) ∪ held-kernel set when a compositor is live" as "GPU/compositor set (hook Target lines, version-aware, compositor live only) ∪ kernel set (always)".
+- Replace "held set" (L57), "held-kernel set" (L55, L161) and "guarded set" (L65, L143) with the defined terms.
+
+2. Name the record. In Decision 4 (L128), and as a new line in Data model (L178), add: "maint cache key upgrade_deferred: ~/.local/state/maint/upgrade_deferred.json (MAINT_STATE_DIR honoured), in cache.put's {written_at, data} envelope, data = {packages: [{name, old, new, kind: gpu|kernel}]}. upgrade-guarded is its only writer and runs as the user. It rewrites the record at the end of every mode (everyday, --complete, boot), with an empty list when nothing is deferred. maint's new probe is its only reader."
+
+3. Boot source. In Decision 6 (L142) and Phase 3 (L161), add: "the boot form applies the record's kind=gpu entries and never the kernel ones; the arm flag stays a presence-only file."
+
+4. Tests. Make Phase 1's "state file round-trips" test read the record in the envelope maint's cache.get expects, and point Phase 2's probe test at the same fixture JSON.
+
+Do not rename the badge, add SUDO_USER handling, or add gate/armed/boot fields to the schema.
+
+Should-fix, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: modified, folded into Design, Decisions, Implementation phases, Readiness dimensions, Testing. Partially confirmed. This adopts the vocabulary and the named record, with two reconciliations.
+- My boot-source call puts the exact name=version list in the root-owned arm flag. The flag isn't presence-only: it is the boot form's source, and the record is the panel's.
+- Accepted findings F10, F12, F29 and F39 each need a durable field (interrupted, gate, AUR failure, news), so the schema carries result, failed_step, detail, gate and news.
+
+Arm state is still not stored, because the probe reads the flag.
+:EVIDENCE:
+Spec:
+- L65: "computes the *blocked set* = the guard's own trigger list ... plus the *kernel set* ...; runs pacman -Syu --noconfirm --ignore=<blocked set> ... It writes the deferred set to a state file the panel reads".
+- L155: "blocked set (hook Target lines, version-aware) ∪ held-kernel set when a compositor is live → ... deferred set written to a state file". Its tests: "the --ignore list is exactly blocked ∪ kernel set; the state file round-trips".
+- L128: "writes the deferred set to its own cache key"; L129: "one more cache key and one more probe in maint".
+- L158: "A new probe reads the deferred-set state file".
+- L178: Data model lists only the arm flag and the topgrade_run key.
+- L69: the arm flag is "a file on a non-tmpfs path".
+- L142: the boot run is "scoped to the deferred GPU/compositor set ... no kernel".
+- L155: --complete is "apply the kernel set live, run the gate, then arm ... or, with no compositor live, apply it directly".
+- L171, L181: the panel clears or still shows pending work after boot.
+- L135, L168: the kernel is held on every everyday run, including a TTY run.
+- L57: "held set". L143: "guarded set".
+
+Code:
+- ~/.dotfiles/maint/src/maint/cache.py:15-42: _dir() is MAINT_STATE_DIR or ~/.local/state/maint; put() writes {"written_at", "data"} to <name>.json atomically; get() returns (data, age).
+- cli.py:294: p_stamp choices=["topgrade"]. cli.py:228-231: cmd_stamp does cache.put(f"{what}_run", {"at": ...}).
+- remedies.py:294-311: the update and topgrade remedies are kind "user", not root.
+
+Adjacent defect, not part of this finding (flag separately): which -a topgrade resolves ~/.local/bin/topgrade first. That wrapper stamps topgrade_run on any rc 0 (wrapper L40-42), and doctor.py:169-170 also stamps on any exit-0 TOPGRADE lever. upgrade-guarded's own topgrade --disable system,... call, and a lever whose argv is the script (which exits 0 while deferring, per L65), would therefore stamp freshness on a deferring run, contradicting Decision 4 (L128).
+
+Also noticed: on ratio the installed hook is /etc/pacman.d/hooks/hypr-live-update-guard.hook, not 10-hypr-...; the installer renames it at archsetup:2621-2625. That affects "read the installed hook", not this finding.
+:END:
+
+** DONE Boot run is invisible on ratio's screen, and its outcome is never surfaced
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Design L67 ('runs in the console'); Decision 7 L149; AC L171; Readiness Errors L179, Observability L181, Performance L182, Config surface L185
+
+The spec assumes the boot run's output appears on the console and that failures are 'named', but it sets no StandardOutput or TTYPath. The flag is removed whatever the outcome, and success or failure lives only in the unit status and the journal. Nothing records the outcome where the panel reads it. AC L171 only requires that 'the panel still shows the pending work', and after a failed run the panel shows the same 'N deferred' as before arming.
+
+Risk: On ratio, a multi-minute armed boot shows a blank tty1, which invites a hard power-cycle mid-transaction (see the timeout finding). Any failure lives only in the journal. The panel gives no sign that the one-shot arm was spent, so the user either re-arms blind or never re-arms.
+
+Recommended change: This replaces the finding's larger proposal: no new outcome field and no "last boot apply failed" panel line.
+
+1. Phase 3 (L161) and Observability (L181). Name the unit's I/O: StandardInput=null, StandardOutput=tty, TTYPath=/dev/tty1. Not journal+console, because on ratio /dev/console is ttyS0. The script mirrors its output to the journal (for example through systemd-cat). Its first line is a banner on tty1: "applying N deferred GPU/compositor upgrades — do not power off".
+
+2. L69 and L161. Move the unconditional flag removal out of ExecStart into ExecStopPost=/usr/bin/rm -f <flag>. ExecStopPost runs after success, failure, and a TimeoutStartSec kill, and ExecStart's exit then carries the upgrade result. A failed or timed-out run leaves archsetup-boot-upgrade.service failed, and maint's existing failed_units row names it with its journalctl hint in the session that follows.
+
+3. AC L171. Append: "and a failed or timed-out run appears in maint's failed-units row as archsetup-boot-upgrade.service".
+
+4. The Phase 3 manual boot test. Add a check that the banner and progress are visible on ratio's monitor during the armed boot.
+
+Should-fix, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: modified, folded into Design, Implementation phases, Acceptance criteria, Readiness dimensions, Testing. Partially confirmed; this follows the narrowing: no new outcome field, and maint's failed_units row is the surface. The proposed ExecStopPost disarm gives way to my ExecStartPre call. That keeps the property the finding wanted: ExecStart no longer ends in an rm, so its exit status is the unit's result, and failures and timeouts leave the unit failed.
+:EVIDENCE:
+Visibility half, all confirmed on ratio:
+- uname -n is ratio.
+- systemctl show reports DefaultStandardOutput=journal.
+- /proc/consoles shows "ttyS0 -W- (EC p a)" (C = preferred, the /dev/console target) and "tty0 -WU (E p )".
+- /etc/default/grub:6 has GRUB_CMDLINE_LINUX="console=tty0 console=ttyS0,115200", with quiet splash loglevel=2 at :5. /proc/cmdline matches.
+- pacman -Q plymouth: package not found.
+- /etc/systemd/system/getty@tty1.service.d/ does not exist (no autologin on ratio). systemctl cat getty@tty1 shows TTYReset=yes and TTYVTDisallocate=yes (getty@.service file lines 42 and 44).
+- ~/.dotfiles/hyprland/.profile.d/99-hyprland-autostart.sh:15 runs clear before start-hyprland.
+- The ttyS0 console is ratio-local. grep for ttyS0 finds nothing in the archsetup repo, so the hazard is ratio-specific and velox is unverified (offline).
+
+Surfacing half, refuted in part:
+- ~/.dotfiles/maint/src/maint/probes/systemd.py:70-101 failed_units emits one evidence entry per failed unit: unit, since, exit, and hint "journalctl -u {name} -b".
+- gui.py:1068-1074 renders it as a "failed units" section with "RESTART / RESET per unit".
+- status.py:50 includes it in maint status.
+
+Spec text:
+- L69: ExecStart "... then maint stamp topgrade on success, then removes the flag unconditionally".
+- L161: "ExecStart runs the script's --complete form as the user and removes the flag unconditionally".
+- L149: "removes unconditionally at the end of its attempt".
+- L171 AC: timeout leaves "the flag is cleared, and the panel still shows the pending work".
+- L181: "the boot run's output is on the console; its systemd unit status and journal record success/failure".
+:END:
+
+** DONE maint is not on the boot unit's PATH, so the boot stamp can silently not happen
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Design L69; Phase 1 L155; Phase 3 L161; AC L170; Readiness L189
+
+The boot oneshot runs the script as the user, and the script stamps with =maint stamp topgrade=, with no path or environment specified. 'maint stamp' is listed as verified present.
+
+Risk: The boot run applies the deferred set but writes no stamp. AC L170 (fresh topgrade_age after the armed reboot) then fails with no error anywhere.
+
+Recommended change: In Phase 3 (L161), after "=ExecStart= runs the script's =--complete= form as the user", add one sentence: "The unit sets =User== to the installing user (templated by the installer, as the BRIO udev rule is), so =HOME= points at that user's maint state. The script calls maint by absolute path (=$HOME/.local/bin/maint=), not through PATH, because a system unit's PATH is =/usr/local/bin:/usr/bin= and doesn't include =~/.local/bin=. If maint is missing or the stamp fails, the script logs a named failure to the journal instead of skipping silently (no =command -v ... || true=)." In L189, qualify the claim: =maint stamp= is present on the user's login PATH only (=~/.local/bin=, from dotfiles), not on the system PATH. Leave topgrade resolution out of this finding. The boot run doesn't invoke topgrade (Decision L142).
+
+Should-fix, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: modified, folded into Design, Implementation phases, Readiness dimensions, Testing. Partially confirmed; this follows the narrowing, so the topgrade-resolution clause is dropped (F01 covers the live path, and the boot form never runs topgrade). It keeps the recommended absolute-path call. maint stamp topgrade exists today (maint cli.py), and the boot unit already depends on the user's HOME for the record.
+
+'Logs a named failure' gets an exit code and a failed_step, so a failed stamp also reaches failed_units at boot.
+:EVIDENCE:
+- Spec L69: the boot unit's "ExecStart runs, as the user: ... then =maint stamp topgrade= on success". L155: --complete stamps when the deferred set is empty. L161: "=ExecStart= runs the script's =--complete= form as the user". L189: "=maint stamp= — all verified present on velox". The spec has no PATH or Environment wording anywhere (grep -n "PATH\|Environment\|absolute" matches only L32's unrelated "PATH wrapper").
+- =which -a maint= finds only ~/.local/bin/maint, a symlink to ../../.dotfiles/hyprland/.local/bin/maint (a python shim that loads maint/src from the dotfiles repo).
+- =systemd-path search-binaries-default= gives /usr/local/bin:/usr/bin.
+- ~/.dotfiles/common/.zprofile:4-12 says that without =export PATH="$HOME/.local/bin:$PATH"=, "maint, the topgrade wrapper, and every user script are unreachable".
+- ~/.dotfiles/hyprland/.local/bin/topgrade:40-42 has =command -v maint >/dev/null 2>&1; then maint stamp topgrade >/dev/null 2>&1 || true=, which skips the stamp silently.
+- The installer already templates the username into system files: archsetup:2595-2600 (BRIO rule: ARCHSETUP_USERNAME, then =sed -i "s/ARCHSETUP_USERNAME/${username}/"=).
+- maint's state path is ~ based (dotfiles maint/src/maint/cache.py:16-17, =os.path.expanduser("~/.local/state/maint")= unless MAINT_STATE_DIR is set), so the unit also needs the user's HOME. =User== provides it.
+- Counterpoint I checked: Decision L142 says the boot run is "one pacman transaction, no ecosystem sweep", so topgrade is not invoked at boot and wrapper resolution doesn't apply to this path.
+:END:
+
+** DONE Second-writer =maint apply-upgrade= path survives from the first draft
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Design final paragraph L71; Alternative D Neutral L93
+
+L71: 'The cleanest single home for the stamp is a small maint apply-upgrade (or a flag in the existing lever) that performs the guarded system upgrade and stamps on success, which both the boot unit and an interactive TTY run call.' Alternative D: 'the same maint apply-upgrade path serves both'.
+
+Risk: It invites an implementer to build a third completion path and stamp writer in maint, which is the drift the paragraph itself warns against. Stamp ownership reads as contradictory.
+
+Recommended change: Three edits:
+- Replace L71 with: "=upgrade-guarded= is the one code path that completes an upgrade and records it. The everyday run stamps when nothing was deferred. =--complete= stamps when it lands the deferred set, whether the boot unit or a TTY invokes it. A by-hand sentinel-override upgrade outside the script does not stamp; =upgrade-guarded --complete= from a TTY replaces that ritual."
+- In Alternative D's Neutral (L93), change "the same =maint apply-upgrade= path" to "the same =upgrade-guarded --complete= path".
+- In L69, replace the ExecStart sentence ("=informant read= ... then =topgrade --only system= ... then =maint stamp topgrade= on success, then removes the flag") with "Its =ExecStart= runs =upgrade-guarded --complete= as the user (which clears news, applies the deferred GPU/compositor set, and stamps), then removes the flag unconditionally." This makes the unit match Decision 6 and Phase 3, and leaves the script as the only stamp writer.
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: modified, folded into Design, Alternatives. Confirmed. Its proposed boot ExecStart ('upgrade-guarded --complete ... then removes the flag unconditionally') conflicts with F08's distinct boot form and with my disarm-at-start call, so that sentence follows F08 and F10. The Design final paragraph and Alternative D rewrites stand, extended to name both completion forms.
+:EVIDENCE:
+- Spec L71 (current): "The cleanest single home for the stamp is a small =maint apply-upgrade= (or a flag in the existing lever) that performs the guarded system upgrade and stamps on success, which both the boot unit and an interactive TTY run call."
+- Spec L93: "the same =maint apply-upgrade= path serves both an interactive TTY run and the boot unit."
+- The contradicting sections:
+ - L121 (Decision 3): ship the split script in archsetup; "maint's UPDATE/TOPGRADE levers change their =argv= to the script".
+ - L142 (Decision 6): "the boot oneshot runs the script's =--complete= form".
+ - L155 (Phase 1): =scripts/upgrade-guarded=, with "=--complete= (... stamp when the deferred set is empty)".
+ - L161 (Phase 3): "=ExecStart= runs the script's =--complete= form as the user".
+ - L173 (AC): "via the Phase-1 path".
+- Draft provenance: =git show a50fb3b:docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org= has the L71 text verbatim at its line 66 and the L93 text at its line 88. That draft's Phase 1 (its line 119) reads "A single =maint= entry point (=maint apply-upgrade=, ...)". Commit 068306e rewrote the phases around the script but left these lines in place.
+- No implementation exists: =grep -rn "apply.upgrade" ~/.dotfiles/maint/src= finds nothing, and the only archsetup hits are in this spec.
+- Adjacent stale text: L69's ExecStart still lists "=topgrade --only system= ... then =maint stamp topgrade= on success". That conflicts with L142 and L161.
+:END:
+
+** DONE First-draft boot and dependency text remains in Risks, Readiness and Phase 4
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Risks L193; Readiness Errors L179, Performance L182, External deps L189; Phase 4 L164
+
+Risks: 'The --only system scope ... shrink this', and it lists 'AUR review' as a boot prompt. Readiness: 'one pacman/yay transaction at boot', and deps 'topgrade --only system, informant read, yay, maint stamp — all verified present on velox'. Errors: 'every failure path lands in boot normally ... re-arm'. Phase 4 flow: 'arm → reboot → console upgrade → session'.
+
+Risk: The implementer's dependency and failure checklists point at a removed command and miss the gate-failure state, so that state gets no observability or documentation.
+
+Recommended change: - L193 (Risks): replace "The =--only system= scope and =--noconfirm=-style flags shrink this to near zero" with "The boot form's single pacman transaction with =--noconfirm= shrinks this to near zero". Drop "AUR review" from the prompt list. Change "verified in Phase 2" to "verified in Phase 3".
+- L182: change "one pacman/yay transaction at boot" to "one pacman transaction at boot".
+- L189: replace the deps line with: "=checkupdates= (pacman-contrib, installer-owned), =yay=, =topgrade --disable system git_repos containers= (space-separated; topgrade 17.12.2 rejects the comma form), =informant read --all= where installed (velox; absent on ratio), =dkms status=, =zfs list -t snapshot= on a ZFS root, =maint stamp=."
+- Make the same two invocation fixes in L65 and L155: comma-separated =--disable= becomes space-separated, and =informant read= becomes =informant read --all=. Apply the =--all= fix in L69 and L172 too.
+- L179: append "A failed =--complete= gate leaves the new kernel installed, nothing armed and no reboot; the foreground run names the failure and the desktop stays up."
+- L164: change the flow to "kernel set live → gate → arm → reboot → console upgrade → session".
+- Optional, same leftover text:
+ - L187 and L200: Phase 1 tests are pytest beside the guard's tests, Phase 2 uses the maint fake harness, and Phase 3 uses tests/installer-steps/ plus maint tests.
+ - L69 and L71: replace the topgrade --only system / yay -Syu / maint apply-upgrade wording with "runs =upgrade-guarded --complete=".
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: modified, folded into Design, Implementation phases, Readiness dimensions, Risks. Confirmed. The replacement deps line is completed with the commands other accepted findings add:
+- sudo and timeout for informant (F11);
+- yay -Pwq (F39);
+- pacman -Sp, -Sw and versioned -S (F08, F09);
+- vercmp;
+- systemctl is-enabled (F31);
+- lsinitcpio and findmnt (F19);
+- foot (F40).
+
+The Risks prompt-surface text is merged with F28's so the two edits don't collide.
+:EVIDENCE:
+Spec text (docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org):
+- L142: "one pacman transaction, no ecosystem sweep, no kernel"
+- L161: "ExecStart runs the script's --complete form"
+- L193: "provider choice, replace, AUR review ... The --only system scope ... verified in Phase 2"
+- L182: "one pacman/yay transaction at boot"
+- L189: "topgrade --only system, informant read, yay, maint stamp — all verified present on velox"
+- L179: "every failure path lands in boot normally"
+- L164: "arm → reboot → console upgrade → session"
+- L67, L135, L155, L169: gate failure stops, names the failure, does not reboot
+- L187: "installer-step pytest for Phase 2; maint unit tests for Phases 1 and 3". This inverts L155 (Phase 1 tests are pytest beside the guard's tests, tests/hypr-live-update-guard/) and L161 (Phase 3 tests go in tests/installer-steps/).
+- L69: "topgrade --only system (or the equivalent yay -Syu)"
+
+Commands run on ratio:
+- /usr/bin/topgrade --disable system,git_repos,containers --help → "error: invalid value 'system,git_repos,containers' for '--disable <STEP>...'". The space-separated form parses. topgrade --version reports 17.12.2. --help short-circuits any run, and the stamping wrapper was bypassed.
+- command -v: checkupdates=/usr/bin/checkupdates (owned by pacman-contrib 1.13.1-1, which the installer installs at archsetup:2175); dkms and zfs present; informant MISSING on ratio.
+
+Installer code:
+- archsetup:1124-1129: "--all marks without printing or prompting; a bare =informant read= is interactive and would hang an unattended run", then it runs informant read --all.
+:END:
+
+** DONE Test framework and phase-to-harness map contradict the phases
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Phase 1 L155 ('pytest'); Dev tooling L187; Risks L193; Testing L200
+
+L155 says 'pytest beside the guard's'. L200: 'Phase-1 and Phase-3 logic under the maint fake harness; Phase-2 install under tests/installer-steps/'. L187: 'installer-step pytest for Phase 2; maint unit tests for Phases 1 and 3'. L193 says boot non-interactivity is 'verified in Phase 2'.
+
+Risk: Following L200 puts the Phase 1 tests where they can't import the script. Following 'pytest' produces a red gate. The prompt-surface check is assigned to the wrong phase.
+
+Recommended change: Use one phase-to-test map and repeat it at L155, L187 and L200:
+- Phase 1 → archsetup tests/upgrade-guarded/ and tests/kernel-modules-check/. These use unittest + subprocess + env-var seams, with fake checkupdates, pacman, dkms and zfs on PATH (tests/zfs-pre-snapshot/fake-zfs can be reused).
+- Phase 2 → dotfiles tests/maint/ under the maint fake harness.
+- Phase 3 → archsetup tests/installer-steps/ for the install step, the arm action's tests in dotfiles tests/maint/, and the manual boot test in todo.org.
+
+Specific edits:
+- L155: change "pytest beside the guard's" to "unittest beside the guard's (tests/upgrade-guarded/, run by make test-unit)".
+- L187: change to "unittest for Phase 1 and the Phase 3 installer step in archsetup; maint unit tests for Phase 2 and the Phase 3 arm action; a manual boot test in todo.org".
+- L200: replace the first sentence to match this map.
+- L193: change "verified in Phase 2" to "the --noconfirm argv asserted in Phase 1's tests; no-stdin behavior verified by Phase 3's manual boot test".
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: modified, folded into Implementation phases, Readiness dimensions, Testing, Risks. Confirmed. The map follows the re-phasing. F16's same-commit rule moves the panel action into Phase 2, so Phase 3 is archsetup only, and F21's TOML pin test lands in Phase 1. The tests use unittest, which is what make test-unit runs: it globs tests/*/test_*.py, so new suites need no list edit.
+:EVIDENCE:
+Spec docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org:
+- L155: "Tests (pytest beside the guard's)".
+- L158: Phase 2 is maint wiring, "Tests under the maint fake harness".
+- L161: Phase 3: "Tests in =tests/installer-steps/= for the install step; the arm action's tests in maint".
+- L187: "installer-step pytest for Phase 2; maint unit tests for Phases 1 and 3".
+- L193: "verified in Phase 2".
+- L200: "Phase-1 and Phase-3 logic under the maint fake harness; Phase-2 install under =tests/installer-steps/=".
+
+The stale lines come from the first draft. In git show a50fb3b, the phases are: Phase 1 "Stamp the safe-completion path", Phase 2 "The armed boot-time unit (archsetup)", Phase 3 "The arming affordance (dotfiles maint)". Its lines 148, 154 and 159 match the current L187, L193 and L200 word for word.
+
+Test conventions:
+- tests/hypr-live-update-guard/test_hypr_live_update_guard.py:1-21 is unittest with HYPR_GUARD_* env-var seams, and its docstring says "python3 -m unittest tests.hypr-live-update-guard...".
+- Makefile:58-68 (test-unit) loops over tests/*/test_*.py running python3 -m unittest "$mod" || fail=1.
+- grep for "import pytest" or bare "def test_" under archsetup tests/ finds nothing.
+- I ran a scratch pytest-style module through python3 -m unittest: "Ran 0 tests ... NO TESTS RAN", exit=5. pytest 9.1.1 is installed but no gate calls it.
+- ~/.dotfiles/tests/maint/test_remedies_doctor.py:18-27 sets REPO_ROOT to the dotfiles repo and puts only maint/src and panelkit/src on sys.path, with FAKE_TOOL = tests/maint/fake-tool.
+- ~/.dotfiles/Makefile:290-303 also globs tests/*/test_*.py through unittest.
+- tests/zfs-pre-snapshot/fake-zfs exists and could be reused.
+:END:
+
+** DONE yay -Sua doesn't hold AUR packages back, and its failure and privilege model is undefined
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Design L65 ('AUR packages pinning a guarded version hold themselves back'); Phase 1 L155 (=yay -Sua --noconfirm=; 'exit 0 on a successful live part')
+
+The script runs =yay -Sua --noconfirm= after the pacman run with --ignore. It assumes AUR packages that need a newer guarded library defer themselves.
+
+Risk: When it does fire, the spec doesn't say whether a failed AUR step counts as a failed live part, or what that does to the exit code, the stamp and the deferred badge. The implementer also has to invent the split between user and root steps.
+
+Recommended change: 1. L65: replace "(=yay -Sua --noconfirm=, AUR packages pinning a guarded version hold themselves back)" with "(=yay -Sua --noconfirm=; yay still resolves repo dependencies normally, so an AUR upgrade that needs a newer held package makes yay's own pacman call trip the guard and the AUR step fails)". Change "on the driven path it never fires" to "on the driven path it fires only in that AUR case".
+
+2. Phase 1 (L155): add one rule. Suggested default, consistent with the state framing: "A non-zero yay exit is a failed live part. The script still runs the topgrade sweep, records the AUR failure in the state file so the panel shows it beside the deferred count, does not stamp, and exits non-zero." Add a test for it.
+
+3. Phase 2 (L158): reword the parenthetical so the force-wrap removal no longer rests on "never trips the guard".
+
+4. L167: change "the guard hook does not fire" to "the guard hook does not fire from the pacman step". Add a criterion: "an AUR upgrade needing a held library fails the AUR step visibly without blocking the rest of the run".
+
+No change is needed for the privilege model; L69, L161 and L180 already cover it. If I want it explicit, add one clause to Phase 1: "runs as the user; pacman, informant and the arm-flag write go through sudo".
+
+Should-fix, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: modified, folded into Design, Implementation phases, Acceptance criteria, Risks, Testing. Partially confirmed; this follows the narrowing that privilege and the stamp are settled. The privilege clause is made explicit, including the EUID-0 refusal.
+
+Two Risks lines are added for the paths the pacman hold can't cover. yay's resolver can move a kernel-set or DKMS-set package when an AUR upgrade requires a newer one. And a DKMS package installed from the AUR would be upgraded by yay -Sua with no hold at all. Neither applies today on ratio, where zfs-dkms comes from the archzfs repo; checklist step (a) confirms velox.
+:EVIDENCE:
+- Spec L65: "runs the AUR-only remainder (=yay -Sua --noconfirm=, AUR packages pinning a guarded version hold themselves back)" and "on the driven path it never fires".
+- Spec L155: "=yay -Sua --noconfirm= → ... → exit 0 on a successful live part". There is no yay failure rule.
+- Spec L158: "the press-again-to-force sentinel wrap goes away (the driven path never trips the guard)".
+- Spec L167: "the guard hook does not fire".
+- man yay, -a/--aur: "Actions such as sysupgrade will only act on AUR packages. Note that dependency resolving will still act normally and include repository packages."
+- scripts/hypr-live-update-guard: L62 and L67 check pgrep for Hyprland, L86-105 split targets by version change, L121 prints the BLOCKED banner, L139 exits 1.
+- The installed hook (/etc/pacman.d/hooks/hypr-live-update-guard.hook) has AbortOnFail and targets mesa, mesa-*, wayland, libdrm, libglvnd and the others.
+- yay 13.0.1, yay -Pg: sudobin "sudo", sudoloop false.
+- /etc/pacman.conf:25 has IgnorePkg = bridge-utils only.
+- Foreign-package deps on guarded libraries are all unversioned or soname-only: claude-desktop (libdrm, mesa), insync (libglvnd), webkit2gtk (libdrm, mesa, wayland), zoom (mesa, libdrm), mpvpaper (libwayland-client.so=0-64, libwayland-egl.so=1-64). None of the AUR packages is itself a guard target.
+- Privilege is already stated: spec L69 ("Its =ExecStart= runs, as the user ... =sudo= works unattended"), L161 ("runs the script's =--complete= form as the user"), L180 ("the unit runs the upgrade as the user via sudo").
+- makepkg:1240-1243 refuses EUID 0.
+- The stamp is already covered: L128 says stamp only when the deferred set is empty, and in this scenario it is not empty.
+:END:
+
+** DONE No one-time install mechanism, and Phase 2 breaks the panel on an uninstalled machine
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Decision L122; Phase 1 L155; Phase 4 L164; Rollout L188; Testing L200
+
+The spec says 'existing machines need the one-time install' but gives no command. The ordering is stated only as commit order ('two commits, archsetup first'), and the install itself is deferred to Phase 4, after Phases 2 and 3.
+
+Risk: The commits land in order, but each machine picks them up independently. A machine that pulls Phase 2 before Phase 1 is hand-installed gets UPDATE/TOPGRADE failures, with the force path already removed. velox, which is offline, will hit this on its next pull.
+
+Recommended change: Replace the L164 sentence "existing machines need the one-time install" with an explicit per-machine checklist, and point L188 at it:
+
+"Existing machines: hyprland() runs inside the window_manager step, which is already marked complete in /var/lib/archsetup/state/, so re-running archsetup won't install anything new. On each machine, do these by hand, in this order:
+(a) After the Phase-1 commit: pull archsetup, then =install -m 755 scripts/upgrade-guarded scripts/kernel-modules-check /usr/local/bin/=.
+(b) Only after (a) on that machine: pull the Phase-2 dotfiles commit. The levers call upgrade-guarded and no longer have a force path, so pulling first breaks UPDATE and TOPGRADE on that machine.
+(c) After the Phase-3 commit: install archsetup-boot-upgrade.service, run =systemctl daemon-reload=, enable the unit, and confirm /var/lib/archsetup/ exists.
+Re-copy configs/maintenance-thresholds.toml only if Phase 2 adds keys to it.
+velox's dotfiles must not move past the Phase-2 commit until (a) is done there."
+
+Optional, in Phase 2: when upgrade-guarded is not on PATH, the lever reports "upgrade-guarded not installed (archsetup one-time install)" rather than the generic "tool missing or timed out". Don't fall back to the old yay/topgrade argv; that would contradict the decision at L114.
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: modified, folded into Implementation phases, Readiness dimensions. Confirmed. The checklist gains items other resolutions make mandatory:
+- the hook migration (F20), which must precede first use because the script fails closed;
+- an unconditional TOML re-copy, because F21 rewrites guard_patterns;
+- a DKMS-origin check (F29).
+
+The checklist is keyed to three commits after the re-phasing. The optional missing-script lever message is adopted. Falling back to the old argv stays rejected, as the finding's check showed.
+:EVIDENCE:
+Spec:
+- L122: "the rollout is two commits, archsetup first".
+- L155: script "installed to /usr/local/bin by the step that installs the guard"; kernel-modules-check is a second new script.
+- L158: levers "change their argv to the script; the press-again-to-force sentinel wrap goes away".
+- L161: Phase 3 adds the unit, the installer step and the /var/lib/archsetup/ flag directory.
+- L164: "existing machines need the one-time install", with no steps given.
+- L188: same wording, no steps.
+- L200: "Roll to velox first, then ratio".
+
+archsetup (installer):
+- Line 257: state_dir="/var/lib/archsetup/state".
+- Lines 290-296: run_step prints "Skipping %s (already completed)" when the marker exists.
+- Lines 3994-3996: every step goes through run_step.
+- Lines 52-56 and 85-99: the only CLI flags are --fresh, --status, --config-file and --autologin. There is no per-step re-run.
+- Lines 2606-2654: guard binary cp plus hook heredoc, inline in hyprland() (defined at 2534).
+- Lines 2676-2683: window_manager() calls hyprland.
+- Lines 1640-1650: thresholds TOML installed with =install -m 0644=. That's a copy, not a stow link, and it's called from user_customizations (1449).
+
+Live system:
+- =ls /var/lib/archsetup/state/= lists window_manager, user_customizations, supplemental_software and others, so re-running archsetup on ratio skips the guard step.
+
+todo.org:
+- Lines 4006-4010: "supplemental_software is a completed step there and doesn't re-run". That is the same mechanism, already hit and fixed by hand per machine.
+
+dotfiles (maint):
+- remedies.py:294-312: update/topgrade levers have "always": True and fixed argv.
+- capability.py:65-81: which() checks only for zpool/snapper/docker/virsh.
+- cmd.py:21-24: OSError returns None.
+- doctor.py:161-163: None is reported as "tool missing or timed out".
+- ~/.local/bin/maint and ~/.local/bin/topgrade are symlinks into ~/.dotfiles/hyprland/.local/bin, so a dotfiles pull is live immediately.
+- No dotfiles auto-pull timer is installed (systemctl --user list-timers), so ordering per machine can be controlled by hand.
+:END:
+
+** DONE Phase 1 ships arming before any boot consumer exists
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Phase 1 L155 (--complete arms); Phase 3 L161 (unit + flag dir)
+
+Phase 1's --complete 'arm[s] the GPU/compositor set', and the gate refuses to 'arm or reboot'. The unit that consumes and removes the flag, and the /var/lib/archsetup flag directory, arrive only in Phase 3.
+
+Risk: A --complete run between Phase 1 and Phase 3 writes a flag that nothing removes, and its reboot offer applies nothing. When the unit lands, the stale flag fires on the next boot with a package set nobody chose for that day.
+
+Recommended change: In Phase 1 (L155), add a rule to the --complete description: --complete arms the GPU/compositor set only when archsetup-boot-upgrade.service is installed. Without the unit, it stops after a passing gate. The GPU/compositor set stays in the deferred state file, the script prints "apply from a TTY: upgrade-guarded --complete", and it offers no reboot for that set. Add a Phase 1 test: with the unit absent, --complete never writes the flag and never offers the GPU reboot. This covers both the window between Phase 1 and Phase 3 and the L188 rollback state. The alternative, moving the arm write into Phase 3, fixes the phase window but not rollback.
+
+Should-fix, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: modified, folded into Implementation phases, Testing. Partially confirmed; this follows the narrowing that the gap is not unsafe and the flag directory already exists. Detection is pinned to one command, systemctl is-enabled, which also covers a unit that is installed but not enabled (F41). Any stale flag is removed, so nothing is left for a later unit to consume unexpectedly.
+:EVIDENCE:
+- Spec L155 (Phase 1): "--complete (the dedicated-session form: apply the kernel set live, run the gate, then arm the GPU/compositor set or, with no compositor live, apply it directly ...)". Further on: "--complete refuses to arm or reboot on that exit. Usable from a TTY at once." Test: "--complete never reaches the arm step on a failed gate."
+- Spec L161 (Phase 3): "archsetup-boot-upgrade.service, installed by the installer ... Installer step + unit file + the /var/lib/archsetup/ flag directory."
+- Spec L67: once the gate passes, "the script arms a persistent flag ... and offers to reboot".
+- Spec L188: "removing the unit and flag reverts fully". The script stays installed, so it can still arm with no consumer.
+- Live system: systemctl cat archsetup-boot-upgrade.service returns "No files found", so the unit does not exist yet. /usr/local/bin has only hypr-live-update-guard and no upgrade-guarded.
+- Live system: /var/lib/archsetup exists and contains state/. The installer at archsetup:257 sets state_dir="/var/lib/archsetup/state", so the directory predates Phase 3 and does not block a Phase-1 flag write.
+- Spec L142 (Decision 6): the boot oneshot is "scoped to the deferred GPU/compositor set ... no kernel". A late-firing flag applies that set with nothing live, the designed safe path, so it is not an unchosen package set.
+:END:
+
+** DONE Acceptance criteria aren't mapped to verification, and the manual test covers only AC4
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Acceptance criteria L167-174; Phase 3 L161; Testing L200
+
+The only manual test is 'arm with a guarded lib pending, reboot, confirm', with no setup for producing a pending guarded lib. Nothing verifies AC3 (DKMS failure stops; snapshot bootable from ZBM), AC5 (boot failure or timeout never blocks the session) or AC6 (unread news). Rollout goes straight to velox.
+
+Risk: Goal 3's safety claim ('can never lock the machine out') and the ZBM fallback are never exercised before velox, where the failure mode is an unbootable machine. The happy-path test can only run on days when upstream happens to publish a guarded update.
+
+Recommended change: Replace the L200 paragraph with a list mapping each acceptance criterion to how it is verified, and correct L187 to match:
+
+- AC1, AC2, AC7: Phase 1 pytest beside the guard (ignore set equals blocked plus kernel set, deferred-state round-trip, stamp only on an empty deferred set) and the Phase 2 maint probe test.
+- AC3: Phase 1 gate tests cover stopping, naming the failure, and never arming. Either make the ZFSBootMenu snapshot-boot clause a documented recovery drill (in a =make test-keep FS_PROFILE=zfs= VM, or on velox) in todo.org, or move it from the AC into Risks as the fallback.
+- AC4: keep the manual test, but give it a trigger and a setup. Run it when =upgrade-guarded --dry-run= lists a GPU/compositor package, or in the VM create a pending guarded lib from a TTY by installing an older cached version.
+- AC5: add an installer-steps test that asserts the unit file statically. It should check Before=getty@tty1.service, a TimeoutStartSec, no Requires/BindsTo/RequiredBy tying it to getty or session targets, and flag removal placed where it also runs on timeout (ExecStopPost or remove-first). Also add a VM or manual test with a failing and a sleeping ExecStart test seam. Expected: boot reaches the Hyprland session, the flag is gone, and the panel still shows the deferred set.
+- AC6: add a Phase 1 pytest with a fake informant asserting that =informant read --all= runs before the pacman transaction in both the live and --complete forms. Also correct the bare =informant read= at L65, L69 and L172.
+- AC8: the existing tests/hypr-live-update-guard suite passes unchanged.
+
+Finally, state that the Phase 3 boot-unit checks run in =make test-keep FS_PROFILE=zfs= before the velox rollout.
+
+Should-fix, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: modified, folded into Acceptance criteria, Testing, Risks, Readiness dimensions. Partially confirmed; this follows the narrowing. The map covers the consolidated acceptance list (glossary §18), which adds the criteria other findings introduce, and uses unittest, not pytest (F28). Of the two options for AC3's ZFSBootMenu clause, it moves to Risks as the fallback and is exercised by a non-gating recovery drill. No gate can check it before rollout, and the drill is where F45's pacman -U --dbonly step gets practiced. AC5's static unit test is the single source-inspection test owned by F41.
+:EVIDENCE:
+Spec:
+- L161: "a documented manual boot test (defer, arm, reboot, observe)"
+- L200: "scripted manual test ... (arm with a guarded lib pending, reboot, confirm ...). Roll to velox first, then ratio." No setup step for producing a pending guarded lib.
+- L155: Phase 1 tests include the gate pass/fail on fakes and "--complete never reaches the arm step on a failed gate", so AC3's stop half is covered. No test mentions informant, the unit's failure or timeout behavior, or the ZBM boot.
+- L184: the ordering is only "tested-by-inspection".
+- L69: ExecStart "... then removes the flag unconditionally", i.e. inside ExecStart.
+- L171: AC5 requires the flag to be cleared on a timeout.
+- L187 vs L155/L157/L200: the phase numbers do not match the phases.
+
+Code and system:
+- =pacman -Q informant= on ratio: "error: package 'informant' was not found".
+- archsetup:1121-1129: "If the base ships informant (e.g. an archangel-installed system) ... a bare =informant read= is interactive and would hang an unattended run", and the installer uses =informant read --all=.
+- Makefile:12-14, 35-36: FS_PROFILE ?= btrfs, with zfs available for test, test-keep and test-vm-base. Makefile:32/108: test-maint runs the break/fix scenarios.
+- scripts/testing/archsetup-test-zfs.conf exists.
+- scripts/testing/tests/test_boot.py:14-18 asserts /efi/EFI/ZBM/zfsbootmenu.efi on a ZFS root.
+- scripts/testing/maint-scenarios/ holds the 10-32 break/fix scripts.
+- =grep -rln 'Before=\|TimeoutStartSec\|\[Unit\]' tests/= returns nothing, so no existing static unit-file tests cover this.
+:END:
+
+** DONE Rollback is described as 'remove unit and flag', but the change isn't additive
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Readiness 'Rollout, compatibility & rollback' L188
+
+'additive; removing the unit and flag reverts fully.'
+
+Risk: Removing only the unit and flag leaves the panel calling the script with no force path. A partial rollback leaves the two repos disagreeing.
+
+Recommended change: Replace the "Rollout, compatibility & rollback" bullet at spec line 188 with:
+
+"Rollout, compatibility & rollback: not additive. Phase 2 repoints the UPDATE/TOPGRADE argv and removes the press-again sentinel wrap. Roll back in reverse rollout order:
+1. Revert the dotfiles Phase 2/3 commits first. That restores the yay/topgrade argv and the force path, and removes the deferred probe and the apply-on-reboot action.
+2. Then remove archsetup-boot-upgrade.service and run daemon-reload.
+3. Remove the /var/lib/archsetup/ arm flag, and /usr/local/bin/upgrade-guarded and kernel-modules-check.
+
+Removing only the unit and flag disables the boot path but leaves apply-on-reboot arming a flag nothing reads. Removing the archsetup scripts before reverting dotfiles leaves UPDATE/TOPGRADE pointing at a missing binary. The rewritten guard_patterns can stay. Existing machines need a one-time install, and a rebuild gets it from the installer. The guard hook is untouched throughout."
+
+Should-fix, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: modified, folded into Readiness dimensions. Partially confirmed; this follows the narrowing, adjusted for the three-commit rollout, the sysupgrade alias (F38), and the hook migration, which is not reverted. With F31 in place, removing only the unit no longer leaves --complete arming a flag that nothing reads.
+:EVIDENCE:
+- Spec line 188: "Rollout, compatibility & rollback: additive; removing the unit and flag reverts fully ... Rollback leaves the guard and manual TTY path intact."
+- Spec line 158 (Phase 2): "UPDATE and TOPGRADE levers change their argv to the script; the press-again-to-force sentinel wrap goes away."
+- Spec line 121: "the rollout is two commits, archsetup first."
+- Spec line 161 (Phase 3): the panel action "installs the held-kernel set live, writes the persistent arm flag, and offers to reboot."
+- Spec line 155: Phase 1 installs scripts/upgrade-guarded and kernel-modules-check.
+- Spec line 174: the hook is unchanged (already an acceptance criterion).
+- Dotfiles code I checked:
+ - ~/.dotfiles/maint/src/maint/remedies.py:282-293 holds guard_sentinel_set and guard_sentinel_clear.
+ - remedies.py:297 sets the update argv to ["yay","-Syu","--noconfirm"]. remedies.py:308 sets the topgrade argv to ["topgrade","--disable","git_repos","-y"]. Both have guard "live_update".
+ - ~/.dotfiles/maint/src/maint/doctor.py:174-188 is _all_steps, which adds the sentinel wrap. doctor.py:242 runs that wrap when force is set.
+ These are exactly what Phase 2 deletes or repoints, so the change is not additive.
+- Thresholds TOML:
+ - configs/maintenance-thresholds.toml:65-72 guard_patterns are mesa, mesa-*, lib32-mesa*, hyprland, hyprland-*, aquamarine, hyprutils, hyprlang, hyprcursor, hyprgraphics, *wayland*, wlroots*, vulkan-radeon, lib32-vulkan-radeon, vulkan-intel and lib32-vulkan-intel.
+ - The hook Target list in archsetup:2628-2642 adds libdrm, libglvnd, vulkan-mesa-layers, nvidia-utils, lib32-nvidia-utils and xorg-xwayland, and has no hyprlang, hyprcursor or wlroots*.
+ - So the Phase 2 pin test does force a TOML rewrite. The maint thresholds.py:31-33 reads this file as the shipped layer at ~/.config/archsetup/maintenance-thresholds.toml.
+- Cache location: maint cache.py:16-17 is ~/.local/state/maint, which is where the deferred-set "own cache key" from spec line 128 would land.
+:END:
+
+** DONE Armed-but-not-rebooted state is invisible, and REBOOT semantics are ambiguous
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Design L67 ('arms … and offers to reboot'); Decision 4 L128 ('apply on reboot'); Phase 2 L158; Phase 3 L161
+
+No phase says how the panel shows a pending arm, how the 'offer to reboot' appears in the panel, or how to disarm.
+
+Risk: A user reading 'N deferred — apply on reboot' may press the REBOOT key the strip already shows, and nothing is applied because nothing was armed. A user who arms on ratio gets no reboot affordance at all. On both machines, a pending arm is invisible.
+
+Recommended change: Phase 2 (L158): replace "N deferred — apply on reboot" with "N deferred". Pair it with a key that runs =upgrade-guarded --complete=, and say plainly that a plain reboot applies nothing until a --complete run has armed. Fix the example string at L128 the same way; this changes only the illustration, not the stamp decision. Phase 3 (L161): the deferred probe also reads the arm flag. While the flag is present, the row reads "armed — N apply at next boot". The panel action calls offer_reboot() when --complete exits armed, rather than relying on reboot_required, which stays false on ratio because the booted linux-lts-strix is a local build the kernel set never upgrades. --complete prompts for a reboot only when stdin is a tty; from the panel, the REBOOT key is the offer. Add one acceptance criterion: after arming from the panel, the row shows the armed state and REBOOT is offered, on both ratio and velox. A --disarm command is optional and can be left out.
+
+Should-fix, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: modified, folded into Decisions, Implementation phases, Acceptance criteria. Partially confirmed; this follows the narrowing, so there is no --disarm. Under F40 the panel launches --complete in a detached terminal and never sees its exit. 'Call offer_reboot() when --complete exits armed' therefore becomes a state rule: REBOOT shows while the arm flag exists. That survives a panel close, and it works on ratio, where reboot_required stays false.
+:EVIDENCE:
+Spec:
+- L67: "arms a persistent flag ... and offers to reboot"
+- L128: "('6 deferred — apply on reboot')"
+- L135: the GPU set follows only after the gate passes
+- L158: "A new probe reads the deferred-set state file and the panel renders 'N deferred — apply on reboot' as its own row" (no arm-flag input)
+- L161: "The panel action installs the held-kernel set live, writes the persistent arm flag, and offers to reboot" (no display of the armed state, no mechanism for the offer)
+
+Code (~/.dotfiles/maint/src/maint):
+- gui.py:1509-1511: if rid in ("update","topgrade") and primary ok, call self.model.offer_reboot()
+- panel.py:501-510: reboot_key_visible() is reboot_offer OR the reboot_required value
+- gui.py:491-495: the REBOOT strip key is gated on reboot_key_visible()
+- packages.py:131-146: reboot_required is true only when /usr/lib/modules/$(uname -r) is missing
+- cmd.py:18-24: subprocess.run(cmd, capture_output=True, text=True, timeout=...)
+- doctor.py:149-161: user steps go through cmd.run
+- grep -rn "/var/lib/archsetup|deferred|upgrade-guarded" over maint/src/maint finds nothing
+
+Live checks on ratio:
+- uname -r: 6.18.25-1-lts-strix
+- pacman -Qo /usr/lib/modules/6.18.25-1-lts-strix: owned by linux-lts-strix 6.18.25-1
+- pacman -Si linux-lts-strix: "package 'linux-lts-strix' was not found"
+- pacman -Qm lists linux-lts-strix; Packager: Unknown Packager
+- No linux-lts-strix-headers is installed. linux, linux-lts, and both of their headers packages are separate packages.
+:END:
+
+** DONE Deferred row goes stale after a successful boot run or out-of-band completion
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Decision 4 L128; Design L71 (by-hand path); Decision 2 L114 (manual TTY path C); AC L167
+
+The boot run 'stamps when it completes the deferred set', but the spec never says it rewrites the deferred state. A by-hand sentinel run or a bare TTY pacman -Syu never touches the file at all.
+
+Risk: After a successful boot run, the panel shows a fresh topgrade_age next to 'N deferred', and the two contradict each other. AC L167 ('exact deferred set') fails after any out-of-band completion.
+
+Recommended change: Phase 1 (L155) and Decision L128:
+- Add: "Every mode, --complete and the boot run included, rewrites the deferred-set state file at the end of its run with what is still deferred, empty when nothing is. --complete drops the kernel set once it is installed and the GPU/compositor set once it is applied."
+- In L128, change "When it is non-empty it writes the deferred set" to "It always writes the deferred set (empty when nothing was held)".
+- Add a Phase 1 test: after a successful --complete or boot run, the state file is empty and the stamp is fresh.
+
+Phase 2 (L158), optional robustness for the out-of-band paths L71 names: the deferred probe drops any recorded entry whose installed version (pacman -Q, compared with vercmp) is at or above the recorded new version. A by-hand sentinel or TTY completion then clears the row without rerunning the script.
+
+Should-fix, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: accepted, folded into Decisions, Implementation phases, Testing.
+:EVIDENCE:
+Spec docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org:
+- L128: "the script stamps topgrade_run only when the deferred set is empty. When it is non-empty it writes the deferred set to its own cache key ... The boot oneshot stamps when it completes the deferred set." This line has no clear and no rewrite.
+- L155: the live pipeline includes "deferred set written to a state file". The --complete clause reads "apply the kernel set live, run the gate, then arm ... or ... apply it directly; stamp when the deferred set is empty", with no state-file write.
+- L142: the boot oneshot runs "the script's --complete form scoped to the deferred GPU/compositor set".
+- L158: the probe "reads the deferred-set state file".
+- L181: "the panel reflects the cleared or still-pending state after boot".
+- AC L170 checks only topgrade_age after the boot run, not the deferred row.
+
+Code: ~/.dotfiles/maint/src/maint/doctor.py:309-324 refreshes the pending cache (p_updates.scan_net) only after a successful update/topgrade lever run. ~/.dotfiles/maint/src/maint/probes/updates.py:145-160 shows scan_net writing updates_repo from checkupdates and updates_aur from yay -Qua, and nothing else deferral-related. The user unit maint-net-scan.timer runs it hourly (OnUnitActiveSec=1h). grep for "deferred" and "upgrade-guarded" across maint/src and archsetup/scripts returns nothing, so no existing code pins this behaviour.
+:END:
+
+** DONE CVE strip and card claim held packages are fixable via UPDATE
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Decision 5 consequences L136 ('security fixes ride the kernel'); Risks L197; Phase 2 L158
+
+The spec doesn't mention the CVE metric, which treats every queued package as something UPDATE will land.
+
+Risk: A fixable kernel or mesa advisory stays red after a successful UPDATE that, by design, cannot apply it. On the one signal that tracks security, the panel points at the wrong action.
+
+Recommended change: Add to Phase 2 (L158), after the deferred-row sentence: "Advisories on deferred packages count against the deferred row, not UPDATE. cve_queued (the red badge and strip), the CVE card's 'fixable via UPDATE' caption, and the UPDATE lever's cve_advisories binding all exclude names in the deferred set. The deferred row names those advisories ('N deferred · K fixable advisories') and goes to WARN when K > 0, so the kernel security fixes from the kernel decision have a visible reminder that points at --complete." Add a Phase 2 test: given a fixable advisory naming a deferred package, cve_queued returns 0, the card caption leaves it out, and the deferred row carries it at WARN. Optionally, extend acceptance criterion L167 with: "a fixable advisory on a deferred package shows on the deferred row, not as UPDATE-fixable."
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: modified, folded into Implementation phases, Acceptance criteria, Testing. Confirmed. This adds the finding's wider scope: REVIEW & FIX also stops offering UPDATE for an advisory on a deferred package. It also absorbs F48's cve_queued clause, so the exclusion has a single owner.
+:EVIDENCE:
+Spec: L65 and L135 hold the kernel and guard sets on every everyday run. L136 says "security fixes ride the kernel". L158 is the full Phase 2 display change, a deferred probe and the "N deferred — apply on reboot" row, with no CVE handling. L197 names the deferred row as the reminder for kernel security fixes. L167 (acceptance) covers only the deferred set.
+
+Code (~/.dotfiles/maint/src/maint/):
+- viewmodel.py:503-516: the cve_queued docstring says the strip "only turns red when running UPDATE would actually close an advisory". It counts any fixable advisory whose package is in updates_repo or updates_aur.
+- viewmodel.py:1012-1018: _card_cve returns "{fixable} fixable via UPDATE".
+- gui.py:641-672: _cve_queued feeds both the faceplate badge and the red strip class from _cache_list("updates_repo").
+- probes/updates.py:75-97: the cves() docstring says "the fix is one UPDATE away", and the metric is WARN whenever any advisory has =fixed= set.
+- remedies.py:294-302: the "update" lever has metric_ids ["updates_repo","updates_aur","cve_advisories"].
+- doctor.py:400-405: review() offers that lever whenever cve_advisories is WARN.
+- probes/updates.py ~160: updates_repo is written from checkupdates, so held packages stay in the queue after a split run.
+
+Live (ratio): checkupdates currently lists linux, linux-headers, linux-lts, linux-lts-headers, mesa, hyprland, vulkan-radeon and vulkan-mesa-implicit-layers. That is exactly the set a split run would hold, so every one of them would stay in updates_repo. arch-audit currently reports no fixable advisories, so the scenario is latent rather than active today.
+:END:
+
+** DONE Stale-kernel tracking is neither in v1 nor a tracked vNext item
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Scope tiers L57 ('vNext: none open'); Decision 5 consequences L136; Risks L197 ('a stale-kernel age … is a possible follow-up')
+
+The spec says the dedicated session 'has to happen on a cadence'. The only reminder is the deferred row, which is present most days, and the stale-kernel age is a 'possible follow-up' that no scope tier lists.
+
+Risk: The security half of the standing kernel hold has no owner. A row that shows nearly every day habituates the user, so an unpatched kernel ages silently.
+
+Recommended change: Change two spec lines, and keep v1 scope as it is:
+1. L57: replace "vNext: none open — …" with "vNext: a stale-kernel age in maint (how long the kernel set has been held, graded so an overdue dedicated session shows up); logged to todo.org as [#D]. The original live-kernel vNext is now part of v1's held set."
+2. L197: change "a stale-kernel age in maint is a possible follow-up" to "a stale-kernel age in maint is the vNext follow-up (see Scope tiers)".
+
+At decomposition, outside the spec, replace todo.org:740's stale "file the vNext [#D] kernel-reboot item" with that stale-kernel-age [#D] item. Do not add deferred_warn_days or hold-age grading to v1 unless I choose to.
+
+Should-fix, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: accepted, folded into Goals and Non-Goals, Risks.
+:EVIDENCE:
+Spec docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org:
+- L57: "vNext: none open — the kernel deferral that was vNext is now part of v1's held set."
+- L136: "the kernel deferral is now standing, so the dedicated session has to happen on a cadence (security fixes ride the kernel), and the panel's deferred count carries a kernel most days"
+- L197: "The panel's deferred row is the reminder; a stale-kernel age in maint is a possible follow-up."
+- git show a50fb3b L54, the original vNext: "fold the same arm-and-reboot pattern into a live-kernel-upgrade prompt (log to todo.org)". 068306e, the decision-closing commit, added L57's "none open" and L197 together.
+
+todo.org:
+- :739-740: "...file the vNext =[#D]= kernel-reboot item, and commit the spec."
+- :759: the stale note covers only "the 'four open decisions' list above".
+- A grep for stale-kernel, kernel age or kernel-age finds no task.
+
+.ai/workflows/spec-review.org:
+- :33: "Deferred work is logged to todo.org (v1 = [#B], vNext/someday = [#D])"
+- :122: "Are deferrals captured (todo.org)?"
+
+~/.dotfiles/maint/src/maint/probes/updates.py:88-89:
+- =fixable = [a for a in data if a.get("fixed")]; sev = schema.WARN if fixable else schema.OK=
+
+~/.local/state/maint/cve.json, kernel entries:
+- AVG-2701 and AVG-2683 [linux-lts], fixed=None
+- AVG-1879, AVG-2345 and AVG-1594 [linux], fixed=None
+- So the CVE probe never flags a held kernel.
+
+There is no kernel or deferred threshold in configs/maintenance-thresholds.toml [updates] (lines 57-75).
+:END:
+
+** DONE Routine update entry points outside the panel still land the kernel ungated
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Non-Goals L51; Design L65 ('backstop for a bare pacman -Syu'); Decision 5 consequences L136; Phase 4 L164; Documentation plan L186; history L204-206
+
+The spec routes only the panel's UPDATE and TOPGRADE levers through upgrade-guarded. The documented routine update path is untouched: system-health-check Phase 3 step 6 runs plain topgrade, and the sysupgrade alias runs topgrade. topgrade's system step is yay -Syu, which upgrades every kernel and zfs-dkms. The guard hook has no kernel Target, so on kernels it isn't the backstop L65 calls it. The same workflow also tells the operator to run 'maint stamp topgrade' by hand. The containers fix lives only in the script's CLI --disable, so a plain topgrade still fails on containers, and that failure is what drives the hand stamp.
+
+Risk: Decision 5's consequence, 'an everyday UPDATE can never put velox into the unbootable state', holds only for the panel. The update velox actually gets, the health-check topgrade, still swaps linux-lts and zfs-dkms with no DKMS/initramfs/snapshot gate. That is the exact failure chain the decision was reversed to close. The hand-stamp instruction also writes freshness while a deferral is outstanding.
+
+Recommended change: Three edits.
+
+1. Add to Phase 4 (L164), after "Document the flow":
+
+"Update docs/workflows/system-health-check.org Phase 3 so the routine update goes through the script. Step 6 runs upgrade-guarded instead of plain topgrade. A day with a pending kernel or guarded library lands it through upgrade-guarded --complete as the dedicated session. Step 5's ratio strix addendum keys on --complete instead of 'before topgrade'. The 'maint stamp topgrade by hand' fallback becomes 'the script stamps; never hand-stamp while a deferred set is outstanding'. Append a resolution note to the 2026-09-12 containers KIL entry rather than rewriting it."
+
+2. Add the health-check workflow to the Documentation plan line (L186).
+
+3. Add one line to Risks, to spell out the consequence of L51:
+
+"Bare topgrade, yay and pacman -Syu (including the sysupgrade alias) still land the kernel set with no DKMS gate, because the hook is silent on kernels. The hold covers only the script's callers."
+
+Optional: point the sysupgrade alias at upgrade-guarded.
+
+Should-fix, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: modified, folded into Implementation phases, Readiness dimensions, Risks. Confirmed in substance; this follows the narrowing that the bare-command exposure needs one explicit Risks line. The optional alias change is taken. sysupgrade is the routine terminal path on both machines (alias sysupgrade="topgrade" at line 42 of both shell alias files). While it points at plain topgrade, it is exactly the ungated kernel and zfs-dkms route that Decision 5 closes. Repointing it is a one-line dotfiles change in the same commit as the levers, and it inherits the same step (a) ordering.
+:EVIDENCE:
+Spec facts:
+- L51: "The hook stays silent on kernels; the split script holds them back on a live run as a second, separately-reasoned list ... a deferral policy rather than a guard." This already concedes that bare commands do not gate kernels.
+- L65: the backstop sentence refers to the GPU guard.
+- L131 title: "lands only in the dedicated session". L136: "an everyday UPDATE can never put velox into the unbootable state".
+- L164 Phase 4 and L186 Documentation plan never mention docs/workflows/system-health-check.org. Grepping the spec for health, sysupgrade or workflow finds only L206: "Artifacts: velox health checks of 2026-09-12 and 2026-10-04".
+
+Code and docs:
+- docs/workflows/system-health-check.org:236, Phase 3 step 6: "Run =topgrade= for the actual update ... If the run happened outside the wrapper somehow, =maint stamp topgrade= records it by hand."
+- :235, step 5: the ratio strix addendum runs "before topgrade" when linux or linux-lts is pending.
+- :698: "If topgrade is present, Phase 3 uses it rather than the raw package manager."
+- :1067: the KIL entry tells the operator to run "=maint stamp topgrade= by hand".
+- todo.org:762-765 (2026-09-17): "velox has linux-lts 6.18.51 -> 6.18.52 pending today, which is the kernel-hold case this spec exists for, so no plain topgrade on velox until the hold is built or the kernel update runs as its own session."
+- .dotfiles/common/.zshrc.d/aliases.sh:42 and .bashrc.d/aliases.sh:42: alias sysupgrade="topgrade".
+- .dotfiles/common/.config/topgrade.toml:131: arch_package_manager = "yay". The misc disable list at :19 is ["emacs","poetry","gnome_shell_extensions","lensfun","uv"], with no containers, so a plain topgrade still fails at the containers step.
+- archsetup:2625-2649: the hook Targets list no linux* package.
+- /etc/pacman.conf IgnorePkg = bridge-utils only.
+- pacman -Q on ratio shows linux, linux-lts, linux-lts-strix and zfs-dkms 2.4.4 installed, all with nothing pinning them.
+- hyprland/.local/bin/topgrade wrapper comment: it stamps for "the system-health-check workflow, a plain shell".
+:END:
+
+** DONE The split run discards Arch news unread
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Design L65 ('clears the news hook (informant read) where installed'; topgrade --disable system); Design L69; AC L172; Reuse L183
+
+Each everyday run marks all Arch news read before the transaction and disables topgrade's system step. Nothing in the flow shows the news. Today the news gets attention two ways: on velox, informant's AbortOnFail hook blocks the transaction until the news is read, and topgrade's show_arch_news prints it during the system step. The split run removes both, and maint has no news surface.
+
+Risk: Manual-intervention notices are often about exactly the mesa, wayland and kernel transitions this design defers. With this change they're silently marked read, or never shown on ratio, right before an unattended 'pacman -Syu --noconfirm'. That removes the one interlock that forced a human to read them.
+
+Recommended change: Design L65 and Phase 1 L155: before the transaction, the script records the unread Arch news titles in its state file, using =yay -Pwq=, which needs no informant and works on both machines. It must run before =pacman -Syu=, because yay counts news as "new" relative to the build dates of installed packages. Only then does it clear the hook with =informant read --all= where informant is installed.
+
+Make the same =--all= correction to the boot ExecStart at L69, and to the parenthetical in AC L172. A bare =informant read= is interactive and hangs or fails with no tty.
+
+Phase 2 (L158): the deferred-set panel row also lists the recorded news titles until the user dismisses them.
+
+Risks: add one bullet. The driven path marks news read, so on velox informant's AbortOnFail hook no longer stops a run, and the panel row replaces it as the place news gets seen.
+
+Leave the auto-clear as designed. Whether unread news should instead stop the everyday run is an open call for the owner, not a review fix.
+
+Should-fix, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: modified, folded into Design, Implementation phases, Risks, Testing. Partially confirmed; this follows the narrowing. The auto-clear stays, and whether unread news should stop a run stays my open call. The text also defines what the recommendation left open:
+- capture happens only in modes with network, never at boot;
+- titles carry forward until dismissed;
+- dismissal is a maint-owned key, so the script never reads panel state and stays the record's only writer.
+:EVIDENCE:
+Spec L65: "clears the news hook (=informant read=) where installed; runs =pacman -Syu --noconfirm --ignore=<blocked set>= ... then runs =topgrade --disable system,git_repos,containers -y=". Spec L69: the boot ExecStart runs "=informant read= (clear the news hook that would otherwise abort the transaction)". Spec L172 (AC): "Unread Arch news does not wedge the boot run". The spec never mentions surfacing news; the only hits for "news" are L65, L69, L172 and L183.
+
+archive/task-archive.org:604 says "informant: the base ships informant; its pacman PreTransaction hook (AbortOnFail) blocked archsetup's first transaction. Fix: informant read --all up front (guarded). PROVEN."
+
+archsetup:1121-1129 reads "registers a pacman PreTransaction hook (AbortOnFail) that blocks every package transaction while Arch news is unread ... --all marks without printing or prompting; a bare =informant read= is interactive and would hang an unattended run."
+
+~/.dotfiles/common/.config/topgrade.toml:144 sets show_arch_news = true, and :131 sets arch_package_manager = "yay". Both are under [linux], so they only take effect in the system step.
+
+=grep -rni 'news|informant' .dotfiles/maint/src/maint= finds no news probe (the only hit is the pacnew comment at gui.py:93).
+
+=pacman -Q informant= on ratio returns "error: package 'informant' was not found".
+
+~/.dotfiles/maint/src/maint/doctor.py:161-168: levers run via cmd.run(argv) and keep only =out[-1]= of stdout as the detail. cmd.py:18-24 shows cmd.run is subprocess.run(capture_output=True). So today's panel TOPGRADE path already drops topgrade's news output on ratio.
+
+=man yay=: "-w, --news Print new news ... News is considered new if it is newer than the build date of all native packages", and "-q Only show titles". That gives an informant-free title source on both machines, as long as it runs before the upgrade.
+:END:
+
+** DONE The panel's lever runner can't host the long --complete session
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Design L67 ('run in the foreground from the panel's action'); Phase 2 L158; Phase 3 L161 ('The panel action installs the held-kernel set live ... and offers to reboot'); Readiness Performance L182
+
+The spec runs the everyday split run and the dedicated kernel session through the maint lever path. That runner captures output, has no tty, and kills on a fixed timeout. It runs on a daemon thread that dies with the panel window. The spec specifies no timeout, detachment or log for the panel-hosted runs; the readiness items cover only the boot unit.
+
+Risk: A stray Escape, a waybar click, or the 3600 s ceiling kills the script partway through the kernel session, after the kernel transaction and before the gate verdict, the state-file write or the stamp. The panel reports 'tool missing or timed out' while pacman or DKMS may still be running orphaned. The 'offers to reboot' prompt has no stdin to read.
+
+Recommended change: Phase 3, L161: replace "The panel action installs the held-kernel set live, writes the persistent arm flag, and offers to reboot." with: "The panel action does not use the captured-output lever runner. Like MERGE, it opens a terminal running the dedicated session (foot -e upgrade-guarded --complete, via Popen with start_new_session=True), so closing the panel cannot kill or orphan the kernel transaction. The script prints the gate verdict there, writes the arm flag, and asks there whether to reboot. The panel picks up the outcome from the deferred-set state file on its next probe. The arm action's maint test asserts the terminal argv and the detach." Design L67: change "run in the foreground from the panel's action" to "run in a terminal the panel's action opens". Leave Phase 2 alone: the everyday levers already run through this runner with timeout 3600, and the argv change keeps that.
+
+Should-fix, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: modified, folded into Design, Implementation phases, Testing. Partially confirmed; this follows the narrowing that the everyday levers keep the existing runner. foot gets --hold so the gate verdict stays readable after the script exits. The action lands in Phase 2 with its row (F16).
+:EVIDENCE:
+Spec:
+- L67: "run in the foreground from the panel's action or upgrade-guarded --complete in a terminal ... offers to reboot".
+- L161: "The panel action installs the held-kernel set live, writes the persistent arm flag, and offers to reboot ... the arm action's tests in maint".
+- L181-182: Observability and Performance cover only the boot unit.
+- L65: the script already writes the deferred set to a state file.
+
+maint (~/.dotfiles/maint/src/maint):
+- doctor.py:161: proc = cmd.run(step["argv"], timeout=r.get("timeout", 60)).
+- doctor.py:164-168: only the last stdout line (160 chars) and a 500-char stderr tail reach the wall; "tool missing or timed out" on None.
+- cmd.py:22: subprocess.run(cmd, capture_output=True, text=True, timeout=timeout), catching TimeoutExpired and returning None.
+- CPython subprocess.run source (checked live): except TimeoutExpired: process.kill(); process.wait(); raise. That kills the direct child only, and the Popen context exit closes the pipes.
+- remedies.py:294-313: update and topgrade are already kind "user", timeout 3600, so the everyday hazard is pre-existing.
+- gui.py:1452/1465: self.firing = True; threading.Thread(target=run, daemon=True).
+- gui.py:342-344: MaintPanel(Gtk.Application) with application_id and no hold(); gui.py:59 is GTK 4.0; no set_hide_on_close anywhere. So close() destroys the only ApplicationWindow and the app exits.
+- gui.py:358-361: activate on an already visible window calls close() (waybar on-click "maint-panel", waybar/config:84).
+- gui.py:1806-1810 + panel.py:246-254: Escape and q close the window. gui.py:421: the close button does too. No close path checks self.firing.
+- Detach precedent: gui.py:94-96 _MERGE_ARGV = ["foot","-e","sudo","pacdiff"]; gui.py:1672/1756 subprocess.Popen(..., start_new_session=True).
+- Kernel transaction weight: /var/log/ratio-upgrade.log:1487-1499 shows "(17/34) Install DKMS modules" (zfs for two kernels), then "(21/34) Updating linux initcpios".
+
+Adjacent fact, not part of this finding: doctor.py:169-170 stamps topgrade_run on any exit 0 when rid == "topgrade". If TOPGRADE's argv becomes the script (which exits 0 even when it deferred packages), that stamp contradicts the stamp-only-on-empty decision at L128 unless Phase 2 removes it.
+:END:
+
+** DONE Phase 3 install step isn't testable by the installer-steps harness, and unit enablement is unspecified
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Phase 3 L161; Phase 1 L155 ('the step that installs the guard'); Architecture L184 ('tested-by-inspection')
+
+Phase 3 says 'Installer step + unit file + the /var/lib/archsetup/ flag directory. Tests in tests/installer-steps/'. It names no function and no location for the unit source. Nothing says how the unit gets pulled into boot: there is no [Install]/WantedBy and no enable step.
+
+Risk: The 'tested-by-inspection' ordering claim has no harness to land in. A unit with no WantedBy never runs, so the manual test fails for reasons unrelated to the design.
+
+Recommended change: In Phase 3 (L161), after "non-fatal to every session target", add: "[Install] WantedBy=multi-user.target, enabled by the installer step (wanted, never required, so its failure can't fail the target)". Replace "Tests in =tests/installer-steps/= for the install step" with: "Tests in =tests/installer-steps/=: a source-inspection test in the style of test_pacman_hook_order.py asserting the unit text carries ConditionPathExists on the flag, Before=getty@tty1.service, an explicit TimeoutStartSec and WantedBy=multi-user.target, and that the installer enables the unit". At L184, change "tested-by-inspection" to "pinned by that installer-steps source-inspection test". Don't name the function and don't decide the network question in this edit. Separately, fix the stale phase numbers at L187 and L200 so the installer-step tests belong to Phase 3 and Phase 1's tests are the archsetup pytest beside the guard.
+
+Optional, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: modified, folded into Implementation phases, Readiness dimensions, Testing. Partially confirmed; this follows the narrowing that the harness can source-inspect inline heredocs and no function is named. The assertion list grows to every directive the glossary settles. It replaces F32's separate static unit test, so there is one such test.
+:EVIDENCE:
+- Spec L69 lists ConditionPathExists, Before=getty@tty1.service, TimeoutStartSec and ExecStart. Spec L161 repeats them, adds "non-fatal to every session target", and says "Tests in tests/installer-steps/ for the install step". Neither line has an [Install] section or an enable step. Confirmed.
+- archsetup:2534-2655: the guard script copy and the 10-hypr-live-update-guard.hook heredoc are inline in hyprland(), next to pacman_install calls. Confirmed.
+- tests/installer-steps/test_pacman_hook_order.py:25-36 (written_hooks parses the hooks the source writes) and :42-45 (setUpClass reads the whole archsetup file) and :53-60 (asserts on the hook filename written inside hyprland()): a source-inspection test that already covers content inline in hyprland(). test_clone_user_repos.py, test_configure_service_discovery.py, test_install_camera_passthrough_rules.py and test_orchestrators.py also read the source text. This refutes "can't be extracted / no harness to land in".
+- More than 25 installer-steps tests use the sed -n '/^name() {/,/^}/p' extraction pattern, so convention already steers an implementer toward a top-level function. Spec L161 never mandates inline placement.
+- archsetup:2286-2299 (zfs-replicate.service: Type=oneshot, [Install] WantedBy=multi-user.target) and archsetup:2315 (installer runs systemctl enable on the unit set up in that block): an in-repo precedent for a oneshot unit written by heredoc and then enabled.
+- Live ratio: getty@.service has Before=getty.target. /etc/systemd/system/getty.target.wants/getty@tty1.service exists. multi-user.target Wants getty.target. So WantedBy=multi-user.target puts the oneshot in the same boot transaction as getty@tty1, and Before= takes effect.
+- Live ratio: NetworkManager-wait-online.service is enabled (matches the finding), but the network/pre-download question belongs to the separate package-source finding.
+- Spec L187 ("installer-step pytest for Phase 2; maint unit tests for Phases 1 and 3") and L200 ("Phase-2 install under tests/installer-steps/") contradict L155, L158 and L161 (Phase 1 = archsetup pytest beside the guard, Phase 2 = dotfiles maint, Phase 3 = installer step).
+:END:
+
+** DONE Documentation plan names no files, and current docs describe removed behaviour
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Readiness 'Documentation plan' L186; Phase 4 L164
+
+'a short "reboot to apply guarded upgrades" note in the maint docs; the installer step self-documents in-comment.'
+
+Risk: After Phase 2, the README and the TOML comment describe a force path that no longer exists and a stamping rule the decision reversed.
+
+Recommended change: Replace the Documentation plan bullet at L186 with: "Documentation plan: Phase 2 rewrites the 'The live-update guard' section of dotfiles maint/README.md. It drops press-again/--force, says UPDATE/TOPGRADE run upgrade-guarded (topgrade with --disable system,git_repos,containers), and describes the 'N deferred' row. The same phase updates the [updates] guard_patterns comment in archsetup configs/maintenance-thresholds.toml to say the patterns are the panel's display mirror of the hook, pinned by a test. Phase 4 adds the arm → reboot → console upgrade flow to that README section. The installer step self-documents in-comment." Add one clause to Phase 2 (L158): "and remove =maint fix --force=, which only served the guard override." Leave the guard banner and the wrapper paragraph alone in this finding. The wrapper-stamps-on-the-script's-own-topgrade-call conflict with L128 should be raised as its own correctness finding.
+
+Optional, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: modified, folded into Implementation phases, Readiness dimensions. Partially confirmed; this follows the narrowing that the guard banner and the wrapper paragraph stay untouched. One detail is fixed: the README gives the space-separated --disable form (F02), because the recommended text quotes the comma form, which topgrade 17.12.2 rejects. The TOML comment edit happens with F21 in Phase 1, since both files are archsetup's.
+:EVIDENCE:
+Spec L158 (Phase 2): "the press-again-to-force sentinel wrap goes away". L155: topgrade runs as =--disable system,git_repos,containers -y=. L186: "Documentation plan: a short 'reboot to apply guarded upgrades' note in the maint docs". L164: "Document the flow".
+
+~/.dotfiles/maint/README.md:54-58:
+ "The panel arms with override wording (press again to force); the CLI equivalent is =--force= ... Topgrade always runs with =--disable git_repos=."
+Only README.md and src/ are present in ~/.dotfiles/maint, so the README is the only maint doc.
+
+configs/maintenance-thresholds.toml:63-64:
+ "a hit arms UPDATE/TOPGRADE instead of running ('press again to run anyway — or apply from a TTY')"
+
+Current code that Phase 2 removes:
+- doctor.py:216-242 (guard refusal, then force-wrapped sentinel)
+- remedies.py:308 (=topgrade --disable git_repos -y=)
+- cli.py:265-267 (--force help: "run a guarded remedy despite the live-update guard")
+
+Overstated parts:
+- Wrapper: the spec never changes it. Wrapper lines 40-42 stamp on rc 0, and README:60-64 matches that. ~/.local/bin/topgrade -> .dotfiles/hyprland/.local/bin/topgrade, and ~/.local/bin is first on PATH.
+- Banner: AC8 L174 says the hook is unchanged, and Non-goal L49 says the guard stays exactly as strict. The hook's Exec is /usr/local/bin/hypr-live-update-guard (archsetup:2648), so editing the banner edits what the hook runs.
+:END:
+
+** DONE Proof-of-concept 'exact sequence' did not hold kernels or zfs-dkms
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Design L65 (proof-of-concept sentence); history L213
+
+'Proof of concept: this exact sequence, run by hand on ratio on 2026-08-25 while Hyprland was live, resolved 724 of 730 pending packages ... with the six guard hits deferred.'
+
+Risk: This overstates the evidence: the kernel hold and the containers disable were never exercised. Under v1, the same day would defer 10 or more packages, not 6.
+
+Recommended change: In Design L65, replace "Proof of concept: this exact sequence, run by hand on ratio on 2026-08-25 while Hyprland was live, resolved 724 of 730 pending packages (Emacs 31.1 among them) with the six guard hits deferred" with "Proof of concept: the guard-set half of this sequence, run by hand on ratio on 2026-08-25 while Hyprland was live and before the kernel hold and the containers disable were added, resolved 724 of 730 pending packages (Emacs 31.1 among them) with the six guard hits deferred. That run upgraded linux, linux-lts and their headers live, so under v1 the same day would have deferred ten". Keep the trailing qemu-block-gluster clause as is. Leave history L213 unchanged.
+
+Optional, not blocking. Verification: confirmed.
+Disposition: modified, folded into Design. Confirmed. F04, which I accepted, puts zfs-dkms in v1's held set, and zfs-utils follows through the closure. Both moved 2.4.3 → 2.4.4 in that run, so the earlier 'drop the zfs-dkms clause' narrowing no longer holds. The count becomes 'at least twelve'; it is a lower bound because that day's closure can't be reconstructed.
+:EVIDENCE:
+- Spec L65: "Proof of concept: this exact sequence, run by hand on ratio on 2026-08-25 while Hyprland was live, resolved 724 of 730 pending packages (Emacs 31.1 among them) with the six guard hits deferred".
+- Spec L65 also defines that sequence as including "plus the *kernel set* ... held on every everyday run" and "topgrade --disable system,git_repos,containers -y".
+- Spec L135: the kernel set is held "on every everyday run, on both machines".
+- Spec L204: containers was added on 2026-10-05.
+- =/var/log/ratio-upgrade.log= L7-12: exactly six "ignoring package upgrade" lines (aquamarine, hyprland, hyprutils, mesa, vulkan-radeon, wayland).
+- =/var/log/ratio-upgrade.log= L16: "Packages (724)".
+- =/var/log/pacman.log= L20243: "upgraded linux (7.1.5.arch1-2 -> 7.1.9.arch1-2)".
+- =/var/log/pacman.log= L20261: "upgraded linux-headers".
+- =/var/log/pacman.log= L20262: "upgraded linux-lts (6.18.41-1 -> 6.18.46-1)".
+- =/var/log/pacman.log= L20263: "upgraded linux-lts-headers".
+- =/var/log/pacman.log= L20503-20504: zfs-utils and zfs-dkms 2.4.3 -> 2.4.4.
+- =/var/log/ratio-upgrade.log= L1488-1489: "dkms install --no-depmod zfs/2.4.4 -k 7.1.9-arch1-2" and "-k 6.18.46-1-lts".
+- =/var/log/ratio-upgrade.log= L1496 onward: the initramfs is rebuilt for the new kernels.
+- The log ends at the pacman post-hooks. =systemctl cat ratio-upgrade.service= returns "No files found" (it was a transient unit).
+:END:
+
+** DONE Summary and Design principle describe only the first draft
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Summary L28; Goals L43-46; Design opening L61, L63; Alternative D heading L90
+
+The Summary and Goals cover only the completion path. Design L61 says 'the ordinary full sweep stays a normal live topgrade run' and 'apply the guarded system upgrade with Hyprland down, once, and stamp it'. L63 says 'Three pieces, at two altitudes' but never lists them. D is labeled '(this spec)'.
+
+Risk: A reader who skims the Summary and the opening principle gets the superseded design before reaching the decisions.
+
+Recommended change: This is the smallest edit set; the first two items carry the substance.
+
+1. Summary L28: after "...record the freshness stamp", add: "The everyday path is a live split run (upgrade-guarded) that applies everything the guard would not block, holds the GPU/compositor and kernel sets, and reports what it deferred. Landing that deferred set is a dedicated, gated session that finishes at a boot-time oneshot."
+
+2. L61: replace "while the ordinary full sweep stays a normal live =topgrade= run" with "while everything else, the ecosystem sweep included, runs live through the split script, which defers the guarded and kernel sets".
+
+3. Goals: add one bullet: "An everyday UPDATE/TOPGRADE that succeeds live by deferring the guarded and kernel sets, and surfaces the deferred set durably."
+
+4. Optional: L63, drop "at two altitudes"; L90, relabel D "(this spec's completion step)".
+
+Optional, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: modified, folded into Summary, Goals and Non-Goals, Design, Alternatives. Partially confirmed; this follows the narrowing. The two optional edits are taken, and the wording uses the glossary's set names, so the DKMS set (F04) is named beside the kernel set.
+:EVIDENCE:
+- Spec L28 (Summary): "This spec designs a safe path to actually complete a guarded upgrade, and makes that completion record the freshness stamp". It does not mention the split live run.
+- Spec L43-46 (Goals): four bullets, all about completion, the boot path, or the installer. None covers the everyday live UPDATE.
+- Spec L61: "...while the ordinary full sweep stays a normal live =topgrade= run."
+- Spec L158 (Phase 2): "UPDATE and TOPGRADE levers change their =argv= to the script".
+- Spec L65: the script runs pacman -Syu --ignore=..., then yay -Sua, then topgrade --disable system,git_repos,containers -y.
+- git show a50fb3b has the identical Summary at draft L25, Goals at draft L40-43, and the L61 sentence at draft L58. Draft L60 is "Three pieces, at two altitudes." with nothing after it, but current L63 appends "— but the everyday gesture is not a reboot. It is a normal live update that simply leaves the dangerous few behind." So L63 has been edited and is not verbatim.
+- Current L65/L67/L69 are three bold-labelled paragraphs (split script, user, implementer).
+- Mitigations already in the spec: L13 (history line naming the live split upgrade as the everyday path), L51 and L55 (the split script named as v1), L167 (acceptance criterion for the live UPDATE).
+- Labels: L90 "D. ... (this spec)" vs L95 "E. ... (this spec's everyday path)".
+:END:
+
+** DONE ZFSBootMenu fallback leaves the separate pacman-db dataset out of sync
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Risks L196; Acceptance L169
+
+The spec treats booting the pre-pacman snapshot from ZFSBootMenu as a complete recovery for a failed kernel change on velox.
+
+Risk: After a fallback boot, /usr, /boot and the modules are back on the old kernel, but the pacman db still records the new kernel and zfs-dkms as installed. checkupdates then shows no pending kernel and the panel's deferred count drops it, so the machine silently runs a kernel the db doesn't know it has.
+
+Recommended change: Append one sentence to Risks L196, and carry it into the Phase 4 flow docs: "The pre-pacman snapshot covers zroot/ROOT/default only. The pacman db (zroot/var/lib/pacman) and the cache (zroot/var/cache) are separate datasets and stay current. So after booting the snapshot from ZFSBootMenu, re-register the old kernel set (and zfs-dkms, if that transaction moved it) with pacman -U --dbonly from /var/cache/pacman/pkg before the next upgrade-guarded run. Otherwise the db reports the new kernel as installed and the deferred row drops it." Do not offer "roll back zroot/var/lib/pacman to its matching snapshot" as a remedy: the hook never snapshots that dataset. A sturdier fix, outside this spec's scope, would have zfs-pre-snapshot snapshot both datasets atomically in one zfs snapshot call.
+
+Optional, not blocking. Verification: confirmed.
+Disposition: modified, folded into Risks, Implementation phases. Confirmed. zfs-utils is named beside zfs-dkms because F04 moves them as a pair.
+:EVIDENCE:
+- Spec L196: "the 05-zfs-snapshot hook snapshots it before every transaction, and ZFSBootMenu can boot that snapshot. The pacman cache also keeps the previous kernel and zfs-dkms for a downgrade." Spec L134: "It is survivable — ZFSBootMenu can boot the pre-pacman snapshot". Spec L169 requires only that the snapshot is bootable. Spec L197: "The panel's deferred row is the reminder". Nothing in the spec mentions the db (grep for snapshot, database, var/lib, downgrade).
+- scripts/zfs-pre-snapshot:11: DATASET="${ZFS_PRE_DATASET:-$POOL/ROOT/default}". Line 30: zfs snapshot "$DATASET@$SNAPSHOT_NAME", one dataset, no -r.
+- ~/code/archangel/installer/archangel:736-738: zfs create -o mountpoint=/var/cache ...; zfs create -o mountpoint=/var/lib -o canmount=off ...; zfs create -o mountpoint=/var/lib/pacman "$POOL_NAME/var/lib/pacman". Present since archangel's initial commit 2b691a0. archangel .ai/notes.org:107 also documents zroot/var/lib/pacman as the "Package database" dataset.
+- Velox was rebuilt from archangel on 2026-08-13/14 (.ai/sessions/2026-08-14-10-02-velox-mainboard-swap-reinstall-and-bringup.org:1079, "full reinstall via archangel+archsetup"). That makes the pre-reinstall claim at docs/design/2026-07-15-velox-boot-failure-handoff.org:21 and todo.org:1762 (no separate var/lib/pacman) stale.
+- /etc/pacman.conf:13: #DBPath = /var/lib/pacman/ (the default is in effect).
+- pacman -U --help lists "--dbonly only modify database entries, not package files".
+- Weak point in the original evidence: archsetup:2269 is sanoid config, not proof the dataset exists.
+:END:
+
+** DONE topgrade_run 'never written' is false on ratio
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Problem L32
+
+'Absent, the probe returns WARN ... the file is simply never written.'
+
+Risk: Cosmetic. The diagnosis still holds.
+
+Recommended change: Line 32: replace "the file is simply never written." with "the file is almost never refreshed: on ratio it was last written 2026-07-08, the day the wrapper landed, so the probe grades its age (88 days on 2026-10-05) against topgrade_warn_days = 14 and warns. The absent-file branch only fires on a machine that has never stamped." Line 34: change "It is never written because" to "It is rarely refreshed because". Line 36 (optional, same edit): change "The metric has read stale ever since." to "The metric had already gone stale in late July and stayed stale." Do not add a claim about velox, which cannot be checked while it is offline.
+
+Optional, not blocking. Verification: confirmed.
+Disposition: accepted, folded into Problem.
+:EVIDENCE:
+- Spec docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org:32: "...the file is simply never written."
+- Spec line 34: "It is never written because topgrade rarely exits 0..."
+- Spec line 36: "...so nothing stamped. The metric has read stale ever since."
+- On ratio, ls ~/.local/state/maint/ shows topgrade_run.json (69 bytes, dated 8 Jul 07:46). Its contents are {"written_at": 1783514783.122665, ...}. date -d @1783514783 gives Wed Jul 8 07:46:23 AM CDT 2026.
+- ~/.dotfiles/maint/src/maint/cache.py:35-42: get() returns (data, time.time() - written_at), or None only on OSError/ValueError/KeyError/TypeError. There is no TTL.
+- ~/.dotfiles/maint/src/maint/probes/updates.py:114-131: the None branch is the "no topgrade run recorded" WARN. Otherwise it computes days = age // 86400 and calls grade_high(days, topgrade_warn_days).
+- configs/maintenance-thresholds.toml:60 sets topgrade_warn_days = 14.
+- maint status (live) prints "warn topgrade_age Topgrade freshness: 88".
+- git log in ~/.dotfiles: commit 9b32a28 dated 2026-07-08 added hyprland/.local/bin/topgrade, the same day as the stamp.
+:END:
+
+** DONE The autologin claim holds only on velox
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Design L67, L69; Architecture L184
+
+The boot unit runs 'before the autologin shell starts Hyprland'.
+
+Risk: Low. Phase 4's 'confirm the ratio path matches' would find a password prompt, not autologin.
+
+Recommended change: Describe the tty1 login without depending on autologin, and keep the Before=getty@tty1.service ordering exactly as it is.
+
+- L67: replace "before the autologin shell starts Hyprland" with "before tty1's login shell can start Hyprland".
+- L69: replace "so it completes before autologin execs Hyprland" with "so it completes before the tty1 login (autologin where the installer enabled it, a password prompt otherwise), and so before ~/.profile.d/99-hyprland-autostart.sh can start Hyprland".
+- L184: replace "getty autologin ordering" with "getty@tty1 login ordering".
+- L192: replace "ahead of autologin" with "ahead of the tty1 login".
+
+Optional, not blocking. Verification: confirmed.
+Disposition: accepted, folded into Design, Readiness dimensions, Risks.
+:EVIDENCE:
+Spec docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org:
+- L67: "before the autologin shell starts Hyprland"
+- L69: "ordered Before=getty@tty1.service so it completes before autologin execs Hyprland"
+- L184: "getty autologin ordering"
+- L192: "the unit sits ahead of autologin"
+- L164: "Confirm the ratio path matches."
+
+Live on ratio (uname -n = ratio):
+- /etc/systemd/system/getty@tty1.service.d/ does not exist.
+- systemctl cat getty@tty1 shows the stock ExecStart=-/usr/bin/agetty --noreset --noclear - ${TERM}, with no --autologin.
+- /etc/systemd/system/getty.target.wants/getty@tty1.service is the stock getty@.service.
+- loginctl session 2 has TTY=tty1, Service=login, Type=wayland, Leader=1963.
+- Process chain: 1963 "login -- cjennings" → 2012 "-zsh" → 2033 /usr/bin/start-hyprland → 2038 Hyprland.
+- Hyprland is started by ~/.profile.d/99-hyprland-autostart.sh, which is guarded by [ "$XDG_VTNR" = "1" ] and runs start-hyprland. ~/.zshrc:10 sources ~/.profile, and ~/.profile loops over ~/.profile.d/*.sh.
+
+Installer archsetup configure_autologin (~L951-1004):
+- Writes getty@tty1.service.d/autologin.conf only when enable_autologin=true, or when the root is encrypted and the user answers yes.
+- A non-encrypted root returns 0 silently and writes no autologin.
+
+Velox autologin: could not be verified. The machine is offline, and no doc in the repo records it.
+:END:
+
+** DONE Strip 'pending' count and QUEUE don't distinguish held packages
+CLOSED: [2026-10-05 Mon 09:20]
+Where: Phase 2 L158; AC L167
+
+After a successful split run, the strip still counts the held set as pending, and QUEUE lists those packages without marking them.
+
+Risk: A successful UPDATE leaves 'N pending' on the strip next to 'N deferred', which reads as if the run failed. This is cosmetic, because updates_repo warns only at 50 or more.
+
+Recommended change: Append one sentence to Phase 2 (L158): "QUEUE (results wall and =maint queue=) tags any package in the deferred-set state file =[HELD]=, the strip's pending cell shows the held share (=N pending · M held=), and =cve_queued= excludes held packages so the CVE badge turns red only for advisories UPDATE can close. This is the 'run will defer' badge named in the script-ownership decision." No acceptance-criterion change is needed. Optionally extend L167 to read "...the panel shows the exact deferred set, marked held in QUEUE".
+
+Optional, not blocking. Verification: confirmed.
+Disposition: modified, folded into Implementation phases, Acceptance criteria, Testing. Confirmed, with two changes. Its cve_queued clause is the same change as F36 and is carried there. And QUEUE's [HELD] tag is not Decision 3's 'run will defer' badge: that badge is F22's pre-run arm line, built from the TOML patterns, while [HELD] is a post-run mark built from the record.
+:EVIDENCE:
+- Spec L158 (Phase 2): the only panel change is a new probe plus the "N deferred — apply on reboot" row. L128: the deferred set is rendered "as its own state". L121: "the badge that says a run will defer", which no phase defines. L167 (AC): "the panel shows the exact deferred set".
+- ~/.dotfiles/maint/src/maint/doctor.py:309-316: =if rid in ("update", "topgrade") and swap_ok:= calls =p_updates.scan_net(th)= to refresh the pending cache after a fire.
+- probes/updates.py:150-158: scan_net writes checkupdates output to the updates_repo cache. --ignore'd packages are still listed by checkupdates.
+- probes/updates.py:53-67: the repo_updates count is len(updates_repo), graded against pending_warn. archsetup configs/maintenance-thresholds.toml:58 sets =pending_warn = 50=.
+- gui.py:447: the strip cell caption is ("updates_repo", "pending") with no held split.
+- viewmodel.py:479-483: queue row state is only "cve", "reboot" (from _needs_reboot: _REBOOT_EXACT includes mesa, wayland, linux, linux-lts; prefix nvidia) or "note". cli.py:189: tags = {"cve": "[CVE] ", "reboot": "[BOOT] ", "note": " "}. gui.py:468-486 (_on_show_queue) uses the same rows.
+- viewmodel.py:503-514 (cve_queued), whose docstring says the number turns red "only when running UPDATE would actually close an advisory". It counts every name in updates_repo/updates_aur, held ones included. It is used by gui.py:641-648 for both the faceplate badge and the strip.
+:END:
+
+** DONE F05: gate clause (b) checks kernels that stage 1 just removed, so a kernel and DKMS change in the same run always fails the gate :blocking:
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Design 'The gate' L137 (b) and L140 (per-kver 'pkgbase exists'); Phase 1 gate list L2147 (b) and L2151; Phase 1 test L2197; Testing map L2489. Compare (c) at L138/L2148, which has the existence filter that (b) is missing.
+
+Clause (b) reads: 'if any DKMS-set package's version changed, every <kver> for which the pre-run dkms status showed a module installed'. Unlike (c), it has no 'that still have /usr/lib/modules/<kver>/vmlinuz' filter. The per-kver check then requires '/usr/lib/modules/<kver>/pkgbase exists'. The only test for (b) covers a DKMS-only change (L2197).
+
+Risk: Every --complete that moves a kernel together with zfs-dkms fails the gate on '<oldkver>: pkgbase', even when the new kernel and its modules are fine. The run exits 4 and nothing is armed. The row goes CRIT with 'do not reboot' and REBOOT is hidden. That is a false alarm on the exact path the gate exists for. A second --complete clears it only because (c) drops the vanished kver. The Phase 1 tests don't catch it.
+
+Recommended change: L137 and L2147: replace (b) with "(b) if any DKMS-set package's version changed, every <kver> for which the pre-run dkms status showed a module installed and that still has /usr/lib/modules/<kver>/vmlinuz after stage 1. A kernel whose version changed is covered by (a) under its new kver."
+
+L239 (Decision): change "or a DKMS-set package moved and that kernel had a module installed before the run" to "or a DKMS-set package moved and that kernel had a module installed before the run and is still installed after it".
+
+Add a test bullet after L2197 (Phase 1 Gate) and after L2489 (AC4 testing map): "one stage 1 moves a kernel-set package and zfs-dkms together. The old kver, which had zfs installed in the pre-run dkms status, is not on the check list. The new kver and every surviving kver with zfs installed are checked. With good fakes the gate passes."
+
+Blocking. Verification: confirmed.
+Disposition: modified, folded into Implementation phases, Decisions, Design, Testing. Both sheets fix this at the root instead of adding R01's existence filter to clause (b). The gate entry is keyed by pkgbase and resolved to the kvers present after stage 1. A kver the run removed then can't reach the gate by any path, on the first run or a re-check. The finding's test is adopted as written.
+:EVIDENCE:
+Spec L137 and L2147: "(b) if any DKMS-set package's version changed, every <kver> for which the pre-run dkms status showed a module installed". There is no existence filter. Compare (c) at L138 and L2148: "...that still have /usr/lib/modules/<kver>/vmlinuz".
+
+Spec L140 and L2151: the first per-kver requirement is "/usr/lib/modules/<kver>/pkgbase exists".
+
+Spec L130 and L2085: the dkms status is saved before stage 1. L2086: stage 1 lands the kernel set, the DKMS set and every other pending package except the GPU closure.
+
+Only the DKMS-only test exists: L2197 and L2489.
+
+Live output on ratio:
+- "pacman -Qqo" on /usr/lib/modules/7.2.7-arch1-1/pkgbase, /usr/lib/modules/7.2.7-arch1-1/vmlinuz, /usr/lib/modules/6.18.54-1-lts/pkgbase and /usr/lib/modules/6.18.54-1-lts/vmlinuz prints "linux linux linux-lts linux-lts". Each kernel package owns its kver's pkgbase, so an upgrade deletes it.
+- "dkms status" prints "zfs/2.4.4, 6.18.54-1-lts, x86_64: installed" and "zfs/2.4.4, 7.2.7-arch1-1, x86_64: installed".
+- "pacman -Q" shows linux 7.2.7.arch1-1 and linux-lts 6.18.54-1. The pkgrel is part of the kver.
+- /usr/share/libalpm/hooks/70-dkms-upgrade.hook removes the old modules PreTransaction (When = PreTransaction, Exec = dkms -D remove).
+
+Worked example: linux 7.2.7.arch1-1 moves to 7.2.8.arch1-1 and zfs-dkms moves in the same stage 1. (a) adds 7.2.8-arch1-1. (b) adds 7.2.7-arch1-1 and 6.18.54-1-lts. 7.2.7-arch1-1/pkgbase no longer exists, so the gate fails with "7.2.7-arch1-1: pkgbase" and exits 4.
+:END:
+
+** DONE F19: the required zfs.ko check runs lsinitcpio unprivileged against root-only 0600 initramfs images, so the gate fails on every ZFS-root --complete :blocking:
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Design L135, L140; Phase 1 L2027, L2144, L2154; sudo lists L80, L2027, Readiness Security L2385
+
+F19 made the lsinitcpio zfs.ko check required on a ZFS root (L140, L2154). kernel-modules-check is described as a stateless reader that --complete runs after stage 1. --complete runs as the invoking user and refuses EUID 0. Nothing gives the gate root: the sudo lists (L80, L2027, L2385) name only pacman -Sy/-Su/-S/-Sw, informant, the flag writes and systemctl reboot, and L2027 says ExecStartPre=+ is 'the only root step outside sudo'.
+
+Risk: On velox (ZFS root), the only machine where the gate matters most, every --complete with a non-empty check list fails the zfs.ko item. It exits 4 and writes an open gate failure; the row goes CRIT with REBOOT hidden, and nothing ever arms. Kernel and DKMS updates can never land through the designed path. The standalone 'no kver' TTY diagnostic fails the same way.
+
+Recommended change: Five edits:
+- L140 and L2154: replace "lsinitcpio of that image lists a zfs.ko" with "sudo lsinitcpio of that image (mkinitcpio writes images root 0600) lists a zfs.ko". Add: "if lsinitcpio itself fails, the item is 'cannot read <image>', not 'zfs.ko missing'". Add sudo to the gate's faked-on-PATH list on L140.
+- L80, L2027 and L2385: add "sudo lsinitcpio <image> in kernel-modules-check" to the sudo enumerations. The gate's standalone TTY diagnostic gets the same NOPASSWD sudo.
+- L2193 and L2480 (gate tests): add a case asserting the lsinitcpio call goes through the fake sudo, plus a case where an unreadable image fails as "cannot read", not as a missing zfs.ko.
+- L2412 (rollout step a): add "on a ZFS root, confirm kernel-modules-check <running kver> reads the real image without a permission error". Run it without --since, or with a since older than the image.
+- Optional alternative: have --complete run "sudo kernel-modules-check ..." instead. Sudo-ing only lsinitcpio inside the gate keeps the standalone diagnostic working the same way, so I'd do that.
+
+Blocking. Verification: confirmed.
+Disposition: accepted, folded into Implementation phases, Readiness dimensions, Testing.
+:EVIDENCE:
+- Spec L140 and L2154: "on a ZFS root (findmnt -no FSTYPE / is zfs), lsinitcpio of that image lists a zfs.ko". No sudo.
+- Spec L80, L2027 and L2385: the full sudo list is pacman -Sy/-Su/-S/-Sw, informant read --all, the flag writes and systemctl reboot. L2027 adds: "The boot unit's ExecStartPre=+ is the only root step outside sudo."
+- /usr/bin/mkinitcpio (pacman -Q: mkinitcpio 42.1-1) lines 1219-1220: "# Set umask to create initramfs images and unified kernel images as 600" / "umask 077".
+- ls -la /boot on ratio: initramfs-linux.img, initramfs-linux-lts.img, initramfs-linux-lts-strix.img and initramfs-linux-lts-fallback.img are all ".rw------- root". The vmlinuz-* files are 0644.
+- Running "lsinitcpio /boot/initramfs-linux.img" as uid=1000(cjennings) prints "==> ERROR: Unable to read file: '/boot/initramfs-linux.img'" and exits 1.
+- These work unprivileged: "dkms status" lists zfs/2.4.4 entries, and "stat -c %Y /boot/initramfs-linux.img" returns the mtime. Only the content read fails.
+- Spec L238: velox has /boot inside the encrypted ZFS root dataset. archsetup:3461 (tighten_efi_permissions) keeps boot images non-world-readable on purpose. A grep found no chmod or umask on initramfs anywhere in archsetup or scripts/.
+- Tests fake lsinitcpio (L2163, L2193, L2454, L2480). Rollout step (a) only confirms "upgrade-guarded --dry-run exits 0" (L2412), and --dry-run uses no sudo and does not run the gate (L80, L2041).
+- Velox was not checked directly because it is offline. The conclusion rests on the shared mkinitcpio umask.
+:END:
+
+** DONE Nothing marks a --complete kernel transaction as unverified before the gate runs, so in-progress, interrupted and aborted stage-1 runs can reboot velox with no initramfs :blocking:
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Design L128-133 (--complete order), L136 and Phase 1 L2146 (gate check list (a)), L168 and Phase 1 L2132 (interruption), Phase 1 L2083-2089, Phase 2 L2240 (REBOOT is state-driven), AC4 L2344
+
+Stage 1 runs =sudo pacman -Su --noconfirm --ignore=<GPU closure>=. The gate runs afterwards 'whatever stage 1's exit code', and its check list (a) holds only 'each kernel-set package whose installed version changed in this run'. The INT/TERM/HUP trap 'waits for the running child to exit, then writes result interrupted with the step and the remedy, and exits 130', so on that path no gate runs. The record's gate field is written only when a gate fails. REBOOT is hidden only while gate.ok is false, shown while the flag exists, and otherwise follows reboot_required. A stale arm flag is removed only after the gate or at the GPU step. A later --complete with 'nothing pending and no gate failure open' prints 'nothing to complete' and rewrites the record empty.
+
+Risk: On velox (ZFS root, one kernel), three paths reach an unbootable machine with no 'do not reboot':
+1. A normal stage 1: once 60-depmod removes the running kernel's module directory, the next 30 s panel refresh shows REBOOT. At that moment zfs is still building and /boot has no initramfs, because it was removed PreTransaction.
+2. Closing the APPLY window or logging out (SIGHUP), or a Ctrl+C that reaches the script: pacman stops, PostTransaction hooks are skipped, and /boot/vmlinuz-linux-lts and its initramfs stay deleted. The script exits 130 at WARN with no gate. The next --complete either wipes the record ('nothing to complete'), or, with GPU entries still pending, gets an empty check list (no version changed in this run), passes and arms. It then offers a reboot onto a kernel that was never gated.
+3. A commit abort, such as ENOSPC (the spec lists a full disk as a realistic trigger), before the kernel package extracts: the version is unchanged, so list (a) is empty, the gate is skipped, and the result is failed_step pacman at WARN while /boot is empty.
+
+Also, a flag left from an earlier arm keeps 'armed' and REBOOT visible through all of stage 1. Any of these leads to a ZFSBootMenu recovery.
+
+Recommended change: Smallest edit, in Design L128-139 and L168, Phase 1 L2087-2093, L2132 and L2146, Phase 2 L2240 and AC4 L2344:
+
+1. Stage 1 (L130 / L2087). When any kernel-set or DKMS-set member is pending, first =sudo rm -f= the arm flag. Then pre-write the record, in the same pattern as --apply-armed step 2:
+ - gate = {ok: false, pkgbases: [pkgbase of each pending kernel-set member; when a DKMS-set member is pending, every pkgbase the pre-run dkms status showed installed], failed: 'kernel transaction not yet verified', since: T0, at: now};
+ - add pkgbases to the gate schema at L108 / L2127.
+
+2. Check list (a) (L136 / L2146). Replace "whose installed version changed in this run" with "each pkgbase in the open gate entry (or pending at stage-1 start), resolved to the kver whose /usr/lib/modules/<kver>/pkgbase names it". Add to the per-kver requirements: /boot/vmlinuz-<pkgbase> exists. Re-check (c) uses the recorded pkgbases.
+
+3. Trap (L168 / L2132). In --complete, the INT/TERM/HUP trap leaves the open gate entry in place. Its detail reads '<step> interrupted — do not reboot; run upgrade-guarded --complete'. Only a gate pass clears it, which keeps 'nothing to complete' from firing and a follow-up run from arming without a re-check.
+
+4. Tests (Phase 1):
+ - during a fake stage 1, the record has gate.ok false and no flag;
+ - an INT or HUP during stage 1 exits 130 with the gate still open and any earlier flag gone;
+ - a stage-1 failure that leaves the kernel version unchanged but /boot/vmlinuz-<pkgbase> missing exits 4;
+ - a follow-up --complete after an interrupt re-checks and neither arms nor prompts until the check passes.
+
+5. AC4. Add: "From the start of a stage 1 that moves a kernel-set or DKMS-set package until a gate passes, including after an interrupted or failed stage 1, the deferred row is CRIT, REBOOT is hidden, no flag exists, and --complete never prompts for a reboot."
+
+Blocking. Verification: confirmed.
+Disposition: modified, folded into Design, Decisions, Implementation phases, Acceptance criteria, Testing, Risks. I adopted the pre-written unverified state keyed by pkgbases, the /boot/vmlinuz requirement, the trap rule, the CRIT and REBOOT rules, the tests and the AC4 outcome, with three changes. First, any flag is removed once, right after every successful --complete refresh (R48). That makes 'no flag while the gate is open' an invariant rather than a list of cases. Second, the entry is checked in one invocation with the recorded since, not in a separate re-check. Third, R03's rule as written raises a false CRIT when stage 1 fails before committing anything, as on a download, signature or conflict error. /boot is then intact, no snapshot was taken and the initramfs predates T0, so the since items fail. The row would say 'do not reboot' on a machine that boots fine, and keep saying it until a later --complete succeeded. So when this run opened the entry and stage 1 changed no kernel-set or DKMS-set version, the gate runs its structural form without --since. A missing vmlinuz or initramfs, an unreadable image or a missing zfs.ko still fails it, which covers R03's ENOSPC case.
+:EVIDENCE:
+Spec:
+- L130 / L2087-2089: stage 1 is a plain -Su with no prior state write.
+- L131 / L2090: "The gate, whatever stage 1's exit code".
+- L136 / L2146: "(a) for each kernel-set package whose installed version changed in this run".
+- L139: "An empty list skips the gate".
+- L129 / L2086: nothing pending and no gate failure open gives 'nothing to complete' and the record is rewritten empty.
+- L133 / L2099: the prompt fires when "the run armed or changed a kernel-set or DKMS-set package".
+- L168 / L2132: the trap "writes result interrupted ... exits 130", with no gate and no flag removal.
+- L2117: flag removal points are a gate failure, unit not enabled, or nothing GPU-side left.
+- L2240: REBOOT is hidden only while gate.ok is false and shown while the flag exists.
+- L2344-2348: AC4 covers only a failed DKMS build.
+- Phase 1 tests (L2183, L2196-2200) cover none of these cases.
+
+Live system:
+- /usr/share/libalpm/hooks/60-mkinitcpio-remove.hook: Type=Path, Operation=Remove, Target=usr/lib/modules/*/vmlinuz, When=PreTransaction. Its script's remove_kernel runs =rm -f= on the preset's ALL_kver and images (/etc/mkinitcpio.d/linux-lts.preset: ALL_kver=/boot/vmlinuz-linux-lts, default_image=/boot/initramfs-linux-lts.img).
+- man alpm-hooks CAVEATS: "PostTransaction hooks will not run if the transaction fails to complete for any reason."
+- 70-dkms-install and 90-mkinitcpio-install are PostTransaction. 60-depmod is PostTransaction and its script does =rmdir --ignore-fail-on-non-empty= on a kernel dir with no modules.order.
+- strings /usr/bin/pacman shows the "Interrupt signal received" and "Hangup signal received" fragments. libalpm.so.16 has "transaction interrupted", "transaction failed" and "problem occurred while upgrading %s". pacman is 7.1.0.
+- archsetup:2622-2624: "a blocked transaction can remove the current initramfs without reaching the PostTransaction hook that rebuilds it."
+
+maint:
+- probes/packages.py:131-146: required = not os.path.isdir(/usr/lib/modules/<release>).
+- panel.py:506-510: reboot_key_visible returns that value.
+- gui.py:92 _FULL_SECONDS=30. gui.py:1376-1384: _full_tick refreshes unless self.firing.
+- /usr/lib/modules/<running>/ holds unowned modules.alias and related files, so the old dir outlives the package until 60-depmod.
+:END:
+
+** DONE The gate's lsinitcpio check can't read the initramfs as the user, so the gate fails every time on a ZFS root :blocking:
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Design L80 (sudo step list) and L140 (gate: lsinitcpio of /boot/initramfs-<pkgbase>.img must list zfs.ko); Phase 1 L2027 (privilege list), L2154 (gate per-kver checks), L2163 and L2193 (tests fake lsinitcpio on PATH); Readiness Security L2385
+
+upgrade-guarded runs as the invoking user and refuses EUID 0. The steps that go through sudo are listed exhaustively: pacman -Sy/-Su/-S/-Sw, informant read --all, writing and removing the flag, and systemctl reboot. kernel-modules-check is not on that list, so --complete (and the standalone TTY diagnostic) runs it unprivileged. On a ZFS root it requires =lsinitcpio= of /boot/initramfs-<pkgbase>.img to list zfs.ko.
+
+Risk: On velox, every --complete whose check list is non-empty fails the gate at the zfs.ko item. That is every run that moves a kernel or zfs-dkms. It exits 4, records gate.ok false, and never arms. The CRIT row's remedy ('after fixing, run upgrade-guarded --complete') re-runs the same unreadable check, so the gate never closes, no form ever stamps, and the GPU/compositor set can only land by hand. The failure also blames a missing zfs.ko, which points me at a nonexistent DKMS problem on the do-not-reboot path. The standalone =kernel-modules-check= diagnostic fails the same way. This is the machine the gate exists to protect.
+
+Recommended change: Four edits to the spec.
+
+1. Phase 1 L2154 and the matching clause at Design L140: replace the zfs.ko bullet with: "on a ZFS root (findmnt -no FSTYPE / is zfs), sudo -n lsinitcpio of that image exits 0 and lists a zfs.ko with any compression suffix. The images are 0600 root (mkinitcpio sets umask 077), so the read goes through sudo. A non-zero lsinitcpio exit is its own failure item, '<kver>: cannot read /boot/initramfs-<pkgbase>.img', never reported as a missing zfs.ko."
+
+2. Add "lsinitcpio, inside kernel-modules-check" to the sudo step lists at L80, L2027 and Security L2385. Say that the standalone diagnostic uses the same sudo read.
+
+3. Phase 1 tests (L2193) and Testing (L2477-2480): add two gate cases.
+ - The fake lsinitcpio fails, as the real one does on a 0600 image, unless the fake sudo invoked it. The gate must read through sudo and pass.
+ - lsinitcpio exits 1 with no output. The gate fails with the "cannot read" item, not "zfs.ko missing".
+
+4. In the zfs-VM / velox manual check, add a step confirming that a passing --complete actually read the image (the gate output names zfs.ko as found).
+
+Blocking. Verification: confirmed.
+Disposition: accepted, folded into Implementation phases, Readiness dimensions, Testing.
+:EVIDENCE:
+Spec (docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org):
+- L80, L2027, L2385: the sudo lists leave out kernel-modules-check and lsinitcpio.
+- L131 and L2087: "run kernel-modules-check", with no sudo.
+- L140 and L2154: "lsinitcpio of that image lists a zfs.ko with any compression suffix".
+- L2163, L2193, L2454: lsinitcpio is faked on PATH.
+- A grep for 0600, "Unable to read" and "sudo lsinitcpio" in the spec finds nothing.
+
+Live system (ratio, uid 1000):
+- ls -l /boot shows all four initramfs-*.img as .rw------- root (linux, linux-lts, linux-lts-fallback, linux-lts-strix). vmlinuz-* are rw-r--r--.
+- lsinitcpio /boot/initramfs-linux-lts.img prints "==> ERROR: Unable to read file: '/boot/initramfs-linux-lts.img'" and exits 1.
+- /usr/bin/mkinitcpio:1219-1220 reads "# Set umask to create initramfs images and unified kernel images as 600" / "umask 077" (mkinitcpio 42.1-1).
+
+Repo knowledge of the velox case:
+- docs/workflows/system-health-check.org:238: "The sudo is load-bearing: the images are 0600, so an unprivileged lsinitcpio exits 1 with 'Unable to read file' ... looks exactly like a missing module (velox, 2026-09-12)."
+- todo.org:753-757: "Also for the kernel-modules-check gate: reading the initramfs needs root. ... The gate has to run as root and check the exit status, not just the count."
+:END:
+
+** DONE F03: closure parser counts every error line, but each failing probe also prints lines that match neither form, so the literal rule always refuses
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Design 'The dependency closure' L96-L100 ('parses every error line, with or without the leading ::' and 'refuses when a line matches neither form'); Phase 1 Dependency closure L2047-L2050; Phase 1 test L2169; Testing AC2 map L2469-L2474
+
+The landed F03 text says the script 'parses every error line, with or without the leading ::'. Two shapes are classified (forward 'unable to satisfy dependency' and reverse 'installing X (v) breaks dependency'), and 'a line that matches neither form (a conflict, a missing target, a replace)' refuses with exit 3 and failed_step closure. The Phase 1 fake pacman emits only the two shapes.
+
+Risk: Under the literal contract, the error header alone matches neither form and refuses. The forward case's ':: ' skip-prompt lines also match neither form. So every probe that fails refuses, including the hyprutils/hyprlang and zfs-utils pin cases that AC2 and AC3 depend on, and the closure never grows. Every soname day becomes an exit-3 refusal. An implementer has to guess which lines to ignore. The Phase 1 fake emits only the two clean shapes, so the tests pass either way.
+
+Recommended change: L96-L100 and L2047-L2050: replace "parses every error line, with or without the leading ::" with this text:
+
+"reads the probe's stdout and parses each dependency line there, stripping an optional leading ':: '. Each line is classified by the two forms below. The probe's stderr preamble 'error: failed to prepare transaction (could not satisfy dependencies)' is expected on every failing iteration and is not classified. A preamble with any other reason in the parentheses refuses."
+
+Then change "a line matches neither form" to "a stdout dependency line matches neither form".
+
+L2169 and L2469: change the fake pacman so it replays pacman 7.1's verbatim failing -Sup transcript: the preamble on stderr, the "::" dependency lines on stdout, and exit 1. Add one assertion that a failing probe with the preamble and only well-formed lines adds names and does not refuse.
+
+Do not add the finding's ignore-list of "warning: ..." lines and skip-prompt lines. pacman suppresses all of them in print mode.
+
+Should-fix, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: accepted, folded into Implementation phases, Design, Testing.
+:EVIDENCE:
+Spec text:
+- L96 and L2047: "parses every error line, with or without the leading ::".
+- L100 and L2050: refuses "when a line matches neither form".
+- L2169 and L2469: the fake pacman "emits both message shapes". No transcript, preamble or stream split is specified.
+- grep for "failed to prepare|neither form|skip the above|cannot resolve" finds only L96, L100 and L2050. The preamble is never mentioned.
+
+Installed pacman: 7.1.0.r9.g54d9411-2 (libalpm 16.0.1).
+
+pacman source, callback.c:424-440 (pacman 7.1.0 source): in print mode, cb_question answers INSTALL_IGNOREPKG and REPLACE_PKG with 1, answers every other question with 0, and returns before the REMOVE_PKGS branch at L493-512 that prints the "cannot be upgraded" and "Do you want to skip" lines.
+
+Empirical runs used a throwaway fixture db, with no system db and no sudo. The command was:
+LC_ALL=C pacman --config <fixture> --dbpath <fixture> -Sup --noconfirm --print-format '%n' ...
+
+- Forward case (--ignore=hyprutils, with hyprlang 2-1 depending on libhyprutils.so=13-64): exit 1.
+ - stdout: ":: unable to satisfy dependency 'libhyprutils.so=13-64' required by hyprlang"
+ - stderr: "error: failed to prepare transaction (could not satisfy dependencies)"
+ - No warning or skip-prompt lines, even though IgnorePkg=bridge-utils is pending and hyprutils is --ignore'd.
+- Reverse/orphan case: exit 1.
+ - stdout: ":: installing libbar (2-1) breaks dependency 'libbar.so=1-64' required by orphan"
+ - stderr: the same preamble.
+- Converged case: exit 0. stdout carries only the names; stderr is empty.
+:END:
+
+** DONE F11: the timeout 60 hardening bounds only the explicit informant call, and the 00-informant hook repeats the same unbounded fetch inside the transaction
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Design everyday step 3 L85; boot form step 4 L160; Phase 1 everyday step 3 L2063 and --apply-armed step 4 L2102; Decision 6 consequences L250; AC7 L2357-2358; Risks 'Arch news' L2448 (F11 disposition L682 states the intent: 'timeout 60 bounds it, and the run carries on either way')
+
+Most of the F11 resolution is in the body and correct:
+- the call is timeout 60 sudo informant read --all, run only when command -v informant succeeds, with failure tolerated;
+- yay -Pwq captures the titles first;
+- the sudo/root reasons are stated;
+- the offline fallback is described;
+- AC7, the Readiness note (velox only) and the Phase 1 ordering test are all present.
+
+One gap remains. The body says the timeout 'bounds informant's feed fetch, which has no timeout of its own' and that 'failure, the timeout included, is tolerated' (L85, L2063). For --apply-armed it says 'the hook's informant check sees the same feed, so neither blocks the transaction' (L160, L2102). Risks L2448 says 'informant's AbortOnFail hook no longer stops a run'. Nothing covers what the hook does after the explicit call times out or fails.
+
+Risk: Take a slow or stalled archlinux.org fetch on velox while unread news exists. timeout 60 kills read --all before it saves, and the script carries on into the transaction. Then the 00-informant hook either:
+- stalls the transaction with pacman holding db.lck. At boot that lasts until TimeoutStartSec=20min, ending as interrupted. From the panel it lasts until the 3600 s runner timeout. This is exactly the hang the timeout was added to prevent, moved one step later.
+- or completes, sees one or more unread items, and exits non-zero, so AbortOnFail aborts the transaction.
+
+At boot the arm has already been consumed by ExecStartPre, so the GPU set doesn't land and the run records failed_step boot-transaction. On the everyday path it records failed_step pacman.
+
+On that path AC7 ('unread Arch news wedges no mode') fails, and L160/L2102 ('neither blocks the transaction') and L2448 ('no longer stops a run') are false. The failure is bounded and not unsafe: nothing is swapped, and pacman exits in the PREPARED state releasing db.lck.
+
+Recommended change: Smallest edit, option (b): name the residual and narrow the claims.
+
+1. L160 and L2102: replace "and the hook's informant check sees the same feed, so neither blocks the transaction" with: "When the fetch fails fast (no route or DNS), the hook's informant check also gets an empty feed and passes. When the clear times out or fails, nothing is marked read. Informant's hook then repeats the same unbounded fetch inside the transaction, so it can hold the transaction until TimeoutStartSec, or abort it on unread news (result failed, failed_step boot-transaction, arm consumed). The same abort can happen if the network comes up between the clear and the transaction."
+
+2. L85 and L2063: append: "A failed or timed-out clear leaves informant's hook live for this run's -Su, so the transaction can wait on the same fetch (bounded only by the lever runner's 3600 s) or abort on unread news (failed_step pacman, before any package changes)."
+
+3. L2448: change "no longer stops a run" to "no longer stops a run once the clear succeeds; a failed or timed-out clear leaves the hook able to stall or abort the transaction, and the record names it".
+
+4. AC7 (L2357): change to "Unread Arch news wedges no mode once the clear succeeds; a failed clear ends in a named failed_step, never a silent hang past the unit or runner timeout."
+
+5. Phase 1 tests (L2175 or L2499): add a case where a fake informant's read --all exits 124 and the fake pacman then fails as the hook abort would. Assert failed_step pacman (everyday) or boot-transaction (--apply-armed), no stamp, and for --apply-armed no re-arm.
+
+Option (a), if AC7 must hold unconditionally: when read --all exits non-zero, run that run's own -Su/-S with "--hookdir /etc/pacman.d/hooks --hookdir <per-run dir>". The per-run dir holds 00-informant.hook -> /dev/null, which keeps the guard hook active. In the everyday and --complete forms, do this only when yay -Pwq succeeded, so the news is never cleared unseen. Assert the extra --hookdir argv in the same new test.
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: accepted, folded into Implementation phases, Acceptance criteria, Risks, Testing.
+:EVIDENCE:
+Spec claims and gaps (docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org):
+- L85 and L2063: "The timeout bounds informant's feed fetch, which has no timeout of its own. Failure, the timeout included, is tolerated and logged."
+- L160: "offline-safe: informant 0.6.0 falls back to an empty feed and exits 0, and the hook's informant check sees the same feed, so neither blocks the transaction." L2102 makes the same claim.
+- L2357: "AC7: Unread Arch news wedges no mode." L2448: "on velox informant's AbortOnFail hook no longer stops a run".
+- L2175 and L2499 test only "a failing or absent informant is tolerated". There is no case where the clear fails and the hook then fires.
+- grep for hookdir, 00-informant and "informant check" finds no handling elsewhere.
+
+informant 0.6.0-2 (archangel airootfs, usr/lib/python3.14/site-packages/informant/):
+- feed.py:81-82: session.get(self.url) with no timeout. feed.py:83-86: on an exception it falls back to feedparser.parse(url), also with no timeout. feed.py:90-106: only a bozo URLError gives an empty feed.
+- informant.py:117-119: --all marks entries read in memory. informant.py:146: fs.save_datfile() runs after the fetch.
+- informant.py:165-167: an empty feed gives sys.exit() (0) with nothing marked.
+- informant.py:83-98: check marks and saves one unread item, but exits with the unread count either way.
+- usr/share/libalpm/hooks/00-informant.hook: Operation=Upgrade, Target=*, Target=!informant, When=PreTransaction, Exec=/usr/bin/informant check, AbortOnFail.
+
+Live system checks:
+- man alpm-hooks lists no timeout option, and says "Hooks may be disabled by overriding them with a symlink to /dev/null".
+- man pacman says --hookdir is "a alternative directory ... hooks in later directories taking precedence".
+- /etc/pacman.d/hooks holds hypr-live-update-guard.hook.
+- sysctl net.ipv4.tcp_syn_retries = 6.
+- archsetup:1801-1802 enables NetworkManager. The boot unit has no network ordering (spec L153).
+:END:
+
+** DONE F22: dropping guard: live_update also removes the only trigger for the arm-line annotation, _rearm_after_guard and the doctor review suffix that Phase 2 says to reword
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Phase 2 Guard UX L2219-2220; Phase 2 tests L2262; Testing L2467
+
+L2219 drops guard: "live_update" from the update and topgrade remedies, along with _update_force and maint fix --force. L2220 then says guard.trips survives as the arm-line annotation, and that on a tripped read arm_line, _rearm_after_guard's text and the doctor review suffix read 'UPDATE armed — will defer at least <guard matches> ... — press again to run upgrade-guarded'.
+
+Risk: Implemented literally, the annotation never shows: guarded() is false, and no guard event is ever emitted. The implementer has to invent a new selector, such as a new remedy key or an rid check, or else keep rewording code that can no longer run. The L2262 test can pass against the pure arm_line function while the panel never calls it. The fixed text also says 'UPDATE armed' on the TOPGRADE key.
+
+Recommended change: In Phase 2 Guard UX, replace the second sentence of L2220 with this:
+
+"Dropping the tag removes the only selector, so panel.guarded() and doctor.py's review-suffix check key on rid in ('update', 'topgrade') instead, and test_panel_phase10.py:420-421 keeps asserting True for both. iter_fix no longer emits a 'guard' event, so delete _rearm_after_guard and the guard-event branch of _on_fired. Do not reword them. _guard_arm_line stops setting _update_force. On a tripped read, arm_line and the review suffix read '<label> armed — will defer at least <guard matches> (kernels and DKMS modules are always held) — press again to run upgrade-guarded'."
+
+Make matching edits elsewhere:
+- L2262 and L2467: change 'UPDATE armed' to '<label> armed'.
+- L2262: make the test drive the arm-press (_press_lever, or the strip-key handler) for both UPDATE and TOPGRADE with a tripped update_guard read, and assert the act line. Add one assertion that the review suffix carries the same annotation.
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: modified, folded into Implementation phases, Testing. R07, R19 and R35 are one defect. I kept the rid selector (R07, R35) over R19's new arm_badge remedy key. I kept panel.guarded and rekeyed it rather than deleting it: _press_lever lives in gui.py, which needs GTK, so the arm press can only be tested GTK-free through panel.guarded and viewmodel.arm_line. The doctor review suffix is deleted rather than reworded (R35). The arm line is the display mirror Decision 3 names, and the roster's FIX reaches the same arm line. The label is generic (R07, R19) because TOPGRADE shares the path.
+:EVIDENCE:
+Spec:
+- L2219: "Drop =guard: "live_update"= from the update and topgrade remedies ... the GUI's =_update_force= override, and =maint fix --force=".
+- L2220: "=guard.trips= survives only as the arm-line annotation ... On a tripped read, =arm_line=, =_rearm_after_guard='s text and the doctor review suffix read 'UPDATE armed — will defer at least ...'".
+- L2262 and L2467: test the arm line text only.
+
+Code (dotfiles maint/src/maint), from a grep for '"guard"|guarded(|update_guard|live_update'. The only readers of the tag are:
+- remedies.py:298 and :309: the tag itself, on update and topgrade.
+- panel.py:228: guarded(rid) returns REMEDIES[rid].get("guard") == "live_update".
+- gui.py:1430-1433: only =if panel.guarded(rid):= reaches _guard_arm_line, which reaches viewmodel.arm_line(..., guard=tripped) via gui.py:1396-1405. Otherwise the plain arm_line runs.
+- gui.py:1496-1499: _rearm_after_guard runs only when a 'guard' event exists.
+- doctor.py:216-240: the 'guard' event is yielded only under =r.get("guard") == "live_update" and not force=.
+- doctor.py:415-421: the review suffix is gated on =r.get("guard") == "live_update"=.
+- gui.py:1525: _rearm_after_guard sets =self._update_force = True=.
+- viewmodel.py:99-107: arm_line formats {label}, not a fixed 'UPDATE'.
+
+Tests:
+- tests/maint/test_panel_phase10.py:420-421: assertTrue(panel.guarded("update")) and assertTrue(panel.guarded("topgrade")).
+- tests/maint/test_panel_levers.py:404: pins the live-apply wording.
+:END:
+
+** DONE F32: no test covers the everyday run's positive stamp or the open-gate no-stamp rule
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Stamp predicate L2137-2138 (Design L114-115); AC3 L2343; Phase 1 'Stamp and record' tests L2185-2191; Testing AC1/AC3 map L2465; gate tests L2195, L2486
+
+Every everyday stamp test is negative: a deferred set, an UPDATE-shaped run, a refresh, closure or pacman failure, a failing sweep (L2178), a failing yay (L2180). The only positive stamp tests are for --complete and --apply-armed (L2189, L2503, AC8). For an open gate failure, the tests only check that the gate field survives a later everyday run (L2195 'the gate field survives a following everyday run', L2486). None checks that the run doesn't stamp. AC3 (L2343) requires the everyday stamp 'only under the everyday rule ... and with no gate failure open', and the AC1/AC3 map (L2465) lists no case for either clause.
+
+Risk: Two broken implementations pass every listed test. One never stamps on the everyday path, which is the original permanently-stale defect. The other stamps fresh over an open gate failure on a CRIT 'do not reboot' day. AC3's stamp clause can't be signed off from the test map.
+
+Recommended change: This is the smallest edit. Phase 1 "Stamp and record", after L2187, add:
+- "a TOPGRADE-shaped everyday run (neither --no-topgrade nor --no-aur) whose refresh, -Su, yay and sweep fakes exit 0 and whose post-transaction pacman -Qu leaves nothing held writes topgrade_run; the same run with --no-aur doesn't stamp"
+
+At L2195, change "the gate field survives a following everyday run" to:
+- "a following everyday run that would otherwise stamp (TOPGRADE-shaped, every step exit 0, nothing deferred) carries the gate field forward unchanged and doesn't stamp"
+
+Mirror both in the Testing map:
+- At L2465 ("Phase 1, the stamp"), add the positive TOPGRADE-shaped case and the --no-aur case.
+- At L2486, apply the same "would otherwise stamp ... doesn't stamp" wording.
+
+Optionally, pin the L2190 and L2505 missing-maint case to that same positive everyday run, so the everyday path is shown to reach the stamp call.
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: modified, folded into Implementation phases, Testing. Adopted except the '--no-aur doesn't stamp' variant, which goes away with R58.
+:EVIDENCE:
+Spec docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org:
+- L114-115 and L2137-2138: "No form stamps while gate.ok is false" and "everyday: stamps iff neither --no-topgrade nor --no-aur was given, the refresh, -Su, yay and topgrade all exited 0, and the record's packages list is empty".
+- L2343 (AC3): "It stamps only under the everyday rule ... and with no gate failure open."
+- L2185-2191 (Phase 1 "Stamp and record"): the only positive case is L2189 "after a successful --complete (stage 2 path) or --apply-armed ... the stamp is fresh". L2190 "a run that would stamp" names no mode.
+- L2195: "the gate field survives a following everyday run", with no stamp assertion. Same at L2486.
+- L2465 (AC1/AC3 map, "Phase 1, the stamp"): only the deferred-set, UPDATE-shaped and failure no-stamp cases.
+- L2503-2505 (AC8 map): the positive stamp and the missing-maint case, both completion forms.
+- L2253 and L2506 (Phase 2 / AC9): negative only.
+- Prior-round disposition (~L775): "No form stamps while a gate failure is open, so TOPGRADE can't mark a do-not-reboot machine fresh."
+- L124 and L2346: after a failed gate the new kernel is installed, not booted.
+
+Repo: tests/upgrade-guarded/ does not exist, so no test already covers this.
+:END:
+
+** DONE F29: the AUR wording says any held package trips the guard, but only GPU/compositor targets under a live Hyprland do
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Design everyday step 7 L89; Phase 1 everyday step 7 L2067; Phase 2 Guard UX L2219 (contradicted by Risks L2447, Non-Goals L54, Risks L2446)
+
+L89 and L2067: 'yay still resolves repo dependencies normally, so an AUR upgrade that needs a newer held package makes yay's own pacman call trip the guard, and the AUR step fails.' L2219: 'on the driven path the guard fires only when an AUR upgrade makes yay's own pacman call reach a held package.' The everyday held set always contains the kernel-kind entries, and when Hyprland isn't live it contains only those (L75, L2041).
+
+Risk: F29's resolution existed to remove a false safety property, and the Design and Phase 1 text still state a narrower one. They imply that the AUR path can't move a held kernel or zfs-dkms. Risks says it can, and that path is the one that leaves velox unbootable. A reader who trusts the step 7 text would see no reason to guard the yay step.
+
+Recommended change: In L89 and L2067, replace the sentence "yay still resolves repo dependencies normally, so an AUR upgrade that needs a newer held package makes yay's own pacman call trip the guard, and the AUR step fails." with: "yay still resolves repo dependencies normally. An AUR upgrade that needs a newer held GPU/compositor package while Hyprland is live makes yay's own pacman call trip the guard, and the AUR step fails. One that needs a newer kernel-set or DKMS-set package installs it ungated (see Risks)."
+
+At L2219, changing "reach a held package" to "reach a held GPU/compositor package while Hyprland is live" is optional: the current wording is a true "only when" claim.
+
+Should-fix, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: accepted, folded into Implementation phases, Design.
+:EVIDENCE:
+Spec text:
+- L89 and L2067 (identical): "yay still resolves repo dependencies normally, so an AUR upgrade that needs a newer held package makes yay's own pacman call trip the guard, and the AUR step fails."
+- L2041: "held set (everyday): ... It always contains the stage-A result, which is kernel-kind. Only when live does it add the GPU patterns..."
+- L54: "The hook stays silent on kernels."
+- L2447 (Risks): "yay's dependency resolution can move a kernel-set or DKMS-set package when an AUR upgrade requires a newer one; that path is ungated ... An AUR upgrade that needs a newer held GPU/compositor package trips the guard inside yay's own pacman call."
+- L2363 (AC10) is correctly scoped: "An AUR upgrade that needs a newer held GPU/compositor library under a live Hyprland fails the AUR step visibly."
+- L2219: "the guard fires only when an AUR upgrade makes yay's own pacman call reach a held package". This is a true necessary condition, not a defect.
+
+Live system:
+- =/etc/pacman.d/hooks/hypr-live-update-guard.hook:4-18= has Targets mesa, mesa-*, wayland, libdrm, libglvnd, hyprland, aquamarine, hyprutils, hyprgraphics, vulkan-radeon, vulkan-intel, vulkan-mesa-layers, nvidia-utils, lib32-nvidia-utils and xorg-xwayland. There is no linux* or zfs* target. On ratio the hook is still at the legacy unprefixed name; the spec's 10- rename is pending, and the Target content is the same.
+
+Code:
+- =scripts/hypr-live-update-guard:57-67= contains =hyprland_running || exit 0=, so the guard allows everything when Hyprland is not live.
+:END:
+
+** DONE F35: --complete's deferred-set definition still drops kernel-side entries that stage 1 failed to land
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Design 'The sets' L77 and Phase 1 'Sets' L2043 (the deferred-set definition), against Decision 4 L227, Phase 1 record L2122, the --complete stage-1 failure path L2089 and APPLY visibility L2234
+
+The applied resolution text is present: L2122 and L227 say "--complete drops the kernel-side entries once stage 1 lands them", and L2120 says the record is rewritten "with an empty packages list when nothing is deferred". But the formal definition was never reconciled with it. L2043 (and L77) still says the deferred set is "the pending entries that remain after the run's last transaction ... and are in that run's held set (everyday) or GPU closure (--complete)", and that it "is exactly the record's packages list". So a --complete record can only ever hold GPU-closure entries.
+
+Risk: After a failed stage 1 (a conflict, a download error, a full disk), the kernel and DKMS entries that are still pending disappear from the record. On a day with nothing GPU-side pending, the row reads 0 deferred at WARN and APPLY is hidden. The retry can't be reached from the panel, and the row underreports the held kernel until the next everyday run recomputes stage A. An implementer also faces two contradictory rules (L2043 and L2122) and has to pick one.
+
+Recommended change: At Design L77 and Phase 1 L2043, change the --complete clause of the deferred-set definition from "or GPU closure (=--complete=)" to:
+
+"or, for =--complete=, its GPU closure (kind gpu) plus the still-pending members of the kernel set, the DKMS set and the previous record's kernel-kind entries (kind kernel), which is empty after a successful stage 1"
+
+Add one bullet to the Phase 1 tests: a --complete whose fake stage-1 =-Su= exits non-zero, with the gate passing or having nothing to check, ends with exit 1, result failed, failed_step pacman, and the kernel-set entries still in the record's packages with kind kernel. Add one bullet to the Phase 2 tests: from that fixture record, the deferred row shows N > 0 at WARN and offers APPLY.
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: accepted, folded into Implementation phases, Testing.
+:EVIDENCE:
+Spec docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org:
+- L77 (Design, the sets) and L2043 (Phase 1, Sets): "deferred set: the pending entries that remain after the run's last transaction ... and are in that run's held set (everyday) or GPU closure (--complete). ... It is exactly the record's packages list."
+- L227 (Decision 4) and L2122 (record, packages): "--complete drops the kernel-side entries once stage 1 lands them, and the GPU closure once it is applied". L2122 also says "A refusal before classification keeps the previous list", which covers refusals only.
+- L1585 (adopted resolution text): "rewrites the deferred-set state file ... with what is still deferred".
+- L131 and L2089: "Stage 1 failed while the gate passed or had nothing to check: failed_step pacman, exit 1, nothing armed." No packages rule.
+- L2224: "Value N: the record's packages minus the entries now installed at or above new".
+- L2234: "APPLY ... shown while N > 0 or a gate failure is open". L2236: APPLY on the topgrade_age row "While N > 0".
+- L2227-2229: WARN when result is failed; the row text adds detail.
+- L2085 and L132: stage 1 is =-Su --ignore=<GPU closure entries>=, and the GPU closure is refused if it contains a kernel-set or DKMS-set member, so on success no kernel-kind entry stays pending. That is why the extra clause is safe to apply unconditionally.
+:END:
+
+** DONE F40: REVIEW & FIX can still fire APPLY through the captured lever runner
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Phase 2 APPLY L2234-2235; Phase 2 tests L2257; Testing AC4 L2490; deferred-row severities L2227-2228; CVE text L2243
+
+The resolution is present for the APPLY key. L2235 says APPLY opens =foot --hold -e upgrade-guarded --complete= through Popen(start_new_session=True) and never uses the captured runner. L2257 and L2490 test only that key's argv and detach. But L2234 makes APPLY "the lever of upgrade_deferred (always: True)", which is a REMEDIES entry. Nothing says how the REVIEW & FIX roster fires it.
+
+Risk: On exactly the CRIT gate-failure day, REVIEW & FIX lists APPLY, and its FIX runs =upgrade-guarded --complete= under iter_fix. Closing the panel (Escape, q, a waybar click) can then kill or SIGPIPE the script mid-kernel-transaction, between the DKMS and initcpio hooks. That is the velox failure F40 closed for the key path.
+
+Recommended change: Phase 2 APPLY, append to L2235: "APPLY is a confirm-tier remedy, so REVIEW & FIX lists it whenever the row is WARN or CRIT. The detached-terminal rule covers every panel entry point: the deferred row's key, the key on the topgrade_age row, and the roster's FIX. All three reach _on_lever/_press_lever, so dispatch on the rid there, and the panel never hands upgrade-guarded --complete to doctor.iter_fix." Phase 2 tests, extend L2257: "APPLY's argv and the detach, fired from the row key and from the REVIEW & FIX roster's FIX on a gate.ok-false fixture; in both cases Popen gets the foot argv with start_new_session=True and iter_fix is never called." Extend AC L2490 with the same roster clause.
+
+Should-fix, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: accepted, folded into Implementation phases, Acceptance criteria, Testing.
+:EVIDENCE:
+Spec, docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org:
+- L2234: "It is the lever of =upgrade_deferred= (always: True)".
+- L2235: "It doesn't use the captured-output lever runner. Like MERGE, it opens =foot --hold -e upgrade-guarded --complete= via =subprocess.Popen(..., start_new_session=True)=".
+- L2227-2228: the row is CRIT on gate.ok false and WARN on failed/refused/interrupted/advisories.
+- L2257 tests "APPLY's argv and the detach". L2490 is the key-path AC.
+- A grep for REVIEW & FIX/roster finds only the CVE exclusion lines (L2243, L2258, L2364, L2508). Nothing addresses APPLY in the roster.
+
+Code (~/.dotfiles/maint/src/maint):
+- remedies.py:23: "always marks free controls (CPU mode, charge limit, the update levers)".
+- remedies.py:534-546: attach_levers/levers_for derive levers from metric_ids.
+- update/topgrade (remedies.py:295-313) are tier "confirm" plus always: True, the template APPLY would follow.
+- doctor.py review() (~L389-424): =if r["tier"] != "confirm": continue= then yields every confirm remedy whose metric_ids hit WARN/CRIT.
+- viewmodel.py:182-199: review_rows gives every item-less remedy =_fix(ev["action"], "FIX", [])=.
+- gui.py:946-954: _digest_key kind "fix" -> self._on_lever. gui.py:781-785: card keys from panel.metric_keys -> self._on_lever. Both paths meet there.
+- gui.py:1407-1438: _press_lever -> _fire. gui.py:1468-1478: _fire -> doctor.iter_fix inside _stream's daemon thread (gui.py:1443-1460).
+- doctor.py:161: =cmd.run(step["argv"], timeout=r.get("timeout", 60))=.
+- cmd.py:18-23: subprocess.run(..., capture_output=True, timeout=timeout), which kills the child on TimeoutExpired.
+- gui.py:1665-1676: _on_merge is a separate Popen path, and MERGE has no REMEDIES entry.
+:END:
+
+** DONE F33: The rollback order leaves out the Phase 4 doc commits, so the health-check workflow points at a deleted binary
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Readiness 'Rollout, compatibility & rollback' L2418-2422; Implementation phases intro L2022; Phase 4 docs L2311-2313; Risks L2446
+
+The F33 text is present at L2404 and L2418-2422: not additive, revert the dotfiles Phase 2 commit, disable and remove the unit, then remove the flag and the scripts. But the list only covers Phase 2 and the machine state. L2022 adds one Phase 4 doc commit in each repo: the maint/README.md flow and recovery steps in dotfiles, and docs/workflows/system-health-check.org in archsetup. The F38 resolution then made the health-check workflow a third caller of upgrade-guarded (L2313, L2446).
+
+Risk: After a rollback that follows the spec exactly, the routine update in the health-check workflow tells the operator to run a binary that no longer exists. Its hand-stamp guidance also no longer matches the restored wrapper. The dotfiles README keeps describing APPLY and --complete, and the git revert of Phase 2 likely needs manual conflict resolution.
+
+Recommended change: Edit spec L2419-2422 as follows.
+
+Step 1 becomes: "1. Revert the Phase 4 doc commits (dotfiles =maint/README.md=, archsetup =docs/workflows/system-health-check.org=), then the dotfiles Phase 2 commit. This restores the yay/topgrade argv, the force path and the alias, removes the deferred probe and APPLY, and returns the health-check update step to plain topgrade. Revert the doc commit first because Phase 2 and Phase 4 edit the same README section."
+
+In L2422, change the second sentence to: "Removing the scripts before these reverts leaves UPDATE, TOPGRADE, the =sysupgrade= alias and the health-check workflow's update step pointing at a missing binary."
+
+Leave steps 2 and 3, and the "can stay" sentence, as they are.
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: accepted, folded into Implementation phases, Readiness dimensions.
+:EVIDENCE:
+- Spec L2022: "Phase 4 holds the docs, which land after Phase 3 as one doc commit in each repo (the =maint/README.md= flow and recovery steps in dotfiles, =docs/workflows/system-health-check.org= in archsetup)".
+- Spec L2313 (Phase 4 docs) and L2398 (Documentation plan): step 6 of system-health-check.org Phase 3 "runs =upgrade-guarded= instead of plain topgrade", and kernel, DKMS and GPU days go through --complete.
+- Spec L2446: "The hold covers only =upgrade-guarded='s callers: the panel levers, the =sysupgrade= alias and the system-health-check workflow's update step."
+- Spec L2419: "1. Revert the dotfiles Phase 2 commit." This is the first rollback step. No step reverts either Phase 4 doc commit.
+- Spec L2421: "3. Remove the arm flag, then =/usr/local/bin/upgrade-guarded= and =kernel-modules-check=."
+- Spec L2422: "Removing the scripts before reverting dotfiles leaves UPDATE and TOPGRADE pointing at a missing binary. The rewritten =guard_patterns=, the =10-= hook name and the record file can stay." The health-check update step is missing from both sentences.
+- Spec L2397: Phase 2 rewrites "the 'The live-update guard' section of dotfiles =maint/README.md=".
+- Spec L2311: Phase 4 "Add the flow to the 'The live-update guard' section of dotfiles =maint/README.md=". This is the same section, edited later.
+- ~/.dotfiles/maint/README.md:52 is "### The live-update guard". It is a short section of about 12 lines, so the two commits' hunks will sit next to each other.
+- docs/workflows/system-health-check.org:236 is today's step 6 ("Run =topgrade= for the actual update ..."), which Phase 4 rewrites. The file is tracked in archsetup (=git ls-files=), so a doc commit is the only thing that would undo the change.
+:END:
+
+** DONE F45: the recovery steps re-register only the kernel and zfs packages, but the snapshot boot reverts every package changed since the snapshot
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Risks 'Last-resort fallback on velox' L2444; Phase 4 Docs L2312; Readiness Documentation plan L2398; Testing recovery drill L2521
+
+All four places say: 'After booting the snapshot from ZFSBootMenu, re-register the old kernel set with pacman -U --dbonly from /var/cache/pacman/pkg before the next upgrade-guarded run, along with zfs-dkms and zfs-utils if that transaction moved them.' The resolution's text is present word for word. The problem is its scope: the pacman db (a separate dataset) also keeps every other package the reverted transactions changed, and these steps don't touch any of them.
+
+Risk: Someone who follows the documented recovery ends up with a db that records new versions, plus newly installed packages, for every non-kernel package those transactions moved, while the booted root holds the old files. pacman -Qu never lists them again, so neither upgrade-guarded nor the deferred row ever re-upgrades them. Later installs then resolve against library versions that aren't on disk. This is the same silent db/file divergence F45 was accepted to close; the resolution closes it only for the kernel and zfs packages.
+
+Recommended change: At L2444, L2312, L2398 and L2521, replace "re-register the old kernel set with pacman -U --dbonly from /var/cache/pacman/pkg ... along with zfs-dkms and zfs-utils if that transaction moved them" with:
+
+"reconcile the db with the booted root: for every package /var/log/pacman.log records as upgraded, downgraded or removed after the booted snapshot's creation time, re-register its old version with pacman -U --dbonly from /var/cache/pacman/pkg (yay's cache for an AUR package). For every package it records as installed after that time, drop it with pacman -Rdd --dbonly. Then confirm with pacman -Qkk. pacman.log lives on zroot/var/log, so it survives the snapshot boot. This covers the kernel set, zfs-dkms and zfs-utils, the non-kernel packages stage 1 landed, and any everyday runs made after the failed gate."
+
+Keep the existing sentence that rules out rolling back zroot/var/lib/pacman. In the L2521 drill, add one pending non-kernel package alongside the kernel update, so the drill actually exercises the reconcile step.
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: accepted, folded into Implementation phases, Risks, Readiness dimensions, Testing.
+:EVIDENCE:
+Spec text checked:
+- L2444 (Risks), L2312 (Phase 4 Docs), L2398 (Readiness) and L2521 (Testing drill) all say: "re-register the old kernel set with pacman -U --dbonly from /var/cache/pacman/pkg ... along with zfs-dkms and zfs-utils if that transaction moved them".
+- L2086: --complete stage 1 "lands the kernel set, the DKMS set and every other pending package except the GPU closure".
+- L2155: the gate needs a pre-pacman snapshot with creation >= since - 60 s.
+- L2057-2061: the everyday preconditions do not check =gate=.
+- L114 and L2137: an open gate failure only blocks stamping.
+- L2521: the drill pends only "a kernel update".
+- grep for pacman.log, Qkk and diverg finds no other recovery handling.
+
+Code and system checked:
+- scripts/zfs-pre-snapshot:11 is DATASET="${ZFS_PRE_DATASET:-$POOL/ROOT/default}", and :30 is a single =zfs snapshot= with no -r.
+- ~/code/archangel/installer/archangel:735 (var/log), :736 (var/cache) and :738 (var/lib/pacman) are separate datasets.
+- /etc/pacman.conf:14-15 leave CacheDir and LogFile at their defaults, so pacman.log lives on the var/log dataset.
+- archsetup:2180 configures paccache to keep 3 versions, so old package files are normally still in the cache.
+- /var/log/ratio-upgrade.log:16 is "Packages (724)", including glibc-2.44, gcc-libs-16.2.1, linux, linux-lts, zfs-dkms and zfs-utils.
+:END:
+
+** DONE --complete stamp rule is 'iff' / 'exactly when' in Phase 1 and AC8, but the nothing-to-complete path never stamps
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Phase 1 stamp predicate L2139; AC8 L2360; Design L116, L129; Phase 1 --complete step 2 L2083; Decision 4 L229; Design L170
+
+Phase 1 L2139: --complete 'stamps iff it started with a non-empty record or an open gate failure, every step it ran succeeded, and the record ends empty'. AC8 L2360: topgrade_run 'is written exactly when' those three hold. But --complete step 2 (L2083, Design L129) says that when nothing is pending and no gate failure is open, it prints 'nothing to complete', rewrites the record empty, exits 0, 'No stamp.' Decision 4 L229 agrees: '...neither does one that finds nothing to complete.'
+
+Risk: Two implementers can disagree on whether freshness clears after a by-hand or out-of-band completion. Either way, one of the spec's own statements (the AC or the Decision) fails as written.
+
+Recommended change: Change L2139 to: "=--complete=: stamps iff it did not take step 2's nothing-to-complete exit, started with a non-empty record or an open gate failure, every step it ran succeeded, and the record ends empty (stage 2 applied the GPU closure, or there was none). An arming run never stamps, and the nothing-to-complete exit never stamps even when the record it started with was non-empty."
+
+Change AC8 at L2360 to: "...from a console with Hyprland stopped stamps freshness: =topgrade_run= is written exactly when the run got past the nothing-to-complete check, started with a non-empty record or an open gate failure, every step succeeded, and the record ends empty." This also drops "identically to the boot unit".
+
+Optionally append "never on the nothing-to-complete exit" to Design L116 for symmetry.
+
+Add a test next to L2504: a non-empty record whose packages are all already installed, nothing pending and no open gate failure. The run should print 'nothing to complete', rewrite the record empty, exit 0, and write no stamp.
+
+L229, L129 and L2083 need no change.
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: accepted, folded into Implementation phases, Acceptance criteria, Decisions, Testing.
+:EVIDENCE:
+- Spec L2139: "=--complete=: stamps iff it started with a non-empty record or an open gate failure, every step it ran succeeded, and the record ends empty".
+- Spec L2360 (AC8): "stamps freshness identically to the boot unit: =topgrade_run= is written exactly when the run started with a non-empty record or an open gate failure, every step succeeded, and the record ends empty."
+- Spec L2083: "If nothing is pending and no gate failure is open: print 'nothing to complete', remove any stale flag, rewrite the record (empty) and exit 0. No stamp."
+- Spec L129: the same text, ending "without stamping".
+- Spec L229 (Decision 4): "stamps only when it started with something to complete (a non-empty record or an open gate failure)... neither does one that finds nothing to complete". This is consistent with L2083.
+- Spec L116: "stamps only when..." This is a necessary condition and consistent.
+- Spec L170: "A by-hand sentinel-override upgrade outside the script doesn't stamp, though the panel's row still clears, because the probe drops entries already installed". So the record still lists the installed packages.
+- Spec L2446: "bare =topgrade=, =yay= and =pacman -Syu= still land the kernel and DKMS sets with no gate".
+- Spec L2140: "=--apply-armed=: stamps iff its transaction exited 0 or was a no-op, and the record ends empty". It has no started-non-empty clause, so it is not identical to --complete.
+- Spec L2504: "a no-op =--complete= (nothing pending, no open gate failure) doesn't stamp". There is no condition on the record, and no test pins the non-empty-record case.
+:END:
+
+** DONE Deferred-set definition drops kernel-kind entries that --complete stage 1 failed to land, contradicting 'drops the kernel-side entries once stage 1 lands them'
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Design 'The sets' deferred set L77; Phase 1 deferred set L2043; Phase 1 record packages L2122; Decision 4 L227; --complete step 4 L2089; Design L103
+
+Deferred set (L77, L2043): 'the pending entries that remain after a run's last transaction and are in that run's held set (everyday) or GPU closure (--complete)... It is exactly the record's packages list.' Record packages (L2122, Decision 4 L227): '--complete drops the kernel-side entries once stage 1 lands them'. L103 adds 'A refusal before classification keeps the previous list', but 'classification' is never defined.
+
+Risk: After a failed stage 1, the panel row stops showing the still-held kernel and DKMS packages until the next everyday run re-adds them. Implementers will also disagree on what packages holds after a --complete refusal.
+
+Recommended change: Edit 1, the --complete deferred-set definition at L77 and L2043. After "or GPU closure (=--complete=)", add: "For =--complete=, the set also includes the pending entries outside the GPU closure that the previous record listed as kernel, or that are kernel-set or DKMS-set members. These are recorded as kind kernel. After a successful stage 1 this part is empty, so a failed stage 1 leaves the kernel side in the record."
+
+Edit 2, at L103 and L2122. Replace "A refusal before classification keeps the previous list." with "Every refusal (exit 3) keeps the previous list, since none runs a transaction."
+
+Edit 3, a new bullet in the Phase 1 tests (after L2201) and in Testing under "Phase 1, =--complete=" (after L2489): "a stage-1 =-Su= that fails before committing exits 1 with failed_step pacman and leaves the kernel-set and DKMS-set entries in the record's packages as kind kernel; a GPU closure that reaches a kernel member refuses and leaves packages unchanged."
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: accepted, folded into Implementation phases, Testing.
+:EVIDENCE:
+Spec L77 and L2043 define the --complete deferred set as pending entries "in that run's ... GPU closure (--complete)" and say it "is exactly the record's packages list".
+
+Spec L129, L2053 and L2082: a GPU closure that contains a kernel-set or DKMS-set member refuses, so kernel and DKMS entries are never in the GPU closure.
+
+Spec L227 (Decision 4) and L2122: "--complete drops the kernel-side entries once stage 1 lands them".
+
+Spec L131 and L2089: "Stage 1 failed while the gate passed or had nothing to check: failed_step pacman, exit 1, nothing armed". Neither line says anything about packages.
+
+Spec L74 defines kernel-kind as "the stage-A closure result", and --complete has no stage A (L2051-2053).
+
+Spec L103 and L2122: "A refusal before classification keeps the previous list". grep -n -i classif finds only these two lines plus an unrelated use at L2379.
+
+Spec L2379: "Refusals happen before any transaction".
+
+Spec L2243-2244: cve_queued, the "fixable via UPDATE" caption, the UPDATE lever's cve_advisories and [HELD] "exclude names in the deferred set" and "read the record".
+
+Spec L2484-2489 and L2503 have no test for the packages list after a failed stage 1.
+
+The script doesn't exist yet: there is no upgrade-guarded or kernel-modules-check under scripts/. This is a pure contract inconsistency.
+:END:
+
+** DONE An open gate failure can never clear once its recorded kernel's vmlinuz is gone ('passes' vs 'had nothing to check')
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Design L108, L138, L140; Phase 1 L2089-L2090, L2127, L2137, L2148-L2149; Phase 1 test L2196; Testing L2488; Phase 2 CRIT row L2227
+
+The gate field is cleared only by 'a --complete whose re-check of those kernels passes' (L108, L2127), and Phase 1 step 4 sets gate to null only when the 'Gate passes' (L2090). L2089 treats 'passed' and 'had nothing to check' as separate outcomes. Check list (c) includes only recorded kernels 'that still have /usr/lib/modules/<kver>/vmlinuz' (L138, L2148). 'An empty list skips the gate' (L140, L2149). The test bullets say a recorded kernel whose modules directory (L2196) or vmlinuz (L2488) is gone 'drops out of the list'. Neither says the gate field then clears.
+
+Risk: The panel can be wedged at CRIT with freshness unstampable, with no supported way out short of editing the record by hand. Implementers must also choose whether a skipped gate clears an open failure.
+
+Recommended change: Edits to docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org:
+
+1. L108, Design: after "...sets it to null;" add "a recorded kernel whose /usr/lib/modules/<kver>/vmlinuz is gone counts as re-checked and passed, so when none remain, a --complete whose own (a)/(b) check passed or was empty also sets it to null;".
+
+2. L2127, Phase 1 record field: append the same sentence.
+
+3. L2090, Phase 1 step 4: replace it with "Gate passes, or every recorded kernel dropped out of (c) and the (a)/(b) check passed or was empty: set =gate= to null."
+
+4. L2196: change "whose modules directory is gone" to "whose /usr/lib/modules/<kver>/vmlinuz is gone". Append "; when that leaves no recorded kernel and nothing else fails, the run sets =gate= to null and, with the record otherwise empty, stamps."
+
+5. L2488, Testing AC4: append "; when no recorded kernel remains, =--complete= sets =gate= to null".
+
+Leave the K-to-K' path as it is. Clearing when the new kernel passes is already correct.
+
+Should-fix, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: modified, folded into Implementation phases, Testing. With the entry keyed by pkgbase (R03), 'vmlinuz gone' becomes 'kernel package no longer installed'. That test is sturdier, because a module directory can outlive its vmlinuz. A recorded pkgbase drops out only when pacman reports the package not found, so a half-transacted kernel can't clear the entry by accident. When every recorded pkgbase drops out, the entry clears without invoking the gate, as the finding asks.
+:EVIDENCE:
+Spec docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org:
+- L108: "Only a =--complete= whose re-check of those kernels passes sets it to null".
+- L2127: "carries it forward unchanged until a =--complete= re-check of those kernels passes".
+- L138 and L2148, item (c): "the kernels of an open gate failure that still have =/usr/lib/modules/<kver>/vmlinuz=".
+- L140 and L2149: "An empty list skips the gate".
+- L2089: "Stage 1 failed while the gate passed or had nothing to check" (two separate outcomes).
+- L2090: "Gate passes: set =gate= to null".
+- L2193 and L2482: "an empty check list with no fresh snapshot passes" (supports the opposite reading).
+- L724, prior-round disposition: "Clearing requires a real re-check of the recorded kernels ... would otherwise pass vacuously".
+- L2137: "No form stamps while =gate.ok= is false".
+- L2227: the CRIT row with remedy "after fixing, run upgrade-guarded --complete".
+- L2240: REBOOT hidden while gate.ok is false.
+- L2196: "a recorded kernel whose modules directory is gone drops out of the list"; L2488 says vmlinuz instead.
+- L2224: probe value N filters packages but does nothing to the gate field.
+- Neither the L2196 test nor the L2488 test asserts that the gate field clears.
+
+Live system on ratio:
+- Kernels and owners from =pacman -Qqo /usr/lib/modules/*/vmlinuz=: 6.18.25-1-lts-strix is linux-lts-strix, 6.18.54-1-lts is linux-lts, 7.2.7-arch1-1 is linux.
+- =dkms status= shows zfs/2.4.4 installed for both 7.2.7-arch1-1 and 6.18.54-1-lts.
+- /usr/share/libalpm/scripts/depmod runs rmdir --ignore-fail-on-non-empty, so a modules directory can outlive its vmlinuz.
+- 71-dkms-remove.hook runs PreTransaction on Remove.
+:END:
+
+** DONE Phase 1 gate test bullet says 'only pre-T0 snapshots fail', contradicting the 60 s window it then states and the gate rule
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Phase 1 tests L2193; Phase 1 gate L2155; Design L140; Decision 5 L239; Testing L2481; Risks L2444
+
+L2193: 'only pre-T0 snapshots fail; one created after T0 − 60 s passes'. The gate rule (L2155, Design L140) requires creation ≥ since − 60 s, and Testing L2481 says 'only snapshots created before since − 60 s fails'.
+
+Risk: A test written from L2193 would pin the opposite of the gate rule for exactly the case the 60 s window exists for, failing a correct gate or forcing a wrong one.
+
+Recommended change: At L2193, replace "only pre-T0 snapshots fail; one created after T0 − 60 s passes" with: "a listing whose newest root-dataset pre-pacman snapshot was created before T0 − 60 s fails, naming the dataset it looked for; one created at exactly T0 − 60 s, one created between T0 − 60 s and T0 (the snapshot hook's 60 s skip window), and one created after T0 each pass". Optionally, align L2481 the same way by adding the between-T0 − 60 s-and-T0 and exact-boundary cases, so that both test lists pin the ≥ comparison L2155 specifies.
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: accepted, folded into Implementation phases, Testing.
+:EVIDENCE:
+Spec docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org:
+- L2193: "snapshot listings: only pre-T0 snapshots fail; one created after T0 − 60 s passes"
+- L2155: "a =<rootds>@pre-pacman_*= snapshot with =creation= ≥ since − 60 s"
+- L140: "created no earlier than 60 s before since"
+- L2481: "only snapshots created before since − 60 s fails, naming the dataset it looked for, and one created after it passes"
+- L2444: "skipped within 60 s of the previous snapshot … which is why the gate requires one no more than 60 s older than the run's start"
+- L130 and L2085 set T0 = now before stage 1, and since = T0 (L108 and L2127, gate.since: <T0>).
+- L964 (prior review round's recommended test): "snapshot listings where only pre-T0 snapshots exist (gate fails) and one is newer than T0 (gate passes)". L2193 is the partially updated descendant of this line.
+
+Code: scripts/zfs-pre-snapshot:13 sets MIN_INTERVAL="${ZFS_PRE_MIN_INTERVAL:-60}", and :22 skips the snapshot when =now - last < MIN_INTERVAL=. This confirms that the snapshot covering a run can be up to 60 s older than T0.
+:END:
+
+** DONE APPLY is declared 'always: True' yet 'shown while N > 0 or a gate failure is open'; maint's lever model can't express either condition
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Phase 2 APPLY L2234, L2236; Phase 2 deferred row L2229-L2230; AC9 L2361-L2362
+
+L2234: APPLY 'is the lever of upgrade_deferred (always: True), shown while N > 0 or a gate failure is open'. L2236: 'While N > 0, APPLY is also offered on the topgrade_age row in TOPGRADE's place.' L2229: a plain deferral (N > 0) grades OK.
+
+Risk: The implementer has to invent a conditional-lever mechanism, or ship APPLY permanently visible (or invisible in the common OK state), contradicting AC9 and the Phase 2 tests at L2255 and L2260.
+
+Recommended change: Keep "always: True" at L2234. It is required because L2229 grades N > 0 OK. Append: "The panel withholds the key through APPLY's panel.LEVER_KEYS items builder, which returns [] when the row's value is > 0 or the row is CRIT for a gate failure, and None otherwise (the _timer_items idiom). APPLY's metric_ids stay ['upgrade_deferred']."
+
+Replace L2236 with: "While N > 0, the topgrade_age card also shows an APPLY key. TOPGRADE is not a row key there today (it is absent from panel.LEVER_KEYS), so nothing is removed from the card, and it remains a strip key. The topgrade_age metric carries no deferred count, so topgrade_freshness adds an evidence row {deferred: N} read from the upgrade_deferred record, and APPLY's items builder reads it. In REVIEW & FIX, doctor.review lists APPLY instead of TOPGRADE for a WARN topgrade_age while that count is > 0."
+
+In L2260, L2362 and L2506, change "APPLY replaces/takes TOPGRADE's place on the topgrade_age row" to: "the topgrade_age card shows APPLY while N > 0 and not at N = 0, and doctor.review offers APPLY rather than TOPGRADE for a stale topgrade_age while N > 0".
+
+If a different channel is preferred (for example, metric_keys taking the envelope), name it in place of the evidence row.
+
+Should-fix, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: modified, folded into Implementation phases, Acceptance criteria, Testing. I kept the card key, as R18 recommends and as round 1's folded change asked ('the topgrade_age row offers that action instead of TOPGRADE'). Cutting it would partly undo that fold. One correction to the finding: attach_levers derives a metric's levers from metric_ids, so APPLY's metric_ids must include topgrade_age for that card to show the key. ['upgrade_deferred'] alone can't reach it. TOPGRADE was never a key on the topgrade_age card, because it is absent from panel.LEVER_KEYS. So 'in TOPGRADE's place' becomes 'the card also shows APPLY, and REVIEW & FIX swaps it in'.
+:EVIDENCE:
+Spec:
+- L2229: "OK otherwise, including N > 0".
+- L2234: "lever of upgrade_deferred (always: True), shown while N > 0 or a gate failure is open".
+- L2236: "While N > 0, APPLY is also offered on the topgrade_age row in TOPGRADE's place. TOPGRADE remains a strip key".
+- L2260, L2362 and L2506: "APPLY replaces/takes TOPGRADE's place on the topgrade_age row".
+- L2351 and L2514: "the deferred row then empties" (the row exists at N = 0).
+
+Code (dotfiles maint/src/maint/):
+- remedies.py:535-545, attach_levers: =levers = [rid for rid in levers_for(m["id"]) if off or REMEDIES[rid].get("always")]=. The always attachment is confirmed.
+- panel.py:155-160 (comment): "An items builder returns the remedy's item list from the metric's own evidence, or None to withhold the key (nothing to act on)".
+- panel.py:184-196, metric_keys: skips a rid not in LEVER_KEYS, or whose items_fn returns None.
+- panel.py:137-153: _timer_items and _battery_items return None conditionally, which is the precedent for the deferred row.
+- panel.py:161-181: LEVER_KEYS has no "topgrade" or "update" entry, so the topgrade_age card renders no TOPGRADE key.
+- gui.py:496-500: TOPGRADE is rendered only as a strip key.
+- doctor.py:389-425, review: selects remedies by =sev[mid] for mid in r["metric_ids"]= being WARN or CRIT. There is no per-metric condition and no swap.
+- probes/updates.py:114-131, topgrade_freshness: the metric carries days and cache_age only. No evidence, no deferred count.
+- indicator.py:42 and 56, and panel.py:474: lever-presence effects apply only to off-nominal rows, so APPLY attached at N = 0 (OK) has no side effect.
+:END:
+
+** DONE Removing the live_update guard tag silently disables the arm-line annotation the spec keeps, and _rearm_after_guard becomes unreachable superseded code
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Phase 2 Guard UX L2219-L2220; Phase 2 test L2262; Testing L2467; Decision 3 L220
+
+L2219: 'Drop guard: "live_update" from the update and topgrade remedies, so iter_fix no longer refuses them.' L2220: 'guard.trips survives only as the arm-line annotation... On a tripped read, arm_line, _rearm_after_guard's text and the doctor review suffix read 'UPDATE armed — will defer at least <guard matches> ... — press again to run upgrade-guarded''. Test L2262 pins that arm line.
+
+Risk: If implemented literally, the display-side mirror that Decision 3 keeps never renders and the L2262 test can't pass. The implementer has to invent a new display-only marker and decide what happens to the dead re-arm path.
+
+Recommended change: In Phase 2 Guard UX, replace L2220 with:
+
+"guard.trips survives only as the pre-run arm-line badge that Decision 3 calls the display-side mirror. The update and topgrade remedies swap the guard tag for a display-only key, "arm_badge": True. panel.guarded() becomes panel.arm_badge(rid), reading that key, and gates both _guard_arm_line in _press_lever and the doctor review suffix (doctor.py:415). Nothing gates execution on it. On a tripped read, the arm line reads '<title> armed — will defer at least <guard matches> (kernels and DKMS modules are always held) — press again to run $ <argv>'. The review suffix reads '[will defer at least <guard matches> — kernels and DKMS modules are always held]'. Nothing says live apply or REBOOT required. With the refusal gone, nothing emits a "guard" event any more, so delete _rearm_after_guard, its call at gui.py:1496-1498, and the "guard" branches at panel.py:368 and cli.py:114, along with their tests."
+
+Then change the test at L2262, and the matching sentence at L2467, to: "an UPDATE press with a tripped guard.trips read, driven through _press_lever, shows 'UPDATE ... armed — will defer at least <guard matches> ...', and a TOPGRADE press shows its own label. A remedy without arm_badge never calls guard.trips."
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: modified, folded into Implementation phases, Testing. Resolved with R07: the rid selector instead of a new arm_badge remedy key, and the review suffix deleted rather than reworded. The deletions R19 lists are adopted: _rearm_after_guard and the guard branches.
+:EVIDENCE:
+Spec text:
+- L2219 says: Drop guard: "live_update" from the update and topgrade remedies, so iter_fix no longer refuses them.
+- L2220 says: On a tripped read, arm_line, _rearm_after_guard's text and the doctor review suffix read 'UPDATE armed — will defer at least ...'.
+- L2262 and L2467 pin that arm line.
+- L1090, from the prior review round: "panel.py:224-237: guarded()/update_guard() key on the same tag".
+- No body section names any replacement gate. grep for panel.guarded, update_guard and "display-only" finds only L1090 and L2394; L2394 says the TOML guard_patterns is display-only.
+
+Code (all paths under ~/.dotfiles/maint/src/maint):
+- remedies.py:298 and :309: update and topgrade both carry "guard": "live_update". No other remedy has the tag.
+- panel.py:224-228: def guarded(rid): return remedies.REMEDIES.get(rid, {}).get("guard") == "live_update"
+- gui.py:1430-1433: if panel.guarded(rid): self._act(self._guard_arm_line(title, argv, th)) else: self._act(viewmodel.arm_line(title, argv)). This is the only call site of _guard_arm_line (gui.py:1396-1405), which is the only caller passing guard= to viewmodel.arm_line (viewmodel.py:99-107).
+- doctor.py:415: if r.get("guard") == "live_update": wraps the review suffix built from guard.trips.
+- doctor.py:216-240: the only site that emits a "guard" event, gated by the same tag, which is also the refusal L2219 removes. Its consumers are gui.py:1496-1498 (calls _rearm_after_guard, defined at gui.py:1518-1535), panel.py:368 and cli.py:114. All of them become unreachable.
+- gui.py:1430-1431 serves both rids, so the literal 'UPDATE armed' would also appear on a TOPGRADE press.
+:END:
+
+** DONE Phase 1 tests need a kernel-set and DKMS fixture, but the named seams don't reach upgrade-guarded's /usr/lib/modules, /usr/src or db.lck reads
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Phase 1 seams L2163; Testing L2453-L2454; Readiness L2401; kernel and DKMS sets L2038-L2039 (Design L71-L72); test L2167; db.lck precondition L2060; --apply-armed step 3 L2101
+
+upgrade-guarded's seams are UPGRADE_GUARDED_HOOK, _HYPR_RUNNING, _ARM_FLAG, _ARMED_LIST, _TOPGRADE, _MAINT, _UNIT_ENABLED and MAINT_STATE_DIR. KMC_MODULES_DIR and KMC_BOOT_DIR are 'Its [the gate's] test seams' (Design L140). The kernel set is derived from the owners of /usr/lib/modules/*/vmlinuz plus /usr/lib/modules/<kver>/pkgbase, and the DKMS set from /usr/src/*/dkms.conf.
+
+Risk: The implementer has to invent seams (or reuse the gate's KMC_* variables, which the spec assigns only to the gate), or the kernel-set tests end up host-dependent.
+
+Recommended change: 1. Add three seams to the seam list at L2163, and mirror them at L2401 and L2453:
+ - UPGRADE_GUARDED_MODULES_DIR (default /usr/lib/modules). Alternatively, state that upgrade-guarded honours KMC_MODULES_DIR and passes it through to the gate.
+ - UPGRADE_GUARDED_SRC_DIR (default /usr/src).
+ - UPGRADE_GUARDED_DB_LCK (default /var/lib/pacman/db.lck).
+2. Name the matching seam in parentheses after the paths at L71-L72 and L2038-L2039, and after db.lck at L83 and L2060.
+3. Add systemd-cat to the fake lists at L2163, L2401 and L2454.
+4. Add one bullet under "Steps and ordering", after L2182: "a file at UPGRADE_GUARDED_DB_LCK makes the everyday run, --complete and --apply-armed each exit 3 naming the file and the manual remedy, run no transaction, leave the file in place and write the record".
+5. Extend L2206 and L2496 to read "a missing, empty or unparseable list, a live Hyprland, a held lock or a present db.lck each exit 3".
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: modified, folded into Implementation phases, Testing, Readiness dimensions. This is the same gap as R29. upgrade-guarded reads its module tree through the gate's KMC_MODULES_DIR rather than a second UPGRADE_GUARDED_MODULES_DIR. One fixture tree then drives the kernel set, the pkgbase resolution and the gate. R20's db.lck and systemd-cat items are adopted through R29.
+:EVIDENCE:
+Spec text:
+- L140: "Its test seams are =KMC_MODULES_DIR= and =KMC_BOOT_DIR=" (said of kernel-modules-check only).
+- L2163, L2401 and L2453 list exactly UPGRADE_GUARDED_HOOK, HYPR_RUNNING, ARM_FLAG, ARMED_LIST, TOPGRADE, MAINT, UNIT_ENABLED, MAINT_STATE_DIR, plus "the gate's KMC_MODULES_DIR and KMC_BOOT_DIR".
+- Fakes (L2163, L2454): pacman, sudo, yay, informant, systemctl, vercmp, checkupdates, dkms, zfs, findmnt, lsinitcpio. systemd-cat is not among them.
+- L2488, under "Phase 1, --complete": "a recorded kernel whose /usr/lib/modules/<kver>/vmlinuz is gone drops out of the check list".
+- L2060 and L2099: the db.lck refusals. L2181-L2182, L2206 and L2496: precondition tests that cover the held upgrade-guarded.lock but never db.lck.
+
+Live system (ratio):
+- ls /usr/lib/modules lists 6.18.25-1-lts-strix, 6.18.54-1-lts and 7.2.7-arch1-1, with pkgbase files reading linux-lts-strix, linux-lts and linux.
+- /usr/src/zfs-2.4.4/dkms.conf exists.
+- pacman -Qqo on those paths returns linux-lts-strix, linux-lts, linux and zfs-dkms.
+- /usr/bin/systemd-cat is owned by systemd 262-1.
+- /var/lib/pacman/db.lck is absent.
+
+Repo idiom:
+- tests/hypr-live-update-guard/test_hypr_live_update_guard.py:46-52 and tests/zfs-pre-snapshot/test_zfs_pre_snapshot.py:49-57 give every path the script touches its own env seam (HYPR_GUARD_SENTINEL, ZFS_PRE_LOCKFILE, and so on).
+:END:
+
+** DONE The record's 'detail' is defined as one line but specified as multi-line pacman output
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Design L107 vs L91, L100; Phase 1 L2126 vs L2051, L2076; Phase 2 L2228
+
+'detail: the last stderr line, or the remedy text' (L107, L2126). Closure refusals put 'pacman's lines and the package names in detail' (L100, L2051). The armed closure refusal prints 'disarmed: closure refused' before the refusal's lines 'so detail keeps pacman's lines' (L91, L2076).
+
+Risk: The writer and the panel renderer may disagree on shape (string vs lines), and so may the shared fixture.
+
+Recommended change: Replace the detail bullet at L107 and again at L2126 with:
+
+"=detail=: a string naming the reason. Usually it is the last stderr line or the remedy text. On a closure refusal it is pacman's error lines followed by one line naming the refused packages, joined with newlines, and that names line is also the run's last stderr line. The row's WARN text uses only detail's last line, cut to 160 characters as doctor.py does for lever details. The row's evidence shows detail whole."
+
+At L91, L248, L2076 and L2382, change "so detail keeps pacman's lines" to "so the disarm line stays out of detail".
+
+With those edits, the definition, the test at L2473, the shared fixture and the panel renderer all agree on one shape.
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: accepted, folded into Implementation phases, Testing.
+:EVIDENCE:
+Spec text that conflicts:
+
+- L107 and L2126: "=detail=: the last stderr line, or the remedy text."
+- L100 and L2051: "Refusing means exit 3, result refused, failed_step closure, pacman's lines and the package names in detail".
+- L2473 (AC2 test): "orphan: ... refuses (exit 3, result refused, failed_step closure, pacman's lines and the names in detail)".
+- L91, L2076, L2382: "prints 'disarmed: closure refused' before the refusal's own lines, so detail keeps pacman's lines".
+- L2228: "WARN when result is failed, refused or interrupted (the text adds detail)".
+- L2225: "Evidence: ... the record's detail".
+- L233: "both test suites read copies of one fixture".
+
+Code: ~/.dotfiles/maint/src/maint/doctor.py:168 has =detail = out[-1].strip()[:160] if out else "done"=. That is maint's existing convention: detail is one line, capped at 160 characters.
+
+I found no other spec line, including the Review findings section around L1116-1123, that defines whether detail is a single line or a newline-joined block, or what the row renders from it.
+:END:
+
+** DONE The KEEP=10 prune can destroy the only old-kernel snapshot before the gated kernel first boots
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Risks L2444 (Last-resort fallback on velox); Decision 5 context L238 ('It is survivable — ZFSBootMenu can boot the pre-pacman snapshot'); gate snapshot rule L140 and Phase 1 L2155; armed everyday rewrite L2073; Decision 2 consequences L214; gate-failure window L2195
+
+The gate requires a pre-pacman snapshot created no earlier than since − 60 s, and Risks relies on ZFSBootMenu booting that snapshot 'with the old kernel, initramfs and modules' after a failed gate or a failure the gate misses. Nothing protects that snapshot afterwards. Meanwhile the design expects everyday runs to continue while armed ('the machine keeps working until the reboot is convenient'; every armed everyday run rewrites the flag) and during an open gate failure (the gate field survives everyday runs).
+
+Risk: Roughly ten transactions between stage 1 and the first boot of the new kernel prune the old-kernel snapshot. That is a few days of armed everyday runs, or installing build dependencies to fix a gate failure. After that, a failure the gate misses at first boot, or an unplanned reboot during a gate failure (battery, power), leaves velox recoverable only from live media. The spec presents that case as survivable through ZFSBootMenu.
+
+Recommended change: Smallest edit that actually protects the snapshot. It touches Phase 1, Risks and one test.
+
+1. Phase 1, --complete step 3. After the gate evaluates on a ZFS root with a non-empty check list, take the oldest <rootds>@pre-pacman_* snapshot whose creation is ≥ since − 60 s. That is the snapshot the gate accepted, holding the pre-stage-1 state. Run =sudo zfs hold upgrade-guarded <snap>= on it whether the gate passed or failed, and record its name in the record (for example a =held_snapshot= field; maint ignores unknown fields).
+
+2. Release. An everyday run or --complete runs =sudo zfs release upgrade-guarded <snap>= and clears the field in either case:
+- =uname -r= equals a kver that passed the gate, meaning a boot into the gated kernel happened;
+- a later --complete holds a newer snapshot.
+A missing snapshot is tolerated.
+
+3. scripts/zfs-pre-snapshot. Skip held snapshots when pruning, for example by listing =-o name,userrefs= and pruning only rows with userrefs 0. Otherwise the EBUSY destroy prints an error on every transaction while the hold is in place.
+
+4. Tests. Add a case in tests/upgrade-guarded/ where a fake zfs logs =hold= and =release=:
+- the hold is placed after a passing gate and after a failing gate;
+- it is released on the first run whose fake =uname -r= is the gated kver;
+- it is never released while gate.ok is false.
+Add a case in tests/zfs-pre-snapshot/ showing that a held snapshot is not destroyed.
+
+5. Risks L2444. Add one sentence: "zfs-pre-snapshot keeps only the newest 10 pre-pacman snapshots, so --complete holds the snapshot the gate accepted until a boot into the gated kernel is seen; without that hold, about ten later transactions (armed everyday runs, or fixes during an open gate failure) would prune the only snapshot with the old kernel."
+
+Documentation-only alternative, if the hold is out of scope: say in Risks L2444 and in the Phase 4 README recovery steps that the fallback lasts only about 10 snapshot-producing transactions (at least 60 s apart) after --complete. Then tell the user to reboot promptly after arming, and during an open gate failure to hold that snapshot by hand (=sudo zfs hold keep <snap>=) before running further everyday upgrades.
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: modified, folded into Implementation phases, Acceptance criteria, Risks, Testing, Readiness dimensions. I took the hold, which is what actually protects the fallback, but dropped the uname-based release. The hold moves only when a later --complete holds a newer snapshot. So exactly one extra snapshot per machine stays pinned, and there is no boot-detection logic to specify or test. I kept a held_snapshot record field rather than scanning snapshots for the tag. The field gives the README recovery a named snapshot to boot, and lets rollback release it. Holding happens only after a --since-form gate. The structural form has no since to pick a snapshot by, and it runs only when no kernel-side version changed. Hold and release failures are tolerated and logged, because the hold protects the fallback rather than gating anything.
+:EVIDENCE:
+Spec:
+- L238: "It is survivable — ZFSBootMenu can boot the pre-pacman snapshot, which holds the old kernel, initramfs, and modules".
+- L2444: "an unplanned reboot after a failed gate, or a failure the gate misses, is recovered from ZFSBootMenu ... can boot the pre-pacman snapshot with the old kernel, initramfs and modules".
+- L214: "even then the machine keeps working until the reboot is convenient".
+- L2073-2077: step 9, the everyday run while armed.
+- L2195 / L2486: "the gate field survives a following everyday run".
+- L140 / L2155: "creation ≥ since − 60 s ... An older retained snapshot doesn't count". This is a lower bound only.
+- L2148: (c) re-checks "with that record's since".
+- Searching the spec for KEEP, retain, prune, "zfs hold" and release turns up only L988, where a prior finding's evidence lists ":14, :35-40 KEEP=10 prune". That finding's disposition (L1000-1006) never addressed retention.
+
+Code:
+- scripts/zfs-pre-snapshot:14 =KEEP="${ZFS_PRE_KEEP:-10}"=. Lines 35-40 list the snapshots sorted by creation, then =grep '@pre-pacman_' | head -n -"$KEEP"=, then =zfs destroy "$old"=. Lines 19-25 skip a snapshot within 60 s of the previous one.
+- archsetup:2421-2433: the 05-zfs-snapshot.hook uses Target = * and PreTransaction, with no AbortOnFail.
+- tests/zfs-pre-snapshot/fake-zfs handles only snapshot, destroy and list. Hold and release would need adding.
+
+Live system (ratio):
+- grep 'transaction started' /var/log/pacman.log gives 585 transactions. Per day: 6 on 2026-07-21, 5 on 07-22, 6 on 08-01, 5 on 09-29. That is roughly 10 within a week or two.
+- man zfs-hold: "If a hold exists on a snapshot, attempts to destroy that snapshot by using the zfs destroy command return EBUSY."
+:END:
+
+** DONE The ZFSBootMenu recovery re-registers only the kernel set, but the rollback reverts every package landed since the snapshot
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Phase 4 Docs L2312; Risks L2444; Readiness Documentation plan L2398; recovery drill L2521
+
+'After booting the snapshot from ZFSBootMenu, re-register the old kernel set with pacman -U --dbonly from /var/cache/pacman/pkg before the next upgrade-guarded run, along with zfs-dkms and zfs-utils if that transaction moved them.'
+
+Risk: After the fallback boot, every package other than the kernel set that changed after the booted snapshot (stage 1's extras and the armed everyday runs) has old files on disk while the un-rolled-back db records the new version. pacman never re-extracts them, and -Qu and the deferred row don't list them. Later upgrades satisfy dependencies the filesystem doesn't actually have, such as a new soname. That silent db/filesystem split is what the documented steps leave behind.
+
+Recommended change: In Phase 4 Docs L2312, the Readiness documentation plan L2398 and Risks L2444, replace "re-register the old kernel set with =pacman -U --dbonly= from =/var/cache/pacman/pkg= before the next =upgrade-guarded= run, along with =zfs-dkms= and =zfs-utils= if that transaction moved them" with:
+
+"reconcile the db to the booted snapshot before the next =upgrade-guarded= run. Read =/var/log/pacman.log= (on =zroot/var/log=, which stays current), take every =[ALPM] upgraded=, =downgraded=, =installed= and =removed= line timestamped after the snapshot's =creation=, and undo them newest first:
+- upgraded or downgraded: =pacman -U --dbonly= the old version from =/var/cache/pacman/pkg=
+- installed: =pacman -Rdd --dbonly=
+- removed: =pacman -U --dbonly= the removed version
+
+Then =pacman -Qkk= over those names reports no mismatches. This covers the kernel and DKMS sets (so the next =--complete= lands them again behind the gate), every other package stage 1 landed, and anything later everyday runs landed."
+
+Keep the existing "Otherwise the db reports the new kernel as installed…" sentence.
+
+In the todo.org recovery drill at L2521, add one package outside the kernel set to stage 1. After the reconcile, confirm that package shows its old version in =pacman -Q= and that =pacman -Qkk= is clean for it.
+
+Optional, and a larger change: snapshot =<pool>/var/lib/pacman= atomically with the root dataset (one =zfs snapshot= call) so the two can be rolled back as a pair.
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: accepted, folded into Implementation phases, Risks, Readiness dimensions, Testing.
+:EVIDENCE:
+Spec docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org:
+- L130 / L2086: Stage 1 "lands the kernel set, the DKMS set and every other pending package except the GPU closure."
+- L2155: the gate requires the =<rootds>@pre-pacman_*= snapshot with creation ≥ since − 60 s, which is the snapshot taken before stage 1.
+- L2444: the fallback covers "an unplanned reboot after a failed gate, or a failure the gate misses". The recovery text re-registers "the old kernel set ... along with zfs-dkms and zfs-utils if that transaction moved them". L2312, L2398 and L2521 say the same.
+- L2191: "the gate field survives a following everyday run". L2073-2077 (everyday step 9): armed everyday runs also transact. Both land packages after stage 1.
+- L1912-1930: the prior finding addressed only the kernel and zfs-dkms. Its disposition adds zfs-utils and nothing else.
+
+Code:
+- ~/code/archangel/installer/archangel:720 creates ROOT/default. Lines 735, 736 and 738 create var/log, var/cache and var/lib/pacman as separate datasets under the pool, not under ROOT.
+- scripts/zfs-pre-snapshot:11 sets =DATASET="${ZFS_PRE_DATASET:-$POOL/ROOT/default}"=. Line 30 runs =zfs snapshot "$DATASET@$SNAPSHOT_NAME"=: a single dataset, no -r.
+- archsetup:2412 and 2421-2432 install the hook with =Exec = /usr/local/bin/zfs-pre-snapshot= and no env override.
+- ratio's /var/log/pacman.log has lines like "[2026-01-25T21:01:39-0600] [ALPM] upgraded gimp (3.0.6-2 -> 3.0.8-1)" and "[ALPM] installed iana-etc (20251215-1)". The log has the old version and a timestamp for every package, which is enough to reconcile the db to the snapshot.
+- Velox was offline, so I didn't check its dataset layout directly. I relied on archangel's installer and the prior finding's reinstall evidence.
+:END:
+
+** DONE Forward closure rule refuses when the unsatisfiable chain runs through a new dependency
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Design L97 ('unable to satisfy ... required by <X>' adds X; 'If X isn't in the pending set, the script refuses'); Phase 1 L2048; tests L2169 and L2469-2474 (AC2)
+
+Every 'unable to satisfy dependency '<d>' required by <X>' line adds X when X is pending. If X is not pending, the script refuses with exit 3 and failed_step closure.
+
+Risk: The refusal branch of the forward rule can fire only on a chain through a new dependency, and in that case the same iteration also names the pending ancestor (ptool) that should be held. On a release day when an unguarded pending package gains a new dependency built against a held soname, the everyday run and --complete refuse instead of holding the ancestor. They exit 3, name the new package rather than the culprit, and upgrade nothing, so AC2 fails. A fake pacman written to the spec's rule would lock this in.
+
+Recommended change: At L97 and L2048, replace "If X isn't in the pending set, the script refuses." with: "If X isn't in the pending set, X is a dependency the transaction would newly install (pacman prints this form only for transaction targets and the packages they pull in). The line adds nothing, because the pending package that pulls X in is named on its own line in the same output. If no line in an iteration adds a name, the existing no-progress rule refuses."
+
+At L2169 (Phase 1 tests) and L2470 (AC2 forward), add a fixture: "forward through a new dependency: the fake emits 'unable to satisfy dependency 'libhyprutils.so=13-64' required by newlib' and 'unable to satisfy dependency 'newlib' required by ptool', where newlib is neither pending nor installed. ptool is held with its stage's kind, the run exits 0 and the -Su runs."
+
+Leave L346 as it is, because it is the earlier review's historical text.
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: accepted, folded into Implementation phases, Testing.
+:EVIDENCE:
+Spec lines:
+- L97 and L2048: "unable to satisfy dependency '<d>' required by <X>" adds X; "If X isn't in the pending set, the script refuses."
+- L69/L2036: the pending set is the LC_ALL=C pacman -Qu rows.
+- L2338 (AC2): "the dependency closure holds that package too, and the run exits 0".
+- L2169 and L2470: the only forward fixtures are hyprutils and hyprlang, with no chain through a new dependency.
+
+First reproduction (pacman v7.1.0, fake db in scratch via --dbpath/--config):
+- Installed: hyprutils 1-1 (provides libhyprutils.so=12-64) and ptool 1-1.
+- Sync: hyprutils 2-1 (=13-64); ptool 2-1, which depends on newlib; newlib 1-1, which depends on libhyprutils.so=13-64.
+- pacman -Qu lists only hyprutils and ptool.
+- -Sup --noconfirm --print-format %n --ignore=hyprutils exits 1. Stdout: ":: unable to satisfy dependency 'libhyprutils.so=13-64' required by newlib" and ":: unable to satisfy dependency 'newlib' required by ptool".
+- Adding --ignore=ptool exits 0.
+- Adding --ignore=newlib instead still fails on "'newlib' required by ptool".
+
+Second reproduction (chain ptool -> n1 -> n2 -> held soname, plus qtool -> n2): a single probe prints lines naming n2, n1, ptool, n2 and qtool. Every pending ancestor shows up in the same iteration. --ignore=hyprutils --ignore=ptool --ignore=qtool exits 0 and leaves "other" to upgrade.
+
+Live system: hyprpaper (unguarded) depends on hyprwire and hyprtoolkit (expac -Q %N). hyprtoolkit depends on libhyprutils.so=13-64 and libaquamarine.so=14-64. hyprutils and aquamarine are both Targets in /etc/pacman.d/hooks/hypr-live-update-guard.hook.
+:END:
+
+** DONE The closure probe's output split (stdout detail vs stderr header) is unspecified, and the generic header matches neither form
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Design L96 ('parses every error line, with or without the leading ::') and L100 ('refuses when a line matches neither form'); Phase 1 L2047 and L2050; tests L2169 and L2469
+
+The script parses 'every error line' of the -Sup probe and refuses on any line that matches neither the 'unable to satisfy' form nor the 'breaks dependency' form.
+
+Risk: Read literally, the only stderr line, and the first line of the combined output, is the generic header, which matches neither form. Every non-zero probe would then refuse and the closure would never add anything. An implementer who captures only stderr as the 'error lines' never sees the dependency lines at all. Either way the soname-day behaviour in AC2 is lost. The Phase 1 fake pacman emits whatever split the implementer assumed, so the tests can't catch it.
+
+Recommended change: Replace the "parse every error line…" sentence at L96 and at L2047 with this output contract:
+
+"The probe's output contract (LC_ALL=C, stdout not a tty):
+- On exit 0, stdout is the %n target list.
+- On a non-zero exit, stderr carries one header, 'error: failed to prepare transaction (<reason>)', and stdout carries the ':: '-prefixed detail lines.
+- The script classifies the stdout ':: ' lines against the two forms below, and accepts the header only when <reason> is 'could not satisfy dependencies'."
+
+Change L100 and L2050 to read: "It also refuses when the header has any other reason, when any other stderr line appears, or when a ':: ' line matches neither form, …". Keep the rest of the sentence as it is.
+
+At L2169 and in the AC2 bullets at L2469-2475, add: "the fake pacman reproduces the split: the detail lines on stdout and the 'could not satisfy dependencies' header on stderr. A header with any other reason refuses, and so does a stray stderr line."
+
+Don't name conflicts as an example of a header the probe will report. In -p mode pacman suppresses warnings, and its conflict checking under print mode hasn't been verified here.
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: accepted, folded into Implementation phases, Design, Testing.
+:EVIDENCE:
+I reproduced this read-only on ratio against the existing checkupdates db (pacman v7.1.0, 397 pending), running each command under bash with the streams captured separately.
+
+Forward case: LC_ALL=C pacman -Sup --dbpath /tmp/checkup-db-1000 --noconfirm --print-format %n --ignore=gdb-common
+- exit 1
+- STDOUT, exactly one line: ":: unable to satisfy dependency 'gdb-common=18.1' required by gdb"
+- STDERR: "error: failed to prepare transaction (could not satisfy dependencies)"
+
+Reverse case: the same command with --ignore=gdb
+- exit 1
+- STDOUT: ":: installing gdb-common (18.1-1) breaks dependency 'gdb-common=17.2' required by gdb"
+- STDERR: the same "error: failed to prepare transaction (could not satisfy dependencies)" header
+
+Other observations:
+- On failure, stdout carries no %n target list, only the ":: " lines.
+- cat -A showed no colour escapes even though /etc/pacman.conf:33 sets Color, because stdout is not a tty.
+- --ignore=aws-c-common, aws-c-io, aws-crt-cpp and harfbuzz each exited 0 (the success path).
+
+Spec references:
+- L96 and L2047: "parse every error line, with or without the =::= prefix"
+- L100 and L2050: refuse "when a line matches neither form (conflict, missing target, replace)"
+- L2169 and L2469-2475: the fake pacman only "emits both message shapes"
+- L346: the prior finding defines the two forms but no stream or header contract
+
+No implementation exists yet (no upgrade-guarded in scripts/ or tests/), so the spec is the only contract.
+:END:
+
+** DONE pacman exit 1 means both 'empty' and 'error' for -Qu and -Qqo, and the empty DKMS-set case is undefined
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Design L69 and Phase 1 L2036 (-Qu 'Exit 1 means nothing is pending, not a failure'); deferred set from post-transaction -Qu at L77 and L2043; DKMS set at L72 and L2039 and kernel set at L71 and L2038 (pacman -Qqo of globs)
+
+Exit 1 from pacman -Qu is read as an empty pending set. The DKMS and kernel sets are 'the owners of /usr/src/*/dkms.conf' and '/usr/lib/modules/*/vmlinuz' via =pacman -Qqo=, and the spec doesn't say what a no-match or unowned path does.
+
+Risk: A post-transaction -Qu that errors would be read as 'nothing deferred'. The record would then be empty, and a TOPGRADE run could stamp fresh while packages are still held. A Hyprland machine with no DKMS package gets upgrade-guarded from the installer, and there deriving the DKMS set errors; depending on set -e handling the run aborts or proceeds with an undefined set.
+
+Recommended change: Three spec edits:
+
+1. L69 and L2036: replace "Exit 1 means nothing is pending, not a failure" (L2036's wording is "Exit 1 means empty, not failure") with: "Exit 1 with empty stdout and no =error:= line on stderr means nothing is pending (warnings are tolerated); any other non-zero exit is a failure. Before the transaction it is failed_step refresh, exit 1, with no transaction. After the transaction it is failed_step pacman, exit 1: the record keeps the pre-transaction pending ∩ held set as packages, and nothing stamps."
+
+2. L71/L72 and L2038/L2039: add "Only existing paths are passed to =pacman -Qqo= (nullglob). No match is an empty set, not a failure, and a vmlinuz with no owner is left out like a foreign kernel."
+
+3. Testing (L2165-2172): add two bullets:
+ - "with no /usr/src/*/dkms.conf the DKMS set is empty and the everyday run proceeds";
+ - "a fake pacman -Qu that prints an error: line and exits 1 fails the step, and the record does not empty or stamp."
+
+Should-fix, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: modified, folded into Implementation phases, Testing. Adopted, plus one case the finding exposes but doesn't name. kernel-modules-check runs dkms, and a Hyprland machine with no DKMS module (a btrfs install without NVIDIA) has no dkms binary. 'Command not found' would then fail every kernel gate there. So the module item passes when dkms isn't installed. A KMC_DKMS seam, where empty means absent, lets the tests exercise that on machines that do have dkms.
+:EVIDENCE:
+Spec lines:
+- L69 and L2036: "Exit 1 means nothing is pending, not a failure" / "Exit 1 means empty, not failure".
+- L77 and L2043: the deferred set comes from a post-transaction pacman -Qu, and it is the record's packages list.
+- L115: the everyday run stamps when "the record's packages list is empty".
+- L129: --complete with nothing pending "rewrites the record empty and exits 0".
+- L72 and L2039: DKMS set = owners of /usr/src/*/dkms.conf (pacman -Qqo).
+- L2165-2172: the Testing list has no case for a -Qu error or an empty DKMS set.
+- L2524: rollout step (a) runs --dry-run only on the two daily drivers.
+
+Commands run on ratio:
+- LC_ALL=C pacman -Qu --dbpath /nonexistent-db prints "error: failed to resolve path ..." and exits 1.
+- The real LC_ALL=C pacman -Qu prints nothing to stdout or stderr and exits 1, so exit 1 alone can't tell the two apart.
+- pacman --config <copy of pacman.conf plus a repo with no db> -Qu leaves stdout empty, prints "warning: database file for 'nonexistentrepo' does not exist" to stderr, and exits 1. The "stderr empty" test would wrongly fail this empty case.
+- bash -c 'pacman -Qqo /usr/src/*/nonexist.conf' prints "error: No package owns /usr/src/*/nonexist.conf" and exits 1.
+- pacman -Qqo /usr/lib/modules/*/vmlinuz prints linux-lts-strix, linux-lts and linux and exits 0. No unowned vmlinuz exists today.
+- pacman -Qqo /usr/src/*/dkms.conf prints zfs-dkms and exits 0 on ratio.
+
+Code:
+- archsetup:2614-2653 installs the guard inside hyprland() on every Hyprland install.
+- grep zfs-dkms in archsetup finds nothing; it isn't installed by the installer.
+- scripts/testing/lib/vm-utils.sh:23 sets FS_PROFILE="${FS_PROFILE:-btrfs}".
+:END:
+
+** DONE The deferred row's state with no record on disk is undefined, and every machine reaches Phase 2 in that state
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Phase 2 'The deferred row' L2222-2230; Phase 1 --dry-run L2033; Rollout step (a) L2322/L2411
+
+Phase 2 defines N, evidence and severity only from fields of an existing record (L2223-2229). Nothing says what upgrade_deferred reports when ~/.local/state/maint/upgrade_deferred.json is absent or unreadable. Rollout step (a) runs only 'upgrade-guarded --dry-run', which 'writes nothing (no record, stamp or flag)' (L2033), so every existing machine pulls the Phase 2 commit (step b) with no record. Every fresh rebuild is in the same state until its first run.
+
+Risk: The implementer has to invent the first state every machine passes through after rollout (b), including the row's severity, whether APPLY shows, and whether the strip reddens. The two machines could also end up on different readings if they're handled ad hoc.
+
+Recommended change: In Phase 2 'The deferred row' (after L2229), add one bullet:
+
+"No record (cache.get returns None: never written yet, since --dry-run writes none): OK, value 0, text '0 deferred', no APPLY. Nothing has run, so nothing is deferred; unlike topgrade_age, this row doesn't treat unknown as stale. A record whose data lacks the documented fields reads UNPROBED 'deferred-set record malformed — rerun upgrade-guarded' (the _cached idiom), with no APPLY, and never crashes the envelope."
+
+Because the record is written atomically, the probe doesn't need to tell absent from unreadable apart. That matches cache.get's single None.
+
+In the Phase 2 test list (after L2254), add: "with no record file: the row reads OK 0, offers no APPLY, and the topgrade_age row keeps TOPGRADE; a record whose data lacks packages reads unprobed."
+
+Should-fix, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: accepted, folded into Implementation phases, Acceptance criteria, Testing.
+:EVIDENCE:
+Spec:
+- L2222-2229: deferred row. Value comes from "the record's packages". Severity is CRIT on gate.ok false, WARN on result failed/refused/interrupted or K > 0, and "OK otherwise". There is no absent-record clause.
+- L2234: APPLY is "shown while N > 0 or a gate failure is open".
+- L2033 and L2184: --dry-run "writes nothing (no record, stamp or flag)".
+- L2316-2323 and L2405-2412: rollout step (a) ends with "confirm upgrade-guarded --dry-run exits 0", then (b) pulls Phase 2.
+- L2120: the record is written only by upgrade-guarded, at the end of a non-dry-run mode, atomically (tmp then rename).
+- L2250-2262: Phase 2 tests have no no-record or malformed-record case.
+
+Code:
+- ~/.dotfiles/maint/src/maint/cache.py:35-42: get() returns None on OSError/ValueError/KeyError/TypeError, so absent and unreadable can't be told apart.
+- maint/src/maint/probes/updates.py:114-126: topgrade_freshness turns None into schema.WARN, "no topgrade run recorded".
+- updates.py:22-39: _cached turns None or a wrong shape into schema.unprobed. Its docstring says "a wrong-shape payload must degrade here, never crash the envelope".
+- remedies.py:535-545: attach_levers adds always-remedies "regardless of severity".
+- indicator.py:42: diag = metrics without levers. Only these colour the glyph.
+:END:
+
+** DONE The everyday split transaction is never run against real pacman before the levers are repointed, and AC1's hook-silent bullet has no test
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): AC1 L2335; Testing map for AC1/AC3 L2461-2468; Phase 1 tests L2163; Rollout step (a) L2322/L2411; manual tests L2512-2519
+
+Every Phase 1 test drives upgrade-guarded against a fake pacman on PATH (L2163). Rollout step (a)'s only live check is that 'upgrade-guarded --dry-run' exits 0, and dry-run never transacts and uses checkupdates' private db (L2033). The written manual tests cover only the boot path (L2512: 'What no unit test can cover is the boot path itself'). The Testing map assigns AC1 only to the fake-pacman tests and to Phase 2 rendering.
+
+Risk: The first real exercise of the core feature is a press of UPDATE after rollout (b). A divergence between the fakes and real pacman shows up there as a guard abort or a closure refusal, while AC1 has already been ticked on fake evidence.
+
+Recommended change: Three edits:
+
+1. In Phase 4 rollout step (a), at L2316-2322 and its Readiness copy at L2405-2411, add a last bullet. On ratio: "with Hyprland live, run upgrade-guarded --no-topgrade --no-aur from a terminal in the session, right after a --dry-run. Expected: exit 0, no BLOCKED banner, the record's packages equal dry-run's predicted deferred set, and a following pacman -Qu lists only those names plus [ignored] rows. Pull the Phase 2 commit only after this passes."
+
+2. In Testing, at L2512-2519, add two manual entries for todo.org under Manual testing and validation. Each gets its own what-it-verifies line, steps and Expected line.
+ - "Everyday split run, live": run the step (a) command above. If --dry-run lists a GPU-kind entry, that entry is the hook-silence check.
+ - "Everyday split run, Hyprland stopped": the same command from a TTY with Hyprland stopped. Expected: the GPU-kind entries land, and only kernel-kind entries remain in the record and in pacman -Qu.
+ Neither entry waits on a GPU-kind update being pending before (b). If none is pending at (a), run the GPU-kind live variant on the first day one is, before ticking AC1.
+
+3. In the AC1/AC3 map at L2461, add a sub-bullet. It maps AC1's "hook silent" bullet and its exit-0/applies-everything bullet, and AC3's held-set claim, to these two manual entries.
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: modified, folded into Implementation phases, Testing, Acceptance criteria. I took the live check before the levers move, on both machines. I moved the Hyprland-stopped variant into the make test-keep FS_PROFILE=zfs VM, which is headless and so naturally not live, rather than asking for a logged-out session on a daily driver. The command is 'upgrade-guarded --no-topgrade', because R58 removes --no-aur. It also exercises yay -Sua, which UPDATE runs anyway.
+:EVIDENCE:
+Spec:
+- L2322 and L2411: rollout step (a) ends with "confirm upgrade-guarded --dry-run exits 0".
+- L2323 and L2412: step (b) repoints the levers and the alias, and "the force path is gone".
+- L2033 and L80: dry-run "never touches the system db"; its pending set comes from checkupdates and its closure runs pacman -Sup --dbpath against checkupdates' db.
+- L2036: the everyday pending set is "LC_ALL=C pacman -Qu ... minus rows marked [ignored]".
+- L2043 and L77: the deferred set is read from a post-transaction pacman -Qu.
+- L2163: every Phase 1 test runs "with fakes on PATH for pacman, sudo, yay ...".
+- L2335: AC1 says "leave the hypr-live-update-guard hook silent during the pacman step".
+- L2460: "Each acceptance criterion maps to the cases that verify it."
+- L2461-2468: the AC1/AC3 map has no case that observes the hook.
+- L2512: "What no unit test can cover is the boot path itself"; the manual tests at L2513-2519 are all boot tests.
+- L2030 and L80: --no-aur "exists for manual runs and tests", but no manual run is scheduled.
+- L94: the proof of concept ran "before the kernel/DKMS hold and the containers disable were added".
+
+System (ratio):
+- /usr/bin/checkupdates:155: mapfile -t updates < <(pacman -Qu --dbpath "$CHECKUPDATES_DB" ... | grep -v '\[.*\]'). Dry-run never sees an [ignored] row.
+- /etc/pacman.conf:25: IgnorePkg = bridge-utils.
+- /var/log/ratio-upgrade.log: six "warning: <pkg>: ignoring package upgrade" lines, then "Packages (724)", with linux, linux-lts, zfs-dkms and zfs-utils inside the transaction. This was a real --ignore run, but without the hold or this script's argv.
+- scripts/hypr-live-update-guard:121: " BLOCKED: live GPU/compositor library upgrade while Hyprland is running". The banner the recommended check looks for exists.
+- pacman 7.1.0.r9.g54d9411-2 and pacman-contrib 1.13.1-1 are installed.
+:END:
+
+** DONE upgrade-guarded reads several real system paths with no named seam, so the specified kernel-set fixture can't be built and tests depend on the host
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Phase 1 seam list L2163; kernel set and DKMS set L2038-2039; gate check list (c) L2148; preconditions L2060, L2099; informant L2063; systemd-cat L2101; kernel-set test L2167; informant test L2175
+
+The seam list gives upgrade-guarded only UPGRADE_GUARDED_* and MAINT_STATE_DIR. KMC_MODULES_DIR and KMC_BOOT_DIR are the gate's seams (L140, L2401). Yet upgrade-guarded itself globs /usr/lib/modules/*/vmlinuz, reads /usr/lib/modules/<kver>/pkgbase and /usr/src/*/dkms.conf, checks /usr/lib/modules/<kver>/vmlinuz for open-gate kernels, refuses on /var/lib/pacman/db.lck, detects informant with 'command -v', and pipes through systemd-cat.
+
+Risk: The kernel-set and DKMS-set tests see the host's real kernels: three on ratio, one on velox. They either can't build their fixture or pass on one daily driver and fail on the other. A pacman running in another terminal turns every test that reaches the precondition into a refusal. 'Absent informant' can't be expressed on velox. The --apply-armed tests write into the real journal.
+
+Recommended change: Edit L2163 and its mirrors at L2401 and L2453, and add a matching line to Readiness Dev tooling:
+"upgrade-guarded reads its module tree from ${KMC_MODULES_DIR:-/usr/lib/modules}, the same seam as the gate, so one fixture tree drives the kernel set, pkgbase, the vmlinuz-to-kver mapping and check list (c). It also honours UPGRADE_GUARDED_SRC_DIR (default /usr/src, for the dkms.conf glob), UPGRADE_GUARDED_DB_LCK (default /var/lib/pacman/db.lck) and UPGRADE_GUARDED_INFORMANT (the command to probe, default informant; empty means absent)."
+
+Add systemd-cat to the fakes-on-PATH list.
+
+Under "Steps and ordering" (near L2181-2182), and in the verification line at L2510, add:
+"a file at UPGRADE_GUARDED_DB_LCK makes the everyday run, --complete and --apply-armed each exit 3 before any transaction, naming the file and the remedy, and the file is still present afterwards."
+
+At L2175, change "absent informant" to "absent informant (UPGRADE_GUARDED_INFORMANT empty)".
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: accepted, folded into Implementation phases, Readiness dimensions, Testing.
+:EVIDENCE:
+Spec docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org:
+- L2163: "...the env seams UPGRADE_GUARDED_HOOK, ..._HYPR_RUNNING, ..._ARM_FLAG, ..._ARMED_LIST, ..._TOPGRADE, ..._MAINT, ..._UNIT_ENABLED and maint's MAINT_STATE_DIR, plus KMC_MODULES_DIR and KMC_BOOT_DIR, with fakes on PATH for pacman, sudo, yay, informant, systemctl, vercmp, checkupdates, dkms, zfs, findmnt and lsinitcpio". systemd-cat isn't in the fakes list.
+- L140: "Its test seams are KMC_MODULES_DIR and KMC_BOOT_DIR". L2401 and L2453 both say "the gate's KMC_MODULES_DIR and KMC_BOOT_DIR".
+- Hardcoded paths: L2038 (/usr/lib/modules/*/vmlinuz, /usr/lib/modules/<kver>/pkgbase), L2039 (/usr/src/*/dkms.conf), L2148 (c) (/usr/lib/modules/<kver>/vmlinuz), L2060 and L2099 (db.lck), L2063 (command -v informant), L2101 (systemd-cat -t archsetup-boot-upgrade).
+- L2167 is the three-owner fixture, which asserts on "the gate's check list". L2175 says "a failing or absent informant is tolerated". L2427 says informant is installed on velox and absent on ratio.
+- db.lck refusal: the AC at L2379 requires it, but the Phase 1 precondition tests at L2181-2182 and L2510 cover only EUID 0 and the held flock.
+
+Live system (ratio):
+- ls /usr/lib/modules/*/vmlinuz returns three files: 6.18.25-1-lts-strix (linux-lts-strix), 6.18.54-1-lts (linux-lts) and 7.2.7-arch1-1 (linux).
+- /usr/src/zfs-2.4.4/dkms.conf exists.
+- stat /var/lib/pacman shows root:root 755.
+- informant is absent.
+- /usr/bin/systemd-cat is present.
+
+Repo precedent:
+- tests/zfs-pre-snapshot/test_zfs_pre_snapshot.py:50 prepends the fake bin to the real PATH (env["PATH"] = self.bin + os.pathsep + env["PATH"]), and :52 seams the lock path with ZFS_PRE_LOCKFILE.
+- tests/hypr-live-update-guard/test_hypr_live_update_guard.py:51 seams the sentinel path with HYPR_GUARD_SENTINEL.
+:END:
+
+** DONE APPLY's place in maint's remedy model and its CLI form are undefined
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Phase 2 APPLY L2234-2236; Phase 2 tests L2257
+
+APPLY 'is the lever of upgrade_deferred (always: True)' but 'doesn't use the captured-output lever runner'. Like MERGE, it Popens foot, and 'maint's CLI form of APPLY runs upgrade-guarded --complete in the foreground'. No remedy id, remedy kind or CLI invocation is named.
+
+Risk: The implementer has to invent a new remedy kind or key kind, its id, and a non-capturing CLI execution path. Built as an ordinary 'user' remedy, the CLI form runs the whole kernel session with its output captured and under a timeout. The tty reboot prompt then never appears, and the gate verdict is cut to a 160-character last line.
+
+Recommended change: In the Phase 2 APPLY block (L2234-2235), replace the first sentence and the last sentence of the second bullet with this:
+
+"APPLY is the REMEDIES entry =apply_deferred=:
+- tier confirm and a new kind =terminal=;
+- argv =['upgrade-guarded', '--complete']=;
+- metric_ids =['upgrade_deferred']=, always True;
+- no timeout;
+- not a strip key, and in no macro.
+
+Every GUI press of a terminal-kind remedy arms on the first press and Popens =['foot', '--hold', '-e', *argv]= with start_new_session=True on the second, never calling _fire. That covers the row lever, the =topgrade_age= row's APPLY, and REVIEW & FIX's FIX key. =maint fix apply_deferred= is the CLI form, and it is the usage line REVIEW & FIX prints. It runs argv with inherited stdin, stdout and stderr and no timeout, never through cmd.run, and returns the script's exit code."
+
+Add two test bullets at L2257:
+- iter_fix and =maint fix apply_deferred= never call cmd.run, and run the argv with inherited stdio.
+- A REVIEW & FIX FIX press on apply_deferred Popens the foot argv and never streams through _fire.
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: modified, folded into Implementation phases, Testing. Adopted, with APPLY's metric_ids widened to include topgrade_age so that R18's card key attaches through attach_levers.
+:EVIDENCE:
+Spec:
+- L2234: "It is the lever of =upgrade_deferred= (always: True)".
+- L2235: "doesn't use the captured-output lever runner. Like MERGE, it opens =foot --hold -e upgrade-guarded --complete= via subprocess.Popen(..., start_new_session=True) ... maint's CLI form of APPLY runs =upgrade-guarded --complete= in the foreground". No id, no kind, no CLI command.
+- L2257: tests cover only "APPLY's argv and the detach". L2490 covers the GUI path only.
+- L2096 and L2201: --complete prompts 'Reboot now? [y/N]' whenever stdin is a tty.
+- L697 (prior evidence): "maint's cmd.run ... capture_output=True with stdin inherited. A prompt there is either invisible or gets EOF."
+
+Code (~/.dotfiles/maint/src/maint):
+- remedies.py:535-545: always is honored only for REMEDIES entries.
+- Remedy kinds: priv (35), user (7) and macro (1); no other kind exists.
+- viewmodel.py:259: MERGE is a row key {"kind": "merge"}, not a remedy.
+- gui.py:986: the merge key is dispatched to _on_merge.
+- gui.py:1665-1672: _on_merge Popens _MERGE_ARGV with start_new_session=True.
+- gui.py:1408-1445: _press_lever is "the arm-or-fire state machine behind every remedy key". It leads to _fire and the doctor stream.
+- gui.py:740-743: the REVIEW & FIX roster gives every item-less remedy an armed FIX key.
+- doctor.py:389-426: review() lists =maint fix <rid>= for confirm-tier remedies on WARN or CRIT metrics.
+- doctor.py:161: non-priv steps run cmd.run(step["argv"], timeout=r.get("timeout", 60)).
+- doctor.py:164-168: on success the detail is the last stdout line, cut to 160 characters; on failure, a 500-character stderr tail.
+- cmd.py:22: subprocess.run(cmd, capture_output=True, text=True, timeout=timeout).
+- cli.py:136-148: cmd_fix routes through doctor.iter_fix only.
+- grep for stdin, inherit or capture_output=False in maint finds no foreground execution path.
+:END:
+
+** DONE Phase 2's GUI-level tests have no harness, and an unhandled key kind silently becomes MERGE
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Phase 2 tests L2250-2262 (L2257 'APPLY's argv and the detach'); DISMISS key L2225; AC7 L2357-2359
+
+Phase 2 requires a test of APPLY's argv and detach, placed 'like MERGE'. It adds a DISMISS key that writes upgrade_news_dismissed. The only DISMISS test listed checks the read side ('leaves dismissed news titles off the row'). Nothing says whether DISMISS clears one title or all of them.
+
+Risk: Put where the spec places it, the APPLY detach test can't be written without GTK. A new 'dismiss' or 'apply' key that _digest_key misses opens pacdiff instead, and no test would catch it. AC7's 'until they are dismissed' has no test of the write.
+
+Recommended change: Change L2225 to: "...which the row's DISMISS key writes. DISMISS is one unarmed row key, shown while any undismissed title is listed. It adds every title currently shown to upgrade_news_dismissed through a GTK-free helper in panel.py, and gui.py's _digest_key handles the new kind explicitly."
+
+Change L2257 to: "APPLY's argv and the detach: the argv and a detached launcher live in panel.py, and gui.py calls them. The test patches subprocess.Popen there and asserts the foot --hold argv and start_new_session=True."
+
+Add a test bullet after L2254: "DISMISS writes every shown title to upgrade_news_dismissed, after which the row lists none of them, and a title first recorded by a later run shows."
+
+Optional: a test that each key kind viewmodel emits is one gui.py dispatches explicitly.
+
+Should-fix, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: modified, folded into Implementation phases, Testing. I adopted the GTK-free placement and the explicit dispatch. DISMISS follows R38's resolution: arm-then-fire, overwriting the key with the record's whole news list. That replaces one unarmed press that adds the shown titles, which would grow the key without bound. The optional 'every key kind is dispatched' test is not taken. It needs gui.py imported, which needs GTK, and handling the new kind explicitly already closes the risk this change introduces.
+:EVIDENCE:
+Spec L2225: "the news titles minus those in maint key =upgrade_news_dismissed=, which the row's DISMISS key writes". L2254: the only DISMISS-related test, read side only. L2257: "APPLY's argv and the detach". L2235: "Like MERGE, it opens =foot --hold -e upgrade-guarded --complete= via =subprocess.Popen(..., start_new_session=True)=". L2359 (AC7): "the deferred row shows them until they are dismissed". L2500: the Testing map for AC7 names only the read side.
+
+Code in ~/.dotfiles/maint/src/maint/:
+- gui.py:57-60 imports cairo and gi and requires Gtk 4.0.
+- gui.py:945-986: _digest_key's final unconditional return sends any unhandled kind to self._on_merge(a[0]).
+- gui.py:94: _MERGE_ARGV = ["foot", "-e", "sudo", "pacdiff"].
+- gui.py:1665-1677: _on_merge Popens it with start_new_session=True.
+- viewmodel.py:204-206 lists key kinds as fix or keep/unkeep/merge. viewmodel.py:259 emits kind "merge", which relies on the fall-through.
+- grep -i dismiss across maint/src/maint/*.py finds no news-dismiss handler today.
+
+Tests in ~/.dotfiles/tests/maint/:
+- test_panel.py:3-7: "The GTK view (maint/gui.py) is never imported here — every decision it renders lives in the GTK-free presenter (maint/panel.py)".
+- grep for gui imports in tests/maint finds only that docstring.
+
+On ratio, python3 with gi.require_version('Gtk','4.0') imports fine.
+:END:
+
+** DONE The Phase 3 boot checks gating velox can't be observed in the VM the spec names, and that VM gets the published dotfiles
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Testing L2513-2514, L2526; Readiness Dev tooling L2403
+
+'The Phase 3 boot checks pass in the make test-keep FS_PROFILE=zfs VM before the unit reaches velox.' The checks expect a visible tty1 banner and login, and the 'Armed boot' check starts with 'arm from APPLY and confirm armed... and REBOOT'. The same paragraph also says 'Where Hyprland isn't live in the VM, UPGRADE_GUARDED_HYPR_RUNNING=1 forces the arm path'.
+
+Risk: The gate that has to pass before velox can't produce its expected observations as written, so it either stalls or gets waived. Unless Phase 2 has been pushed, the VM's maint has no deferred row, so 'the deferred row empty / at WARN' can't be checked either. Pushing Phase 2 early, on the other hand, exposes it to a velox pull before step (a).
+
+Recommended change: Replace the Trigger bullet's last sentence at L2513 with a VM-procedure bullet:
+
+"In the VM (make test-keep FS_PROFILE=zfs runs QEMU headless with no compositor), arm over SSH from a console with 'UPGRADE_GUARDED_HYPR_RUNNING=1 upgrade-guarded --complete' instead of APPLY. Confirm the armed state from the flag and the record, not the panel. Observe tty1 through the QEMU monitor's screendump on vm-images/qemu-monitor-zfs.sock; a guest reboot keeps the same QEMU process and SSH. Confirm the banner and the before-login ordering from 'journalctl -u archsetup-boot-upgrade' and getty@tty1's start timestamp. Arming from APPLY, the panel's armed row and REBOOT are checked on ratio and velox (AC5)."
+
+Change L2526 (and the matching clause at L2403) to: "The Phase 3 boot checks other than Unread news (velox) and Banner on ratio pass in the make test-keep FS_PROFILE=zfs VM before the unit reaches velox."
+
+Optionally add "run it with DOTFILES_SOURCE=$HOME/.dotfiles if the Phase 2 commit isn't on the published remote yet."
+
+Do not adopt the finding's debug-vm.sh step. debug-vm.sh:92-94 restores the clean-install snapshot on the kept base disk and would erase the install under test.
+
+Should-fix, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: accepted, folded into Implementation phases, Testing.
+:EVIDENCE:
+Spec L2526: "The Phase 3 boot checks pass in the =make test-keep FS_PROFILE=zfs= VM before the unit reaches velox." L2403: same gate under Dev tooling. L2514: "Armed boot, networking down: arm from APPLY and confirm 'armed — N apply at next boot' and REBOOT; ... the banner ... on tty1, the set installed from the cache before the tty1 login". L2513: "Where Hyprland isn't live in the VM, =UPGRADE_GUARDED_HYPR_RUNNING=1= forces the arm path." L2515: "Unread news: an armed boot on velox". L2519: "Banner on ratio". L2235: APPLY opens "foot --hold -e upgrade-guarded --complete" from the panel.
+
+Code:
+- scripts/testing/run-test.sh:150: start_qemu "$DISK_PATH" "disk" "" "none"
+- scripts/testing/lib/vm-utils.sh:226-229: gtk gets virtio-vga-gl and -display gtk, anything else gets -display none
+- scripts/testing/tests/test_desktop.py:7: "(the headless test VM has none)"
+- scripts/testing/archsetup-vm.conf:15: AUTOLOGIN=yes
+- dotfiles maint/src/maint/cli.py:32-40: _human prints severity, id, label and value only
+- run-test.sh:197: DOTFILES_SOURCE defaults to https://git.cjennings.net/dotfiles.git
+- run-test.sh:176: archsetup bundled from HEAD
+- scripts/testing/debug-vm.sh:92-94: 'if [ -z "$OVERLAY_DISK" ] && snapshot_exists "$VM_DISK" "clean-install"; then ... restore_snapshot "$VM_DISK" "clean-install"'
+- vm-utils.sh:63 and 73: DISK_PATH=archsetup-base-zfs.qcow2 and MONITOR_SOCK=qemu-monitor-zfs.sock under FS_PROFILE=zfs
+- vm-utils.sh:198-215: the qemu argv has no -no-reboot and no -vga none
+:END:
+
+** DONE The routine-update workflow keeps running bare topgrade on velox until the Phase 4 doc commit, which waits on Phase 3
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Implementation phases L2022 (docs land after Phase 3); Phase 4 L2313; Risks 'Entry points outside the hold' L2446; Testing L2526
+
+The docs/workflows/system-health-check.org change, which makes step 6 run upgrade-guarded and stop hand-stamping, lands in Phase 4 'after Phase 3'. Phase 3 can't reach velox until the VM boot checks pass. Risks says the hold covers 'the system-health-check workflow's update step'.
+
+Risk: Between Phase 2 and Phase 4, an agent-run health check on velox lands the kernel and zfs-dkms ungated through bare topgrade. That's the unbootable chain the kernel decision exists to prevent, while the spec claims the workflow is covered. It may also hand-stamp freshness while the record lists deferred entries.
+
+Recommended change: Change the L2022 intro sentence to: "The system-health-check.org edit lands as an archsetup doc commit right after Phase 1. Phase 4 holds the remaining docs (the maint/README.md flow and recovery steps in dotfiles), which land after Phase 3, and the per-machine rollout."
+
+Move the L2313 bullet out of Phase 4 into Phase 1, worded as: "Update docs/workflows/system-health-check.org Phase 3, step 6:
+- Where upgrade-guarded --dry-run exits 0 (step (a) done on this machine), run upgrade-guarded instead of plain topgrade.
+- A day with a pending kernel or DKMS package lands it through upgrade-guarded --complete as the dedicated session. Until step (c), a pending GPU/compositor set finishes from a console with Hyprland stopped (L2095).
+- Where step (a) isn't done and a kernel or DKMS package is pending, don't run plain topgrade.
+Step 5's strix addendum keys on --complete. The hand-stamp fallback becomes 'the script stamps; never hand-stamp while a deferred set is outstanding'. Append a resolution note to the 2026-09-12 containers KIL entry."
+
+Leave L2446 unchanged.
+
+Should-fix, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: accepted, folded into Implementation phases, Readiness dimensions.
+:EVIDENCE:
+- Spec L2022: "Phase 4 holds the docs, which land after Phase 3 as one doc commit in each repo (... =docs/workflows/system-health-check.org= in archsetup)".
+- Spec L2313: the health-check edit (step 6 runs upgrade-guarded; kernel/DKMS/GPU days go through --complete; never hand-stamp) sits under Phase 4.
+- Spec L2095: --complete with the unit not enabled prints 'apply from a console with Hyprland stopped' and writes no flag. That path works after Phase 1 alone ("covers the window between Phases 1 and 3").
+- Spec L2133-2134: --complete stamps through ~/.local/bin/maint stamp topgrade. dotfiles maint/src/maint/cli.py:292 already has the stamp subparser, so stamping needs no Phase 2 code.
+- Spec L2446: bare topgrade, yay and pacman -Syu still land the kernel and DKMS sets ungated. The hold covers "the system-health-check workflow's update step", which describes the end state.
+- Spec L2526: the VM boot checks gate only "the unit reaches velox". They don't gate the Phase 3 commit.
+- docs/workflows/system-health-check.org:236, step 6: "Run =topgrade= ... If the run happened outside the wrapper somehow, =maint stamp topgrade= records it by hand." Also :1067, the KIL entry with the hand stamp after the containers failure.
+- .ai/project-workflows/system-health-check.org symlinks to that file, and ~/projects/home's copy is only a pointer to it. So this file is the canonical workflow.
+- todo.org:763-765 (2026-09-17): "no plain topgrade on velox until the hold is built". todo.org:778-781: "The 2026-10-04 velox health check ran a plain topgrade that moved the kernel 6.18.51 → 6.18.55, against the note above."
+:END:
+
+** DONE Repointing the sysupgrade alias in common/ breaks it on dwm and none installs, which never get upgrade-guarded
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Phase 2 'Alias and README' L2247; Rollout L2315 ('A rebuilt machine gets every piece from the installer')
+
+Phase 2 points the sysupgrade alias in common/.zshrc.d/aliases.sh and common/.bashrc.d/aliases.sh at upgrade-guarded.
+
+Risk: On a dwm or none rebuild, sysupgrade becomes 'command not found'. That contradicts 'a rebuilt machine gets every piece from the installer'.
+
+Recommended change: Replace the spec L2247 bullet with:
+
+"- The =sysupgrade= alias in =common/.zshrc.d/aliases.sh= and =common/.bashrc.d/aliases.sh= (today plain =topgrade=) becomes conditional, because common/ is also stowed on dwm installs, where =upgrade-guarded= and its hook are never installed: 'if command -v upgrade-guarded >/dev/null 2>&1; then alias sysupgrade=upgrade-guarded; else alias sysupgrade=topgrade; fi'. minimal/'s alias (DESKTOP_ENV=none) stays plain =topgrade=."
+
+Add a matching Phase 2 test bullet, for example a source check that both common alias files carry the conditional and that the minimal ones are unchanged.
+
+Optionally, reword Rollout L2315 to "A rebuilt Hyprland machine gets every piece from the installer."
+
+Should-fix, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: accepted, folded into Implementation phases, Testing.
+:EVIDENCE:
+- Spec L2246-2247: "The =sysupgrade= alias in =common/.zshrc.d/aliases.sh= and =common/.bashrc.d/aliases.sh=, today an alias for plain =topgrade=, points at =upgrade-guarded=."
+- Spec L2025: "both installed to =/usr/local/bin= by the installer step that installs =hypr-live-update-guard= (inside =hyprland()=)". Spec L80 and L220 say the same.
+- Spec L2037: a missing hook file makes every mode except --apply-armed refuse with exit 3.
+- archsetup:1497-1503: the 'dwm|hyprland)' branch stows common, then "$desktop_env".
+- archsetup:1518-1521: the 'none)' branch stows only minimal.
+- archsetup:2678-2690: window_manager runs dwm for dwm, hyprland for hyprland, and skips for none.
+- archsetup:2614-2653: the guard binary and the 10- hook are installed inside hyprland().
+- archsetup:189-190: DESKTOP_ENV is validated as dwm, hyprland or none.
+- archsetup:3429: 'aur_install topgrade' is in supplemental_software, which runs for every environment.
+- ~/.dotfiles/common/.zshrc.d/aliases.sh:42 and common/.bashrc.d/aliases.sh:42: alias sysupgrade="topgrade".
+- ~/.dotfiles/minimal/.zshrc.d/aliases.sh:42 and minimal/.bashrc.d/aliases.sh:42: alias sysupgrade="topgrade". The spec doesn't touch these, which refutes the none claim.
+- The hyprland stow package has no .zshrc.d or .bashrc.d (ls -a: .config .gnupg .local .profile.d). common/.zshrc:176-177 and common/.bashrc:57-58 glob-source *.sh from those directories.
+- dwm installs are exercised in tests/installer-steps/test_orchestrators.py and scripts/testing/tests/test_desktop.py.
+:END:
+
+** DONE Phase 2 keeps the guard arm-line wording in three code paths that its own tag removal makes unreachable
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Phase 2 Guard UX L2219-2220; Phase 2 tests L2252, L2262; Testing L2467
+
+L2219 removes guard: "live_update" from the update and topgrade remedies, along with the iter_fix refusal, the --force sentinel wrap and _update_force. L2220 then requires arm_line, _rearm_after_guard's text and the doctor review suffix to read 'UPDATE armed — will defer at least <guard matches> (kernels and DKMS modules are always held) — press again to run upgrade-guarded' on a tripped read.
+
+Risk: Implemented as written, Decision 3's display badge never renders, which leaves the rewritten TOML mirror and its pin test guarding a display that nothing shows. The alternative is that the implementer invents a new gating key. Either way, at least two of the three reworded sites are dead code whose text tests still pin.
+
+Recommended change: Replace L2220 with the following:
+
+"=guard.trips= survives only as the arm-line annotation that Decision 3 calls the display-side mirror. The arm press for UPDATE and TOPGRADE (strip keys and the roster FIX key alike) is gated on rid in ('update', 'topgrade'), not on the dropped tag. =panel.guarded= is deleted, or rekeyed to that rid set. On a tripped read, that arm line reads 'UPDATE armed — will defer at least <guard matches> (kernels and DKMS modules are always held) — press again to run upgrade-guarded'. Nothing says live apply or REBOOT required. With the refusal gone, doctor emits no 'guard' event, so =_rearm_after_guard=, the 'guard' branch of =_on_fired=, the 'guard' event handling at cli.py:114 and panel.py:368, and the doctor review suffix (doctor.py:415) are deleted with it. maint computes no kernel set of its own; the post-run row built from the record gives the exact set."
+
+Make two matching test edits:
+- In the Phase 2 test list (L2262) and in Testing (L2467), replace the arm-line bullet with: "the arm press for UPDATE and for TOPGRADE, on a tripped =guard.trips= read with no guard tag on either remedy, shows 'UPDATE armed — will defer at least <guard matches> (kernels and DKMS modules are always held) — press again to run upgrade-guarded'."
+- Add to the rewritten-tests bullet: "test_panel_levers.py:397-406 and test_panel_phase10.py:417-422 rewritten to the rid-gated annotation."
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: modified, folded into Implementation phases, Testing. I adopted the rid gating and the deletions, the review suffix included. panel.guarded is rekeyed rather than deleted, so the arm press stays testable without GTK. The label is generic rather than a fixed 'UPDATE', because TOPGRADE shares the path (R07, R19).
+:EVIDENCE:
+The arm-line gate:
+- gui.py:1430-1433 reads "if panel.guarded(rid): self._act(self._guard_arm_line(title, argv, th)) else: self._act(viewmodel.arm_line(title, argv))".
+- panel.py:228 reads "return remedies.REMEDIES.get(rid, {}).get("guard") == "live_update"".
+- remedies.py:298 and 309 are the only carriers of the tag.
+
+The guard event and its consumers:
+- doctor.py:216, "if not dry_run and r.get("guard") == "live_update" and not force:", is the only place that yields _ev("guard", ...) (doctor.py:235).
+- gui.py:1496-1498, guard_ev -> _rearm_after_guard.
+- gui.py:1525, "self._update_force = True".
+- cli.py:114 and panel.py:368 also consume kind == "guard".
+
+The review suffix:
+- doctor.py:399 iterates remedies.REMEDIES.
+- doctor.py:415, "if r.get("guard") == "live_update":", appends the suffix.
+
+The spec text:
+- L2219: "Drop guard: "live_update" from the update and topgrade remedies, so iter_fix no longer refuses them ... the GUI's _update_force override".
+- L2220: "On a tripped read, arm_line, _rearm_after_guard's text and the doctor review suffix read 'UPDATE armed — will defer at least ...'".
+- No other spec line defines a new gate. The grep hits are only L2219, L2220, L2262, L2467 and the history at L1075-1094.
+
+Tests pinning the old behavior:
+- tests/maint/test_panel_levers.py:397-406 pins "press again to apply live (REBOOT required after)".
+- tests/maint/test_panel_phase10.py:417-422 pins panel.guarded("update") and panel.guarded("topgrade") as True.
+:END:
+
+** DONE The script contract is restated two to four times, and the copies have already drifted
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Design L68-170 vs Phase 1 L2024-2208; Decisions 4, 6 and 7 L227-257; Readiness L2371-2434; Testing L2451-2526
+
+Each of these appears in full in two to four places:
+- the everyday sequence: L82-92 and L2056-2078
+- --complete: L128-133 and L2080-2096
+- --apply-armed: L156-166, L2098-2107 and L249
+- the record schema: L102-111, L227, L2118-2129 and L2372
+- the stamp predicate: L113-118, L228-232 and L2135-2141
+- the arm flag: L142-147, L256, L2109-2116 and L2373
+- the rollout checklist: L2315-2328, L2404-2417 and L2524
+- the test lists: L2163-2208, L2250-2262 and L2292-2307, again as L2460-2510
+
+Risk: Every later edit has to land in every copy. An implementer or test author who reads the wrong copy builds the wrong test or skips the Targets check. The wrong gate test would reject exactly the snapshot window that the 05-zfs-snapshot hook's 60 s skip produces (L2444).
+
+Recommended change: Smallest edit, in three parts:
+
+1. Change L2193's snapshot clause to: "snapshots created before since − 60 s fail, naming the dataset; one created at or after since − 60 s passes (including one in [T0 − 60 s, T0), the window the hook's 60 s skip leaves)". That matches L2155 and L2481.
+
+2. Bring L2408 in line with L2319 by replacing it with: "write =/etc/pacman.d/hooks/10-hypr-live-update-guard.hook= from the installer heredoc, confirm its Targets match the heredoc's, and remove the unprefixed name (confirm with =ls /etc/pacman.d/hooks/=; ratio still has the legacy name, and velox's was hand-placed under it)". Better still, replace L2405-2417 with "(a) to (c) as Phase 4's Rollout checklist" so only one checklist remains.
+
+3. Add one sentence to the Implementation phases intro (L2022): "Where Design, Decisions, Readiness or Testing restate a phase bullet, the phase bullet is normative." That way a later disagreement resolves one way.
+
+The broader consolidation (Design down to set definitions and the 'For the user' narrative, Decisions down to choice plus a pointer, Readiness and Testing citing phase bullets by AC) is an optional simplicity follow-up, not needed to close this finding.
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: accepted, folded into Design, Decisions, Implementation phases, Acceptance criteria, Readiness dimensions, Risks, Testing.
+:EVIDENCE:
+- Spec L2193: "only pre-T0 snapshots fail; one created after T0 − 60 s passes".
+- Spec L2155: "a <rootds>@pre-pacman_* snapshot with creation ≥ since − 60 s".
+- Spec L140: "created no earlier than 60 s before since".
+- Spec L2481: "only snapshots created before since − 60 s fails ... and one created after it passes".
+- Spec L2444: the hook skips within 60 s, "which is why the gate requires one no more than 60 s older than the run's start".
+- scripts/zfs-pre-snapshot:13 sets MIN_INTERVAL="${ZFS_PRE_MIN_INTERVAL:-60}", and lines 19-24 exit 0 without a snapshot when the lockfile is younger than 60 s. So the newest pre-pacman snapshot can predate T0 by up to 60 s.
+- Spec L2319: "write ... from the installer heredoc, confirm its Targets match the heredoc's, remove the unprefixed ...".
+- Spec L2524: "the hook migrated to the 10- name with its Targets matching the installer heredoc's".
+- Spec L2408: "migrate the guard hook to 10-hypr-live-update-guard.hook and remove the unprefixed name". There is no Targets check.
+- ls /etc/pacman.d/hooks/ shows 99-grub-sync-efi.hook and hypr-live-update-guard.hook (legacy name on ratio).
+- Diffing the Target lines of ratio's live hook against the archsetup heredoc (archsetup:2625-2650): 15 vs 15, identical. Velox wasn't checked (offline).
+- The duplicated ranges were re-read and match the finding's citations.
+- No precedence statement among the copies anywhere in the spec (Implementation phases intro, L2022).
+:END:
+
+** DONE kernel-modules-check's no-argument 'standalone diagnostic' mode has no caller and no test, and needs foreign-kernel logic of its own
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Design L140 (final sentences), L108; Phase 1 L2156, L2127
+
+With no kver given, the gate checks every installed kernel whose vmlinuz owner isn't foreign, without the since requirements ('the standalone TTY diagnostic'). L108 and L2127 add that a passing standalone run never clears the record's gate.
+
+Risk: An extra untested branch and package-ownership query inside a safety script. A regression there would only show when someone runs it by hand during a recovery.
+
+Recommended change: 1. Make at least one kver mandatory. In the gate paragraph at L140 and the gate list at L2156, replace the no-kver sentence with: "At least one =<kver>= is required; with none it exits 2 (usage). For manual diagnosis, run =kernel-modules-check <kver>=, using the kver the CRIT row names."
+2. Spell out the empty-list case. At L140/L2149 and step 4 (L2087), say: "=upgrade-guarded= skips the invocation when a check list is empty, so an empty list means no gate call, snapshot check included."
+3. Move the test "an empty check list with no fresh snapshot passes" (L2193, L2482) out of the =kernel-modules-check= tests and into the =--complete= tests, reworded as: "a =--complete= whose check list is empty never invokes =kernel-modules-check= and passes with no fresh snapshot."
+4. Add one gate test: "no kver exits 2."
+5. Simplify the record wording. At L108, L2127 and AC4 (L2347), replace "a passing standalone =kernel-modules-check= never clears it" with "only a =--complete= re-check clears it."
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: modified, folded into Implementation phases, Acceptance criteria, Testing. I cut the no-argument diagnostic and made at least one argument mandatory, as recommended, with two changes. The arguments are pkgbases, not kvers, because the entry is keyed by pkgbase (R03). The gate then owns every failure item, 'no module tree' included, and stays pacman-free. And --since stays optional, because it now has a caller: the structural check after a stage 1 that changed nothing (R03). The supported manual re-check is upgrade-guarded --complete.
+:EVIDENCE:
+Spec, docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org:
+- L135: =kernel-modules-check [--since <epoch>] [<kver>...]= … "Only =--complete= runs it, after stage 1".
+- L140: "An empty list skips the gate, snapshot check included. Kernels outside the list are never checked" … "With no kver given it checks every installed kernel whose vmlinuz owner isn't foreign, without the since requirements; that is the standalone TTY diagnostic. Its test seams are =KMC_MODULES_DIR= and =KMC_BOOT_DIR=, with dkms, zfs, findmnt and lsinitcpio faked on PATH." No pacman fake is listed.
+- L2087: "Gate: whatever stage 1's exit code, build the check list and run =kernel-modules-check= (below)." There is no skip-when-empty instruction.
+- L2149 and L2156 repeat L140's two sentences.
+- The test lists (L2193 and L2482, the latter under "Phase 1, =kernel-modules-check= on fake…") include "an empty check list with no fresh snapshot passes". Every other gate test passes kvers.
+- L108, L2127 and L2347 exist only to say a passing standalone run doesn't clear the record.
+- Phase 4 docs and recovery (L2309-2330, L2398) and the recovery drill (L2521) never use the no-kver form.
+
+Live system (ratio):
+- =uname -n= prints ratio, and =findmnt -no FSTYPE /= prints btrfs.
+- =pacman -Qm= lists linux-lts-strix 6.18.25-1.
+- =dkms status -k 6.18.25-1-lts-strix= prints "zfs/2.4.4: added".
+- 6.18.54-1-lts and 7.2.7-arch1-1 both print "installed".
+- The foreign-owner filter is therefore the only thing that keeps the standalone mode from failing on ratio.
+:END:
+
+** DONE upgrade_news_dismissed is a new state key that is specified only by its reader
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Phase 2 L2225; Phase 2 tests L2254; Readiness L2375; Risks L2448; Testing L2500
+
+The deferred row's DISMISS key writes the maint key upgrade_news_dismissed, and the probe hides any news title found in it.
+
+Risk: The implementer has to invent the write semantics. An append-only key would grow for the life of the machine. And the one user action in the news feature goes untested.
+
+Recommended change: Replace the Phase 2 Evidence bullet clause at L2225 ("which the row's DISMISS key writes") with this text:
+
+"which the row's DISMISS key writes: one press, shown only while undismissed titles exist, overwrites the maint cache key upgrade_news_dismissed (cache.put, beside the record) with the news titles the row is showing, so the key never holds more than the record's 10."
+
+Make the matching edit to Readiness L2375: "upgrade_news_dismissed (maint cache key, cache.put envelope), overwritten by the deferred row's DISMISS key with the row's shown titles; never appended, so it holds at most 10."
+
+Add one Phase 2 test after L2254: "pressing DISMISS on a row showing news writes exactly the shown titles to upgrade_news_dismissed, replacing any prior value, and the re-probed row shows no titles and no DISMISS key."
+
+If one press is the wrong friction for hiding a manual-intervention notice, say arm-then-fire there instead, as MARK KNOWN does. Either way, state the choice in the spec.
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: modified, folded into Implementation phases, Readiness dimensions, Testing. I adopted the bounded key and the write-side test, with two changes. First, DISMISS overwrites the key with the record's whole news list rather than the titles the row is showing. Overwriting with only the shown lines brings earlier dismissals back once a new line arrives: dismiss A and B, C arrives, DISMISS stores only C, and A and B return. The record already caps news at 10, so the key stays bounded. Second, DISMISS is arm-then-fire, which the finding offers as the alternative. Hiding a manual-intervention notice is silencing a signal, and MARK KNOWN, the panel's other signal-silencing key, is arm-then-fire for exactly that reason (gui.py:1687).
+:EVIDENCE:
+Spec mentions of upgrade_news_dismissed and DISMISS (all reader-side):
+- L2225: "the news titles minus those in maint key =upgrade_news_dismissed=, which the row's DISMISS key writes"
+- L2375: "=upgrade_news_dismissed= (maint key), written by the deferred row's DISMISS key."
+- L2254: the test only "leaves dismissed news titles off the row"
+- L2500: the row "lists the record's news minus the titles in =upgrade_news_dismissed="
+- L1742 (review disposition): "dismissal is a maint-owned key". No shape, store or write rule is given anywhere.
+
+Record news cap: L109 and L2128 say news is "merged with the previous record's, deduplicated, keeping the newest 10." The dismissed key has no cap.
+
+Store naming: the spec calls the record a "maint cache key" (L106, L2372) and topgrade_run a "maint-owned cache key" (L2374). The dismissed key is only called a "maint key" (L2375).
+
+maint code:
+- cache.py:24-32: put() writes ~/.local/state/maint/<name>.json in the {written_at, data} envelope.
+- thresholds.py:36-38: the curation store is ~/.config/maint/curation.toml.
+- curation.py:164-190: mark() appends to a table's add list, dedups and never trims.
+- gui.py:1653-1663 (KEEP) and gui.py:1686-1700 (MARK KNOWN) are the existing row-level persisted-decision keys. MARK KNOWN is arm-then-fire, per its docstring "silencing signal deserves the same two-press deliberateness as a remedy".
+- grep -i dismiss in maint/src/maint finds only panel.py:247, panel.py:390 and gui.py:603/616, none of which is row-level.
+
+Phase 2 test list (L2250-2262): APPLY gets "APPLY's argv and the detach", but no item presses DISMISS.
+:END:
+
+** DONE F19: the Phase 1 snapshot test bullet contradicts the gate's 60 s tolerance
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Phase 1 Gate tests L2193 vs gate rule L2155/L140 and Testing L2481
+
+L2193: 'only pre-T0 snapshots fail; one created after T0 − 60 s passes'. The gate rule (L2155) passes any snapshot with creation ≥ since − 60 s. The Testing map (L2481) says 'only snapshots created before since − 60 s fails'.
+
+Risk: A test written from L2193 with a fixture at T0 − 30 s would assert a failure the gate must not produce. Either the test or the gate ends up wrong.
+
+Recommended change: At L2193, replace "only pre-T0 snapshots fail; one created after T0 − 60 s passes" with "only snapshots created before T0 − 60 s fail, naming the dataset; one created at or after T0 − 60 s passes, including one taken up to 60 s before T0". Optionally, at L2481, fix the agreement ("fails" → "fail") and change "one created after it passes" to "one created at or after it passes", so the boundary matches the ≥ in L2155.
+
+Optional, not blocking. Verification: confirmed.
+Disposition: accepted, folded into Implementation phases, Testing.
+:EVIDENCE:
+Spec docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org:
+- L140 (Design): "requires a <rootds>@pre-pacman_* snapshot created no earlier than 60 s before since"
+- L2155 (Phase 1 gate rule): "a <rootds>@pre-pacman_* snapshot with creation ≥ since − 60 s"
+- L2193 (Phase 1 Gate tests): "only pre-T0 snapshots fail; one created after T0 − 60 s passes"
+- L2481 (Testing, AC4): "only snapshots created before since − 60 s fails, naming the dataset it looked for, and one created after it passes"
+- L964 (Review findings, the origin of the L2193 wording): "snapshot listings where only pre-T0 snapshots exist (gate fails) and one is newer than T0 (gate passes)"
+- L2444: "(skipped within 60 s of the previous snapshot ...) which is why the gate requires one no more than 60 s older than the run's start"
+Code: scripts/zfs-pre-snapshot:13 has MIN_INTERVAL="${ZFS_PRE_MIN_INTERVAL:-60}", and lines 16-24 exit 0 without snapshotting when now - last < MIN_INTERVAL.
+Counterexample: a snapshot created at T0 − 30 s is pre-T0, so L2193's first clause makes it fail. It is also after T0 − 60 s, so L2193's second clause, L2155 and L2481 make it pass.
+:END:
+
+** DONE F23: the shared record fixture became two unsynchronized copies with no canonical source
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Decision 4 Consequences L233; Phase 1 tests L2191; Phase 2 tests L2254; Testing L2464
+
+The resolution said Phase 2's probe test reads the same fixture as Phase 1. The spec has 'both test suites read copies of one fixture' (L233) and 'Phase 2's probe test reads a copy of it' (L2191, L2254, L2464). It names neither the canonical file nor how the copies stay in step.
+
+Risk: A later change to the record format, such as a new field or a renamed kind, passes both suites while the panel misreads the live record. That is the silent cross-repo mismatch F23 was meant to close.
+
+Recommended change: Replace L2191's bullet with: "the record round-trips through the canonical fixture tests/upgrade-guarded/fixtures/upgrade_deferred.json in cache.put's envelope. Phase 2's probe test reads a verbatim copy at dotfiles tests/maint/fixtures/upgrade_deferred.json. That test's module docstring names the archsetup path as its source, and any change to the record schema updates both files in the same rollout." Then make L2254 and L2464 refer to "the copy of Phase 1's canonical fixture (see Phase 1 tests)". Don't add a cross-repo comparison test or a provenance key inside the JSON.
+
+Optional, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: modified, folded into Implementation phases, Decisions, Testing. Merged into R56, which names both paths and binds the canonical file to the script's real output. The copy's test docstring names its source, as R40 asks. There is no cross-repo diff check and no provenance key in the JSON, because each copy is already bound to its own side's tests.
+:EVIDENCE:
+Spec:
+- L233: "both test suites read copies of one fixture in maint's envelope"
+- L2191: "the record round-trips through a shared fixture JSON in cache.put's envelope; Phase 2's probe test reads a copy of it."
+- L2254: "the probe reads a copy of Phase 1's fixture record" (says which side is canonical)
+- L2464: "Phase 2's probe test reads a copy of the same fixture"
+- L2163 and L2401: Phase 1 tests live in archsetup tests/upgrade-guarded/
+- L2119-2120 and L2372: full record schema (path, envelope, data fields)
+- L2022: rollout order is archsetup Phase 1, then dotfiles Phase 2, then archsetup Phase 3
+
+The spec gives no fixture filename and no rule for keeping the copies in sync. Grepping the spec for 'copy|copies|re-copy|in step|drift' finds nothing about the fixture beyond the lines above.
+
+Prior review note, L1063: "No dotfiles maint test references the archsetup checkout."
+
+Code:
+- ~/.dotfiles/maint/src/maint/cache.py:35-42 reads the envelope as entry["data"] and entry["written_at"]. The file is plain JSON with no comment syntax.
+- ~/.dotfiles/tests/maint/gen_fixtures.py:4-5 shows the existing way of citing an archsetup source: in a docstring.
+- dotfiles shipped fixtures live in maint/src/maint/fixtures/ (bad.json, good.json). There is no tests/maint/fixtures/ directory yet.
+:END:
+
+** DONE F31: removing the unit while a flag exists leaves it dangling, because the everyday rewrite has no is-enabled check
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Design everyday step 9 L91 and arm-flag paragraph L147; Phase 1 everyday step 9 L2073-2077; Readiness Rollout L2422; Testing rollout L2525; Phase 2 row L2229 and REBOOT L2240
+
+Only --complete checks =systemctl is-enabled= (L132, L2094-2095). Everyday step 9 runs whenever the flag is present: it re-runs the arm procedure (resolve check, =sudo pacman -Sw=) and rewrites the flag, with no unit check. L2422 says 'Removing only the unit leaves --complete unable to arm, because it checks is-enabled, so nothing is left dangling.' L2525 says 'Disabling and removing the unit later falls back the same way.'
+
+Risk: If the unit is disabled or removed while armed, every everyday run keeps re-downloading and re-arming a flag nothing consumes. The panel then shows 'armed' and offers a REBOOT that applies nothing, which is F31's misleading-reboot outcome in the rollback state, and it lasts until someone runs --complete by hand. It isn't unsafe, but L2422's 'nothing is left dangling' is false in that state.
+
+Recommended change: Preferred (behavior fix):
+- Phase 1 step 9 (L2073): add a first case. "Unit not enabled (systemctl is-enabled --quiet archsetup-boot-upgrade.service fails; seam UPGRADE_GUARDED_UNIT_ENABLED): sudo rm -f the flag and print 'disarmed: boot unit not enabled'. No failed_step; the exit code is unaffected; the other cases are skipped."
+- Mirror it in one clause each:
+ - Design step 9 (L91).
+ - The arm-flag paragraph (L147).
+ - The writer contract (L2116): "an everyday run removes it when the unit isn't enabled".
+ - Decision "While armed" (L248).
+- Add a test bullet beside L2202 and L2493: "with the flag present and the unit reported not enabled, an everyday run removes the flag, prints the disarmed line and never runs -Sp/-Sw." With that in place, L2422 and L2525 are accurate as written.
+
+Docs-only alternative: reword L2422 to: "Removing only the unit while a flag exists leaves the flag, and everyday runs keep rewriting it, until rollback step 3 or a --complete run removes it, so remove the flag with the unit." Drop "Disabling and removing the unit later falls back the same way" from L2525, or add "after removing any flag" to it.
+
+Optional, not blocking. Verification: confirmed.
+Disposition: accepted, folded into Implementation phases, Testing.
+:EVIDENCE:
+- Spec L91 and L2073-2077: the step-9 cases are "no GPU-kind entries remain" (remove), "otherwise" (arm procedure and rewrite), closure refused, and resolve/-Sw failure. None of them checks the unit.
+- Spec L147: "While armed, every everyday run whose refresh succeeded rewrites or removes the flag". L2116 says "Everyday runs rewrite or remove it while armed", and L248 has the same wording. None of these mentions is-enabled.
+- Spec L132 and L2094-2095: only --complete gates on "systemctl is-enabled --quiet archsetup-boot-upgrade.service" (seam UPGRADE_GUARDED_UNIT_ENABLED), and it removes any stale flag when the unit is off.
+- Spec L2229: the deferred row reads "'armed — N apply at next boot' while the flag exists". L2240: "REBOOT ... is shown while the flag exists".
+- Spec L2419-2422: rollback runs in this order: (1) revert dotfiles, (2) disable and remove the unit, (3) remove the flag. Then: "Removing only the unit leaves --complete unable to arm, because it checks is-enabled, so nothing is left dangling."
+- Spec L2525: "Disabling and removing the unit later falls back the same way", which describes --complete only.
+- Spec L1519 (prior review): "With F31 in place, removing only the unit no longer leaves --complete arming a flag that nothing reads". That disposition covers new arming, not a flag that already exists.
+- Live (ratio): systemctl cat archsetup-boot-upgrade.service prints "No files found", so the unit doesn't exist yet. Nothing in the code contradicts the spec reading.
+:END:
+
+** DONE F25: a failed stamp has no defined path into the record, because every mode writes the record before stamping
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Stamp predicate L113 and L2136; everyday step 10 L92 and L2078; --complete step L133 and L2096; --apply-armed step L164 and L2107; Phase 1 test L2190 and L2505; Phase 2 row severity L2228
+
+Every mode's last step is 'Write the record, stamp per the stamp predicate, and exit' or 'Write the record and stamp', and the predicate itself reads the written record ('the record ends empty'). A missing maint or a failed stamp then 'prints stamp failed: <reason>, sets failed_step stamp and exits 1'. failed_step is a record field, and nothing says the record is rewritten after the stamp, or which result value goes with failed_step stamp.
+
+Risk: An implementer who follows the stated order has to decide whether to rewrite the record. If it isn't rewritten, the run exits 1 but the record says result ok with failed_step null. The deferred row then stays OK while freshness never clears, which is the panel half of the silent stamp failure F25 set out to close. The exit code, the stderr line and, at boot, the failed unit still show it.
+
+Recommended change: In the stamp predicate's first bullet at L2136, and in the matching sentence at L113, replace "sets =failed_step= stamp and exits 1" with "rewrites the record with result failed, =failed_step= stamp and that line as detail (packages, gate and news unchanged), and exits 1". In the L2190 test bullet and in AC8 at L2505, add "and the record reads result failed, failed_step stamp". No change to the sequence steps is needed.
+
+Optional, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: modified, folded into Implementation phases, Testing. Rather than a second record write after a failed stamp, every mode's finish computes the record, stamps when the predicate holds, folds a stamp failure into the computed record, and writes once. The outcome the finding asks for is unchanged with one write, and the predicate reads the computed record rather than the file.
+:EVIDENCE:
+- Spec L113 and L2136: "A missing maint, or a failed stamp, prints 'stamp failed: <reason>', sets =failed_step= stamp and exits 1."
+- Spec L106 and L2125: failed_step is a record data field, and its enum includes stamp.
+- Spec L2378 (Readiness dimensions, Errors): "Exit codes: 0 ok, 1 a step failed, ... On any non-zero exit the last stderr line names the reason, and the record's =result=, =failed_step= and =detail= carry it to the panel."
+- Spec L2380 repeats the stamp-failure rule: it sets failed_step stamp and exits 1.
+- Spec L2078 "10. Write the record, stamp per the predicate below, and exit." L2096 "6. Write the record and stamp." L2107 "rewrite the record ... Stamp per the predicate below and exit". These sequence lines say nothing about rewriting the record after a stamp failure.
+- Spec L2072 and L2081 show the same implicit convention for other steps: "On failure: =failed_step= refresh, exit 1", with no result value given. AC10 at L2507 expects failed_step aur to grade WARN, which relies on result failed being implied. The WARN rule at L2228 grades on result failed, refused or interrupted.
+- Spec L2190 and L2505: the stamp test asserts failed_step stamp but not result.
+- ~/.dotfiles/maint/src/maint/cli.py:228 and 292-295: maint stamp topgrade exists and can fail like any other subprocess.
+:END:
+
+** DONE F33: 'Removing only the unit ... nothing is left dangling' is false while a flag is armed
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Readiness rollback L2422; everyday step 9 L91 and L2073-2077; arm-flag rules L147; REBOOT rule L2240; armed row text L2229
+
+L2422 says: "Removing only the unit leaves --complete unable to arm, because it checks is-enabled, so nothing is left dangling."
+
+Risk: If the unit is removed while armed (a partial rollback, or between steps 2 and 3 of the full one), the panel keeps advertising 'armed — N apply at next boot' and offering REBOOT. Every everyday run keeps re-downloading the set, and a reboot applies nothing. That is the misleading-REBOOT state F34 closed. Nothing is unsafe, and a later --complete or an empty GPU set removes the flag.
+
+Recommended change: Smallest edit is to reword L2422's first sentence to: "Removing only the unit leaves --complete unable to arm, because it checks is-enabled. Remove any present arm flag at the same time: everyday runs keep an existing flag in step without checking the unit, so the panel would keep showing 'armed — N apply at next boot' and REBOOT with no unit left to consume it." An alternative that also covers a unit disabled by any other means is to add a first bullet to everyday step 9 (L2073, mirrored at L91): "the unit not enabled (systemctl is-enabled, seam UPGRADE_GUARDED_UNIT_ENABLED): sudo rm -f the flag and print 'disarmed: boot unit not enabled'". It would come with a matching Phase 1 test. Then the L2422 sentence would be true as written.
+
+Optional, not blocking. Verification: confirmed.
+Disposition: modified, folded into Implementation phases, Readiness dimensions. Resolved by R41's behaviour fix, which is the finding's own alternative. It makes the existing rollback sentence true, so no reword is needed.
+:EVIDENCE:
+- Spec L2422: "Removing only the unit leaves =--complete= unable to arm, because it checks =is-enabled=, so nothing is left dangling."
+- Spec L2073-2077 (Phase 1 everyday step 9): "While armed (flag present) and step 4 succeeded: no GPU-kind entries remain: sudo rm -f the flag; otherwise: run the arm procedure (below) on the current GPU-kind entries and rewrite the flag". There is no is-enabled condition. L91 has the same wording in Design.
+- Spec L2112-2115: the arm procedure is (a) a resolve check, (b) -Sw, (c) an atomic write. It has no unit check.
+- Spec L2116: "--complete's arm step writes the flag, only when the unit is enabled. Everyday runs rewrite or remove it while armed." The is-enabled gate is scoped to --complete only.
+- Spec L2229: "'armed — N apply at next boot' while the flag exists". L2240: REBOOT "is shown while the flag exists".
+- Spec L150: the unit's ConditionPathExists is the only consumer of the flag. Once the unit is removed or disabled, nothing reads it at boot.
+- Mitigating factors: L2418-2421 (the full rollback reverts the dotfiles first and removes the flag in step 3), and L2095 (--complete with the unit not enabled removes any stale flag).
+:END:
+
+** DONE F38: Keying the strix addendum on --complete misses linux-firmware-only days
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Phase 4 docs L2313 ("Step 5's ratio strix addendum keys on --complete instead of 'before topgrade'"); Design 'The sets' L71 and Phase 1 L2038 (linux-firmware* is not a kernel)
+
+The F38 resolution is present at L2313, including step 6 -> upgrade-guarded, the --complete days, the hand-stamp rewrite and the KIL note. Step 5's trigger is moved to --complete for every trigger.
+
+Risk: On a day when only linux-firmware is pending, the addendum keyed on --complete never runs before that package lands. The watch then misses its most likely signal until the next kernel day. The addendum is research rather than a gate, so nothing is unsafe.
+
+Recommended change: Spec L2313: replace "Step 5's ratio strix addendum keys on =--complete= instead of \"before topgrade\"." with "Step 5's ratio strix addendum keeps its triggers and runs before whichever =upgrade-guarded= invocation lands each one: before =--complete= for linux, linux-lts or a held mesa, and before step 6's everyday run for linux-firmware, which is never held."
+
+Optional, not blocking. Verification: confirmed.
+Disposition: accepted, folded into Implementation phases.
+:EVIDENCE:
+- Spec L2313 (Phase 4 docs): "Step 5's ratio strix addendum keys on =--complete= instead of \"before topgrade\"." It has no per-trigger split.
+- Spec L71 and L2038: "=linux-firmware*=, =linux-api-headers= and other =linux*= names are not kernels."
+- Spec L2167 and L2463: tests assert "firmware and api-headers are never held".
+- Spec L1708 (prior review evidence) says step 5 runs "before topgrade" when linux or linux-lts is pending. It omits linux-firmware and mesa.
+- docs/workflows/system-health-check.org:235: step 5 fires "if =linux=, =linux-lts=, =linux-firmware=, or a major =mesa= bump is pending ... before topgrade".
+- docs/workflows/strix-soak-watch.org:39 says linux-firmware is "the most likely vector for the real fix". At :68-72 the watch reads the SMC version "BEFORE topgrade" and examines the delta "After topgrade installs the new linux-firmware".
+- /etc/pacman.d/hooks/hypr-live-update-guard.hook and archsetup:2629-2643: the Targets are mesa, mesa-*, wayland, libdrm, libglvnd, the hypr* packages, vulkan-*, nvidia-utils, lib32-nvidia-utils and xorg-xwayland. No linux-firmware.
+- pacman -Qi linux / linux-lts: Depends On is coreutils, initramfs and kmod. linux-firmware is required only by mkinitcpio-firmware, so the closure never adds it.
+:END:
+
+** DONE The failed_step vocabulary has no value for several refusals that still write the record
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Design record L106; Phase 1 record L2125; everyday precondition L2060 (Design L83); --apply-armed step 1 L2099 (Design L157); Readiness L2379
+
+failed_step is 'null, hook, refresh, closure, pacman, aur, topgrade, gate, download, boot-transaction or stamp'. Only the EUID 0 and lock-held refusals skip the record write (L102, L2120). Every other refusal writes result refused.
+
+Risk: Implementers will choose null, invent a value, or reuse boot-transaction differently. The panel and the shared fixture record that both repos read (Decision 4 L233) can then disagree on the vocabulary.
+
+Recommended change: L106 and L2125: append "precondition" to the failed_step list. Then add one sentence after the list in each place: "A precondition refusal that writes the record (db.lck present; for --apply-armed, a missing, empty or unparseable list, a live Hyprland or db.lck) sets failed_step precondition. --complete's refusal for a kernel-set or DKMS-set member in the GPU closure sets failed_step closure. An interruption during news capture or informant records failed_step precondition."
+
+Testing, Phase 1 (L2494 and the preconditions line L2510): add "db.lck present exits 3 with result refused and failed_step precondition, and the file is not deleted". Change "each exit 3" for the --apply-armed list and Hyprland cases to "each exit 3 with result refused and failed_step precondition". The held-lock case keeps its existing "record unchanged" assertion.
+
+A smaller alternative: one sentence saying "any refusal not assigned a step above records failed_step null, with the reason in detail".
+
+Optional, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: modified, folded into Implementation phases, Acceptance criteria, Testing. I adopted the precondition value and folded the existing 'hook' value into it. A missing or Target-less hook is a precondition refusal like db.lck, and the detail names the path. Nothing in either repo keys on 'hook', so the vocabulary shrinks instead of growing.
+:EVIDENCE:
+Spec docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org:
+- L106 and L2125: "failed_step: null, hook, refresh, closure, pacman, aur, topgrade, gate, download, boot-transaction or stamp."
+- L102 and L2120: the record is rewritten at the end of every mode "refusals, failures and interruptions included", with only the EUID 0 and lock-held refusals exempt.
+- L2060: db.lck refusal, exit 3 with detail. No failed_step. L2081: --complete reuses the everyday preconditions.
+- L2082: --complete refuses if the GPU closure holds a kernel-set or DKMS-set member. No failed_step (contrast L2051, where the closure-loop refusal is named closure).
+- L2099: --apply-armed exits 3 for a missing, empty or unparseable list, a live Hyprland, a held lock, or db.lck. No failed_step. L2100 pre-writes boot-transaction only after this step. L2105 names boot-transaction for the resolve-check refusal only.
+- L2132 and L2183: an interrupt records "the step". News (step 2) and informant (step 3) have no enum value.
+- L2494: the Phase 1 test for the --apply-armed preconditions asserts only "each exit 3", not the record. No automated test covers the db.lck refusal (db.lck appears only at L2354 and L2517).
+- Phase 2 severity, L2234-2237: WARN when result is failed, refused or interrupted. L2261 is the only Phase 2 test that keys on failed_step (aur).
+- No implementation exists yet: there is no scripts/upgrade-guarded in archsetup, and grep finds no failed_step in ~/.dotfiles/maint/src.
+:END:
+
+** DONE 'N' means three different counts across the deferred row, the armed text and the strip, and the armed count reads the flag's raw line count
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Design L122, L142; Phase 2 L2224, L2229, L2234, L2244; AC5 L2349
+
+Row value N is the record's packages minus installed entries (L2224). In the armed state, 'armed — N apply at next boot' uses 'N the flag's line count' (L2229). The strip reads 'N pending · M held' (Design L122, L2244), where N is the pending count and M is the deferred count. APPLY is 'shown while N > 0' (L2234). The flag parser ignores '#' lines (L142), but the probe 'reads only its presence and line count'.
+
+Risk: Small UI miscounts, and it is unclear which N gates APPLY.
+
+Recommended change: At L2229 (mirror at L124 and L2495), replace "with N the flag's line count" with "with A the number of flag entries (non-'#' lines) not yet installed at or above their version (=pacman -Q= plus =vercmp=, as for N); when the row's N exceeds A, append ' · <N−A> more deferred'". At L2223, L257 and L2373, replace "presence and line count" with "presence and entries". At L2234, write "shown while the row's value N > 0". Optionally, at L2244, define M as the row's value N, and say whether 'N pending' includes the held share.
+
+Optional, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: accepted, folded into Implementation phases, Acceptance criteria, Testing.
+:EVIDENCE:
+Spec L142 and L2111: the flag carries one <name>=<version> line per GPU-kind entry and '#' lines are ignored. L2116: "=upgrade-guarded= is the only writer". The arm procedure (L2112-2115) writes no comment lines.
+
+L2224: "Value N: the record's =packages= minus the entries now installed at or above =new=".
+L2229: "'armed — N apply at next boot' while the flag exists, with N the flag's line count".
+L2234: APPLY "shown while N > 0 or a gate failure is open".
+L2231: "A kernel sits in the deferred set most days".
+L91 and L2073-2075: an everyday run while armed rewrites the flag from "the current GPU-kind entries" only.
+L2438: a bare pacman -Sy or yay can refresh without re-arming.
+L2104: the boot form drops entries already at or above the recorded version, so the flag's line count can exceed what actually applies.
+L2244: "the strip's pending cell reads 'N pending · M held'. Both read the record."
+maint doctor.py:56 pending_updates() is the existing source of the strip's pending count, which is distinct from the record.
+:END:
+
+** DONE The wall note 'freshness clears on --complete' contradicts the rule that an arming --complete never stamps
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Design L122; Phase 2 L2239; AC9 L2362; Phase 2 test L2260 vs Decision 4 L229, Design L116
+
+After a TOPGRADE that left anything deferred, the wall reads 'N deferred — freshness clears on --complete'.
+
+Risk: The note promises a result the spec's own predicate withholds in the most common case.
+
+Recommended change: Change the note in all five places that state or assert it (L122, L2239, L2260, L2362, L2506) to one string that holds for both kinds of deferral: 'N deferred — freshness clears once they land (APPLY)'. If you'd rather keep the --complete wording for the common case, use two strings instead:
+- When the record holds no gpu-kind entry: 'N deferred — freshness clears on --complete'.
+- When it holds any gpu-kind entry: 'N deferred — freshness clears after APPLY and the next boot'.
+
+In that case, add one Phase 2 test line next to L2260 asserting the gpu-kind variant.
+
+Optional, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: accepted, folded into Implementation phases, Design, Acceptance criteria, Testing.
+:EVIDENCE:
+Spec docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org:
+- L116: "=--complete= stamps only when ... the record ends empty ... An arming run never stamps."
+- L117: "=--apply-armed= stamps when its transaction exited 0 or was a no-op, and the record ends empty."
+- L126: on the boot run, "The stamp is written when nothing is left deferred."
+- L132 (step 4): in the live case with the unit enabled, --complete runs the arm procedure. With the unit not enabled it removes any stale flag and writes none, so the record stays non-empty.
+- L229: "A run that arms the boot oneshot never stamps."
+- The note is attached to any deferral at L122 ("after a TOPGRADE that left anything deferred") and L2239 ("After a TOPGRADE press while anything is deferred").
+- L240: "the panel's deferred count carries a kernel most days". The common case is kernel-only, where --complete does stamp.
+- L2362 (AC9) and L2506 (AC9 test) pin the note only for the kernel-only case. L2260 asserts it for any non-empty record.
+- L2234: the row reads 'armed — N apply at next boot' while the flag exists, which corrects the user after an arm.
+:END:
+
+** DONE Flag handling is unspecified when --complete refreshes the db and then refuses or fails stage 1
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Design L129, L131, L147; Phase 1 L2082, L2089, L2116; Risks L2438
+
+--complete runs its own 'sudo pacman -Sy' (L2082). It removes the flag on a gate failure, an unenabled unit, nothing GPU-side left, or nothing to complete, and it re-arms when live and enabled. For a GPU-closure kernel refusal or a closure-loop refusal it says nothing about the flag, and for a stage-1 failure it says only 'nothing armed' (L2089).
+
+Risk: Low, because the boot form fails before any package changes when a version is gone, but the spec is internally inconsistent about who keeps the flag in step.
+
+Recommended change: Make --complete mirror everyday step 9 after a successful refresh:
+- Append to --complete step 2 (L2082, and its twin in the Design text at L129): "A refusal here removes any existing flag with sudo rm -f and prints 'disarmed: closure refused' before the refusal's own lines, as everyday step 9 does."
+- At L2089 and L131, change "nothing armed" to "removes any stale flag and arms nothing".
+- Extend the --complete removal list at L147 and L2101 to "on a refusal after its refresh, a failed stage 1, a gate failure, when the unit isn't enabled, or when nothing GPU-side is left".
+- Add a Phase 1 test bullet next to L2474/L2492: "with a flag present, a --complete closure refusal and a failed stage 1 each remove it."
+
+If the intent is to leave the flag in place, add "a refusing --complete" to L2438's list of refreshes that don't re-arm instead, and say "leaves any existing flag untouched" at L2082/L2089.
+
+Optional, not blocking. Verification: confirmed.
+Disposition: modified, folded into Implementation phases, Risks, Testing. Rather than adding refusal and stage-1-failure cases to --complete's list of flag removals, --complete removes any flag once, right after its refresh succeeds, and only its arm step writes a new one. That one rule covers a closure refusal, a failed stage 1, a failed gate, a unit that isn't enabled, nothing GPU-side, and nothing to complete. With R03 it also guarantees that no flag exists while the gate is open.
+:EVIDENCE:
+- Spec L91 and L2076: everyday step 9, "the closure refused: remove the flag and print 'disarmed: closure refused' before the refusal's own lines ... exits 3".
+- Spec L129 and L2082: --complete runs "sudo pacman -Sy", then "compute the pending set and the GPU closure, refusing if the closure holds a kernel-set or DKMS-set member". Nothing about the flag on refusal. Only the nothing-pending branch (L2083) says "remove any stale flag".
+- Spec L131 and L2089: "Stage 1 failed while the gate passed or had nothing to check: failed_step pacman, exit 1, nothing armed."
+- Spec L147: "While armed, every everyday run whose refresh succeeded rewrites or removes the flag (step 9 above), so the armed versions follow the db that run synced. --complete removes the flag on a gate failure, when the unit isn't enabled, or when nothing GPU-side is left ... Re-arming means running --complete again."
+- Spec L2101 repeats the same three-case list.
+- Spec L2438: "what breaks the agreement is a manual cache clean, or a db refresh that didn't re-arm (a bare pacman -Sy or yay)". --complete is not named.
+- Spec L2474: the --complete GPU-closure refusal is tested to "refuse the same way", with no flag assertion. The flag-on-refusal tests at L2202 and L2493 are everyday-only.
+- Why it is safe: L2111 (the boot form's resolve check refuses a kernel ride-in) and L2112 ("A version the db no longer carries fails as target not found ... both fail before any package changes").
+:END:
+
+** DONE Phase 2 removes the lever stamp and --force but leaves code and doc text that still describes them
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Phase 2 L2216, L2219, L2248; Documentation plan L2397
+
+Phase 2 rewrites only the PATH wrapper's header comment, tests/maint/test_topgrade_wrapper.py, and the 'The live-update guard' README section.
+
+Risk: Leftover text from the replaced design tells the user that a successful TOPGRADE stamps, and documents a flag that no longer exists.
+
+Recommended change: Append one bullet to Phase 2's Levers or Guard UX list (after L2219):
+
+"Also reword the leftover text from the replaced design:
+- the topgrade_freshness docstring and its 'no topgrade run recorded' error in probes/updates.py:115-127, so they name upgrade-guarded (current runs only) and the PATH wrapper (bare-shell runs) as the writers, with no doctor or TOPGRADE-lever stamp;
+- the doctor.py module docstring (L28-30) and guard.py's docstring (L5-7), so neither describes a refusal or --force;
+- the =maint fix= usage line at maint/README.md:23, dropping [--force]."
+
+In the Documentation plan's Phase 2 bullet (L2397), add: "and drops [--force] from the =maint fix= usage line at README.md:23."
+
+Optional, not blocking. Verification: confirmed.
+Disposition: modified, folded into Implementation phases, Readiness dimensions. Adopted, plus two items that went stale the same way. The topgrade PATH wrapper's header says the maint TOPGRADE lever resolves the wrapper and that the system-health-check workflow runs through it. test_topgrade_wrapper.py's docstring repeats the workflow claim. After the change, the lever runs upgrade-guarded, which calls /usr/bin/topgrade directly, and the workflow's update step runs upgrade-guarded.
+:EVIDENCE:
+- Spec L2216 names only "the PATH wrapper ~/.local/bin/topgrade and tests/maint/test_topgrade_wrapper.py" for rewording. L2248 and L2397 limit the README rewrite to "the 'The live-update guard' section".
+- ~/.dotfiles/maint/src/maint/probes/updates.py:116-118: "The topgrade PATH wrapper stamps the cache on any successful run ... and the TOPGRADE lever stamps via doctor"
+- updates.py:125-126: error="no topgrade run recorded — any successful run stamps it (PATH wrapper / TOPGRADE lever)"
+- ~/.dotfiles/maint/README.md:23: "maint fix <id> [items...] [--dry-run] [--force]". The guard section starts at README.md:52 ("### The live-update guard").
+- ~/.dotfiles/maint/src/maint/doctor.py:28-30: "the live-update guard refuses UPDATE / TOPGRADE when the pending set trips it (`=--force=` is the CLI's "press again")"
+- ~/.dotfiles/maint/src/maint/guard.py:5-7: "tells the UPDATE/TOPGRADE key to arm with "press again to run anyway — or apply from a TTY" (the CLI mirrors that with `=--force=`)"
+- ~/.dotfiles/maint/src/maint/doctor.py:169-170 is the stamp that L2216 deletes, so after Phase 2 "stamps via doctor" is false.
+:END:
+
+** DONE Rollback is labelled 'reverse order' but runs Phase 2, then 3, then 1
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Readiness, Rollout L2418-L2421
+
+'Roll back in reverse order: 1. Revert the dotfiles Phase 2 commit. 2. disable and remove the unit... 3. Remove the arm flag, then the scripts.'
+
+Risk: Cosmetic, but someone following the label instead of the list would disable the unit before reverting dotfiles.
+
+Recommended change: At L2418, replace "Roll back in reverse order:" with "Roll back in this order (dotfiles first, so nothing calls the scripts once they are removed; steps 1 and 2 are independent of each other):". Leave the numbered steps unchanged.
+
+Optional, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: accepted, folded into Implementation phases, Readiness dimensions.
+:EVIDENCE:
+Spec file: docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org
+- L2405-L2417: rollout order (a) after the Phase 1 commit (install the scripts), (b) only after (a), pull the Phase 2 dotfiles commit, (c) after the Phase 3 commit (write, daemon-reload and enable archsetup-boot-upgrade.service).
+- L2418-L2421: "Roll back in reverse order: 1. Revert the dotfiles Phase 2 commit ... 2. sudo systemctl disable archsetup-boot-upgrade.service, remove the unit ... 3. Remove the arm flag, then /usr/local/bin/upgrade-guarded and kernel-modules-check." That order is b, c, a, not c, b, a.
+- L2422: "Removing only the unit leaves --complete unable to arm, because it checks is-enabled, so nothing is left dangling. Removing the scripts before reverting dotfiles leaves UPDATE and TOPGRADE pointing at a missing binary." So disabling the unit before reverting dotfiles is explicitly safe, which refutes the finding's stated risk.
+- L2382: "With the boot unit not enabled, --complete prints the console remedy and writes no flag."
+- L1511-L1514 (prior finding's recommended text): "Roll back in reverse rollout order: 1. Revert the dotfiles Phase 2/3 commits first ..." The label dates from when Phase 3 was still grouped with dotfiles.
+:END:
+
+** DONE =flock -n <lockfile>= as written is a usage error and holds no lock
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Design L83 (everyday precondition); Phase 1 L2059; --apply-armed L157 and L2099 ('the lock is held'); test L2182
+
+Precondition: '=flock -n ${MAINT_STATE_DIR:-$HOME/.local/state/maint}/upgrade-guarded.lock= succeeds, else 'another upgrade-guarded run is in progress''.
+
+Risk: Copied literally, the precondition never succeeds, so every run refuses as 'in progress'. A working one-shot =flock file true= would release the lock immediately and guard nothing. The spec doesn't say the lock is held for the whole run, which is what makes the lock-held refusals and the 'record untouched' rule meaningful. The tests use the real flock, so the literal form would fail on the first test run, but the lifetime still has to be invented.
+
+Recommended change: Make the same edit at L83 and L2059, and have L2099 refer to it. Replace "=flock -n <path>/upgrade-guarded.lock= succeeds" with:
+
+"take an exclusive, non-blocking flock on =${MAINT_STATE_DIR:-$HOME/.local/state/maint}/upgrade-guarded.lock= (creating the directory and the file if missing) through a descriptor held open until the process exits, so the lock covers the whole run (in sh: =exec 9>>\"$lock\"; flock -n 9=; in Python: =fcntl.flock(fd, LOCK_EX|LOCK_NB)=); if the lock is unavailable, refuse with 'another upgrade-guarded run is in progress'".
+
+At L2376, add "held for the life of each non-dry-run invocation". Optionally, make the L2182 test take the lock with a real flock held by the test process. It should then assert both exit 3 and that a normal run with the lock free gets past this precondition.
+
+Optional, not blocking. Verification: confirmed.
+Disposition: accepted, folded into Implementation phases, Readiness dimensions, Testing.
+:EVIDENCE:
+Command run: bash -c 'flock -n <scratch>/t.lock; echo rc=$?' printed "flock: bad file descriptor: '<scratch>/t.lock'" and rc=64.
+
+flock --version reports util-linux 2.42.4. Its usage lines accept only "flock [options] <file>|<directory> <command> [<argument>...]", "flock [options] <file>|<directory> -c <command>" or "flock [options] <file descriptor number>".
+
+Spec lines:
+- L83 and L2059: "flock -n ${MAINT_STATE_DIR:-$HOME/.local/state/maint}/upgrade-guarded.lock succeeds, else 'another upgrade-guarded run is in progress'".
+- L2079: --complete reuses everyday steps 1 to 3.
+- L2099: --apply-armed refuses if "the lock is held".
+- L2182 and L2510: "a held upgrade-guarded.lock exits 3 and leaves the record unchanged".
+- L2376: "upgrade-guarded.lock, beside the record, is a flock file and holds no data".
+- L2430: external deps include "flock and pgrep".
+
+A grep of the spec for lock-lifetime wording (held for/until/through, lifetime, for the whole/life/duration, exec 9, file descriptor, fcntl) returns no hits. The spec states no implementation language. The sibling script scripts/hypr-live-update-guard is #!/bin/sh and the tests are Python unittest.
+:END:
+
+** DONE yay -Pwq returns date-prefixed lines, oldest first, not bare titles
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Design L84 and L109 (news: 'titles merged ... deduplicated, keeping the newest 10'); Phase 1 L2062 and L2128; Phase 2 DISMISS (L2225)
+
+The record's news field holds the 'unread Arch news titles' from =yay -Pwq=, merged with the previous record's, deduplicated, with the newest 10 kept.
+
+Risk: 'Newest 10' depends on knowing the order, and dedup and DISMISS matching depend on whether the date prefix is kept. Phase 1 writes the shared fixture and Phase 2 reads a copy, so the two repos can disagree on the entry shape.
+
+Recommended change: At L109 and L2128, replace the news bullet with: "=news=: this run's =yay -Pwq= lines, each kept verbatim as =YYYY-MM-DD <title>= (yay prints them oldest first and the date is part of the line), merged with the previous record's, deduplicated on the whole line, sorted by the date prefix, keeping the 10 latest. An empty output with exit 0 means no new news."
+
+At L2225 and L2500, add: "DISMISS stores the same verbatim line, and the row filters on whole-line equality."
+
+At L2499, change "cap at the newest 10" to "cap at the 10 latest by date prefix". Also make the fake yay emit date-prefixed lines, oldest first.
+
+Optional, not blocking. Verification: confirmed.
+Disposition: accepted, folded into Implementation phases, Testing.
+:EVIDENCE:
+Spec: L109 and L2128, "=news=: this run's =yay -Pwq= titles merged with the previous record's, deduplicated, keeping the newest 10." L2499: "titles merge with the previous record's, deduplicate, and cap at the newest 10." L2225: "the news titles minus those in maint key =upgrade_news_dismissed=". There is no other definition of a news entry anywhere in the spec.
+
+Live check on ratio. yay --version reports "yay v13.0.1 - libalpm v16.0.1". The command yay -Pwwq printed 10 lines, oldest first, from "2025-11-05 waydroid >= 1.5.4-3 update may require manual intervention" through "2026-09-22 Mkinitcpio >=42 requires manual intervention for TPM2-based unlocking of LUKS devices", with rc=0. The command yay -Pwq printed nothing, also with rc=0.
+
+The yay(8) man page reads "-q, --quiet Only show titles when printing news" and "News is considered new if it is newer than the build date of all native packages."
+
+maint has no news code yet: grep -rn news over ~/.dotfiles/maint/src/maint finds only a comment at gui.py:93.
+:END:
+
+** DONE --dry-run should own its checkupdates db rather than share the default path with maint
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Design L80 and Phase 1 L2036 (--dry-run: pending set from checkupdates, closure via =pacman -Sup --dbpath <checkupdates' db>=); test L2184
+
+--dry-run takes its pending set from =checkupdates= and runs its closure against 'checkupdates' db', without saying how the script learns that path.
+
+Risk: The script would have to re-derive checkupdates' default path from TMPDIR and UID. A maint probe that refreshes the shared db between the dry-run's checkupdates read and its -Sup probes can make the pending set and the probe db disagree, and the preview would then show a spurious 'X not in pending set' refusal.
+
+Recommended change: L80 and L2036: replace "runs its closure with =pacman -Sup --dbpath <checkupdates' db>=" with "creates a private directory (=mktemp -d=, removed on exit), exports =CHECKUPDATES_DB= to it, runs =checkupdates= against it, and passes that same path to every closure probe's =pacman -Sup --dbpath=; it never uses the shared default =${TMPDIR:-/tmp}/checkup-db-${UID}=". L2184 and L2475: change "its closure passes =--dbpath= with checkupdates' db" to "every closure probe passes =--dbpath= equal to the =CHECKUPDATES_DB= the fake checkupdates received, and that path is not the shared default".
+
+Optional, not blocking. Verification: confirmed.
+Disposition: accepted, folded into Implementation phases, Testing.
+:EVIDENCE:
+- Spec L80 and L2036: "runs its closure with pacman -Sup --dbpath <checkupdates' db>", with no mechanism for obtaining the path. L2184 and L2475 assert "--dbpath with checkupdates' db", with no defined value. L2048 refuses when X is not in the pending set.
+- /usr/bin/checkupdates:132-133: CHECKUPDATES_DB defaults to "${TMPDIR:-/tmp}/checkup-db-${UID}/" only when unset, so it is overridable from the environment.
+- /usr/bin/checkupdates:136: trap 'rm -f $CHECKUPDATES_DB/db.lck' INT TERM EXIT. Each run deletes the lock in the shared dir on exit.
+- /usr/bin/checkupdates:150: fakeroot pacman -Sy --dbpath "$CHECKUPDATES_DB". Line 181: exit 2 when nothing is pending.
+- maint calls bare checkupdates against the same default db at ~/.dotfiles/maint/src/maint/probes/updates.py:152 and ~/.dotfiles/maint/src/maint/doctor.py:83.
+- Live system: /tmp/checkup-db-1000 has mtime 09:09. maint-net-scan.timer last ran at 09:08:45 and runs hourly, which confirms the default db is shared and refreshed on a schedule.
+:END:
+
+** DONE The new post-rebuild-check assertion has no seam, so the Phase 1 commit's own test gate goes red on ratio until the rollout step that follows it
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Phase 1 L2161 (post-rebuild-check hook-name assertion), L2208 (its test); Implementation phases L2022; Rollout step (a) L2315-2322
+
+post-rebuild-check gains a check keyed on whether /usr/local/bin/hypr-live-update-guard exists, asserting that /etc/pacman.d/hooks/10-hypr-live-update-guard.hook exists and the unprefixed name doesn't. The spec names no seam for either path. Ratio's hook migration is rollout step (a), which the spec orders after the Phase 1 commit.
+
+Risk: The unseamed check reads ratio's real /etc and fails all 32 all-clean tests. So 'make test-unit' can't pass on ratio before the commit, and ratio can only be fixed after it. Either the commit lands red or the machine is migrated out of order. Adding a ninth check also moves every 'check N/8' assertion.
+
+Recommended change: At the end of the L2161 bullet, add: "It follows the script's seam convention. PRC_GUARD_BIN overrides the guard path (set-but-empty = not installed), and PRC_PACMAN_HOOKS_DIR overrides /etc/pacman.d/hooks. Every test env, run_check's defaults included, sets PRC_GUARD_BIN empty so the suite never reads the real /etc. It is check 9, so the header, the usage text and the 'check N/8' counters become 9."
+
+Optional, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: accepted, folded into Implementation phases, Testing.
+:EVIDENCE:
+- Spec L2161 is the assertion. It names no seam. A grep for PRC_ in the spec returns nothing.
+- Spec L2163 names UPGRADE_GUARDED_* and KMC_MODULES_DIR / KMC_BOOT_DIR for the other two scripts.
+- Spec L2208: "tests/post-rebuild-check/ covers the hook-name assertion, and the check is skipped when the guard isn't installed". The guard is installed on both daily drivers, so this test needs a guard-path seam.
+- scripts/post-rebuild-check:55-82 documents an env seam for every probe ("for each, set-but-empty means ... unset means run the real probe"). PRC_IDLE_DAEMON at 72-73 already uses the installed-or-not MISSING shape.
+- tests/post-rebuild-check/test_post_rebuild_check.py:54-77: run_check sets all nine PRC_ seams to clean defaults. Other tests build their own env dicts at lines 167, 213, 368, 912, 930, 955, 983, 1008 and 1065. 32 lines assert returncode 0, and 7 assert '/8' strings.
+- Live ratio (uname -n = ratio): /usr/local/bin/hypr-live-update-guard exists. /etc/pacman.d/hooks holds 99-grub-sync-efi.hook and hypr-live-update-guard.hook, with no 10- file.
+- scripts/post-rebuild-check: "eight checks" at lines 3 and 95; report "check N/8" at 218, 367, 412, 475, 502, 579, 617 and 667.
+- Makefile:58-64: test-unit globs tests/*/test_*.py, so the post-rebuild-check suite runs on every test-unit.
+:END:
+
+** DONE Phase 1 and Phase 2 are each too large to build and verify in one session, and both can be split without a broken intermediate state
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Implementation phases L2022; Phase 1 L2024-2208; Phase 2 L2210-2262; Decision 3 (three code commits)
+
+Phase 1 is one commit with two new scripts, four modes, the closure loop, the arm procedure, the record, the stamp predicate, signal handling, the installer step, the TOML rewrite and the post-rebuild-check change. Its test list runs to 38 bullets, many compound (L2163-2208). Phase 2 is one dotfiles commit.
+
+Risk: A session that can't finish a phase ends with a half-verified commit or one large unreviewed diff. In Phase 1 the risky logic (the closure loop, the gate, the arm flag) shares a single verification pass with everything else.
+
+Recommended change: L2022: replace "Three code commits across two repos, in this order: archsetup (Phase 1), dotfiles (Phase 2), archsetup (Phase 3)." with "Three ordered commit groups across two repos: archsetup (Phase 1), dotfiles (Phase 2), archsetup (Phase 3). A phase may land as several commits if each one is green on its suite (make test-unit, or tests/maint/). The constraints are ordering and coupling, not commit count: Phase 4 step (a) follows Phase 1's last commit; no Phase 2 commit reaches a machine before step (a) there; the deferred row and APPLY land in one commit; the lever repoint, the doctor.py stamp deletion, the guard UX and --force removal, and the sysupgrade alias land in one commit."
+
+L2211: change "One dotfiles commit, pulled" to "The Phase 2 commits, pulled".
+
+L221 (Decision 3): change "the rollout is three code commits" to "the rollout is three ordered commit groups".
+
+L2316, Readiness (a) and L2523: change "After the Phase 1 commit" to "After Phase 1's last commit", and "three code commits" to "three commit groups".
+
+Readiness rollback step 1: change "Revert the dotfiles Phase 2 commit" to "Revert the dotfiles Phase 2 commits, newest first".
+
+Leave L2159 and L2231 as they are; the new L2022 sentence restates them as invariants. Don't hard-code the 1a-1d / 2a-2b split; leave the decomposition to the implementer's approach step.
+
+Optional, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: accepted, folded into Implementation phases, Decisions.
+:EVIDENCE:
+Spec (docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org):
+- L2022: "Three code commits across two repos, in this order".
+- L221 (Decision 3): "so the rollout is three code commits".
+- L2211: "One dotfiles commit, pulled on a machine only after Phase 4's step (a)".
+- L2231: "The row and APPLY ship in this same commit."
+- L2159: TOML and hook name "change in the same commit".
+- L2523: "Rollout follows the three code commits in order".
+- L2316 and Readiness step (a): "After the Phase 1 commit".
+- Readiness rollback step 1: "Revert the dotfiles Phase 2 commit."
+
+Size:
+- sed -n 2024,2208p | wc -w gives 4575 words for Phase 1, and Phase 2 (L2210-2262) gives 1231.
+- L2163-2208 has 45 '- ' lines; minus the 5 group headers and the 2 installer-steps/post-rebuild-check bullets, that leaves 38 test bullets, matching the claim.
+
+Coupling the split must keep:
+- dotfiles doctor.py:169-170: =if rid == "topgrade": cache.put("topgrade_run", ...)=. Repointing TOPGRADE without deleting this stamps a deferred run fresh (prior blocking finding at L262).
+- remedies.py:298/308: "guard": "live_update" on update/topgrade, and doctor.py:216/242 refuse or force-wrap guarded remedies. The guard removal has to land with the repoint.
+- L860/L867: the row ships with its lever (APPLY).
+
+Dotfiles touch points confirmed:
+- remedies.py:294-313, panel.py:224-237, viewmodel.py:99-104, gui.py:352/1396-1436/1496 (_update_force, _guard_arm_line), cli.py:265 (--force).
+- tests/maint/test_remedies_doctor.py has 55 case-insensitive guard/force matches (the finding said 37; same direction, not material).
+
+No hazard between sub-commits:
+- L1378 evidence: dotfiles pulls go live immediately (symlinks), and there is no auto-pull timer.
+- L2095: --complete with the unit not enabled arms nothing.
+- Nothing calls upgrade-guarded before Phase 2.
+:END:
+
+** DONE The cross-repo record fixture has no named paths and nothing keeps its two copies in sync
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Phase 1 L2191; Testing L2464; Decision 4 consequences L233
+
+'The record round-trips through a shared fixture JSON in cache.put's envelope; Phase 2's probe test reads a copy of it.' Neither copy's path is named, and nothing compares the copies.
+
+Risk: A later change to the writer in archsetup leaves the dotfiles copy passing against a stale shape. The first sign is the live panel misreading the record.
+
+Recommended change: Replace the L2191 bullet (and the matching sentence at L2464) with: "a fixed fake everyday run's record equals tests/upgrade-guarded/fixtures/upgrade_deferred.json in every field except written_at; Phase 2's probe test reads a byte-identical copy at tests/maint/fixtures/upgrade_deferred.json, and any change to the record's fields updates both copies in the same rollout." This binds the fixture to the script's real output and names both paths. A cross-repo diff check is optional beyond that. If one is wanted, add it to Phase 4 step (a) as "diff the two fixture copies".
+
+Optional, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: accepted, folded into Implementation phases, Decisions, Testing.
+:EVIDENCE:
+- Spec L2191: "the record round-trips through a shared fixture JSON in cache.put's envelope; Phase 2's probe test reads a copy of it." No path is named.
+- Spec L2254: "the probe reads a copy of Phase 1's fixture record". No path is named.
+- Spec L2464: "The record round-trips through a fixture JSON in cache.put's {written_at, data} envelope, and Phase 2's probe test reads a copy of the same fixture."
+- Spec L233: "a record format two repos must agree on, so both test suites read copies of one fixture in maint's envelope".
+- Spec L2117-2125 (Phase 1 'The record') and L2372 (Data model) fully specify the path, envelope, atomic write and every data field, so an implementer has the shape without the fixture.
+- Spec L1105/L1116: the earlier finding's risk was that "the 'state file round-trips' test only checks the script against itself", and its fix was to point Phase 2 at the same fixture.
+- Rollout L2318-2323 and L2540-2544 have no fixture comparison step.
+- archsetup: =find tests -name '*.json'= returns nothing, so there's no existing fixture convention.
+- dotfiles: maint/src/maint/fixtures/ contains bad.json, good.json and gen_fixtures.py, which are panel board fixtures loaded through panel.load_fixture (tests/maint/test_panel_levers.py:721).
+- dotfiles maint/src/maint/cache.py:31 writes {"written_at": time.time(), "data": data}, and cache.py:40 reads entry["data"] and entry["written_at"].
+:END:
+
+** DONE topgrade_age's absent-stamp text still says the TOPGRADE lever stamps
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Phase 2 Levers L2216
+
+Phase 2's stamp-removal edits name doctor.py:169-170, the wrapper's header comment and tests/maint/test_topgrade_wrapper.py.
+
+Risk: On a fresh machine the row tells the user that any successful TOPGRADE clears it. That's the very misreading the deferred row and the wall note exist to correct.
+
+Recommended change: At spec L2216, after "...and =tests/maint/test_topgrade_wrapper.py=", insert: ", and the =topgrade_freshness= docstring and absent-stamp error in =maint/src/maint/probes/updates.py:115-126=,". Then change the closing clause to "so none of them says the lever stamps through the wrapper or through doctor". Also give the replacement error text: 'no topgrade run recorded — upgrade-guarded stamps it once nothing is left deferred; a bare-shell topgrade stamps through the PATH wrapper'. Optionally, add updates.py to the file list in the Phase 2 done criterion at L2467.
+
+Optional, not blocking. Verification: confirmed.
+Disposition: accepted, folded into Implementation phases.
+:EVIDENCE:
+- Spec L2216: "Delete doctor.py:169-170 ... Rewrite the header comment of the PATH wrapper =~/.local/bin/topgrade= and =tests/maint/test_topgrade_wrapper.py= so neither says the lever stamps through the wrapper." It doesn't mention updates.py.
+- Spec L113-118: the stamp predicate. The everyday run stamps only when the record's packages list is empty, and UPDATE never stamps. L120: "The panel's TOPGRADE lever no longer stamps on its own."
+- Spec L2253 (Phase 2 test): "a zero-exit TOPGRADE lever whose record lists deferred packages leaves =topgrade_run= unchanged".
+- Spec L2467 and L2506: the Phase 2 done criterion and AC9 also leave out the probe text.
+- ~/.dotfiles/maint/src/maint/probes/updates.py:115-118, docstring: "The topgrade PATH wrapper stamps the cache on any successful run (TTY, workflow, shell), and the TOPGRADE lever stamps via doctor".
+- updates.py:122-126, error text: "no topgrade run recorded — any successful run stamps it (PATH wrapper / TOPGRADE lever)".
+- ~/.dotfiles/maint/src/maint/doctor.py:169-170: the =if rid == "topgrade": cache.put("topgrade_run", ...)= branch, which Phase 2 deletes.
+- grep over tests/maint finds no test that pins the updates.py error string.
+:END:
+
+** DONE --no-aur has no caller, yet it widens the stamp predicate and the usage rules
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Design L80, L89, L115; Phase 1 L2029-2030, L2067, L2138; tests L2181; AC3 L2343; Testing L2510
+
+--no-aur skips the yay step. 'No lever passes it, and it exists for manual runs and tests' (L80, L2030). It is a usage error outside the everyday mode, and the everyday stamp requires that neither --no-topgrade nor --no-aur was given.
+
+Risk: It is a second skip flag that the stamp predicate, AC3, the usage-error rule and the mode tests each have to carry, for a manual convenience with no owner.
+
+Recommended change: Delete --no-aur from the body sections. Leave the historical text in Review findings (L752-771) unchanged.
+- L80 and L2029: change "passing =--no-topgrade= or =--no-aur= outside the everyday run/mode" to "passing =--no-topgrade= outside the everyday run/mode". Delete the sentence "=--no-aur= skips the yay step; no lever passes it, and it exists for manual runs and tests." from L80 and L2030.
+- L89 and L2067: change "7. Unless =--no-aur=: =yay -Sua --noconfirm=." to "7. =yay -Sua --noconfirm=."
+- L115, L228 and L2138: change "neither =--no-topgrade= nor =--no-aur= was given" to "=--no-topgrade= was not given".
+- L2181 and L2510: change "combining modes, or =--no-topgrade= or =--no-aur= outside the everyday mode, exits 2" to "combining modes, or =--no-topgrade= outside the everyday mode, exits 2".
+- L2343 (AC3): change "a TOPGRADE run (neither =--no-topgrade= nor =--no-aur=)" to "a TOPGRADE run (no =--no-topgrade=)".
+
+Optional, not blocking. Verification: confirmed.
+Disposition: accepted, folded into Implementation phases, Acceptance criteria, Testing, Design, Decisions.
+:EVIDENCE:
+Spec references (docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org):
+- L80 and L2030: "=--no-aur= skips the yay step; no lever passes it, and it exists for manual runs and tests."
+- L89 and L2067: "7. Unless =--no-aur=: =yay -Sua --noconfirm=. ... On failure: failed_step aur, and step 8 still runs."
+- L115, L228 (Decision 4, not in the finding's list) and L2138: the everyday run stamps only when neither --no-topgrade nor --no-aur was given.
+- L2163: the fakes on PATH include yay.
+- L2180: "a failing fake yay: the sweep still runs, the record says failed_step aur, nothing stamps, and the exit is 1".
+- L2181 and L2510: the only test that mentions --no-aur is the usage-error test.
+- L2343 (AC3): "a TOPGRADE run (neither --no-topgrade nor --no-aur)".
+- L2247 (sysupgrade alias) and L2313 (system-health-check step 6) pass no flags.
+- L752-760: the prior review asked for a consumer for --no-topgrade and --no-aur. Only --no-topgrade got one: UPDATE.
+
+Code: grep -rn 'no-aur' over the dotfiles maint/ tree, hyprland/.local/bin, and archsetup's scripts/, tests/, the archsetup installer and docs/workflows/ found nothing. Nothing exists today that would pass the flag.
+:END:
+
+** DONE Record fields mode and gate.at have no reader, and gate.ok is always false when present
+CLOSED: [2026-10-05 Mon 10:40]
+Where (round 2): Design L104, L108; Phase 1 L2123, L2127; Readiness L2372
+
+data.mode is everyday, complete or apply-armed. gate is either null or {ok: false, kernels, failed, since, at}.
+
+Risk: Every field belongs to the cross-repo fixture that both test suites share (L233, L2191). Each one is a contract obligation with no consumer.
+
+Recommended change: This is the smallest edit:
+- Delete the "- =mode=: everyday, complete or apply-armed." bullet at L104 and L2123.
+- At L108 and L2127, change "since: <T0>, at: <epoch>}" to "since: <T0>}".
+- At L2372, change "{packages: [...], mode, result, failed_step, detail, gate, news}" to "{packages: [...], result, failed_step, detail, gate, news}".
+
+Optional further step: drop ok as well. Define gate as "null, or {kernels: [<kver>...], failed: <item>, since: <T0>}" at L108 and L2127. Then reword "gate.ok is false" to "gate is non-null" at L232, L2137, L2227, L2240, L2255 and L2490.
+
+If I want mode kept for human diagnosis instead, keep it but add "informational; no reader depends on it" to L104 and L2123.
+
+Optional, not blocking. Verification: confirmed.
+Disposition: modified, folded into Implementation phases, Decisions, Acceptance criteria, Readiness dimensions, Testing. Adopted, including the optional drop of ok, and one step further: the result value gate-failed goes too. A gate failure is result failed with failed_step gate and exit 4. The panel grades CRIT from the gate field, not from result. So no reader loses anything, and the cross-repo fixture contract gets smaller.
+:EVIDENCE:
+Spec docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org:
+- L104 and L2123 define the mode values: everyday, complete or apply-armed.
+- L108 and L2127 define gate as null or {ok: false, kernels, failed, since: <T0>, at: <epoch>}.
+- L2372 lists data as {packages, mode, result, failed_step, detail, gate, news}.
+
+Readers:
+- L2223-2229: the probe reads the record, the flag's presence and line count, packages (minus installed), detail, news, gate.ok (CRIT), result (WARN) and advisories.
+- L2240: REBOOT reads gate.ok and the flag.
+- L2148 and L2487: the re-check reads the recorded kernels and since.
+- L2137 and L232: the stamp reads gate.ok.
+
+A grep of the spec for the record's mode field and for gate.at turns up only the definitions at L104, L108, L2123, L2127 and L2372. The other line it hits, L710, is the earlier finding's {ok: false, ..., at: <ts>}, which is where ok and at came from.
+
+Tests: L2186-2196 and L2479-2490 assert packages, failed_step, the gate's survival and clearing, and gate.ok false. None asserts mode or gate.at.
+
+Code: ~/.dotfiles/maint/src/maint has no upgrade_deferred probe yet, and its only "mode" read (viewmodel.py:821) is the storage probe's evidence. The archsetup scripts/ and tests/ do not mention upgrade_deferred or upgrade-guarded.
+:END:
+
+** DONE R10: P1-complete case (i) drops 'result failed', and nothing replaces the pre-written result on the stage-1-failed, gate-passed path
+CLOSED: [2026-10-05 Mon 12:10]
+Where (round 3): Phase 1 --complete step 3 L4166 (pre-write), step 5 last bullet L4175, Finish L4198, The record L4212, Exit codes L4271; P1-complete case (i) L4351
+
+R10 resolved P1-complete (i) as: the run 'ends with exit 1, result failed, failed_step pacman, gate null, and the kernel-side entries still in packages as kind kernel'. L4351 asserts exit 1, failed_step pacman, gate null and the kernel kind kernel entries, but not result failed. In the body, step 3 pre-writes 'result interrupted, failed_step pacman' (L4166). Step 5's fail branch overrides it explicitly ('result failed, failed_step gate, exit 4', L4173). The branch R10 covers, 'if stage 1 failed and the gate didn't fail ... failed_step pacman, exit 1' (L4175), sets no result. Neither does the pass branch ('gate = null', L4172). Finish step 1 is only 'Compute the record' (L4198), and no rule anywhere maps the exit code to result (L4271 lists exit codes only).
+
+Risk: Take a mirror or signature failure in stage 1, the common case R03's structural form was added for. An implementation that carries the pre-written record forward ends with result interrupted and fails no test. The trap's 'interrupted' (exit 130) then becomes indistinguishable from a download failure (exit 1). The same gap leaves a successful --complete's result unspecified after the pre-write.
+
+Recommended change: Three edits, all small:
+
+1. L4351: change "exits 1 with failed_step pacman" to "exits 1 with result failed, failed_step pacman". This restores the wording R10 agreed.
+
+2. Finish, L4198: change "1. Compute the record." to:
+
+ "1. Compute the record. result is ok on exit 0, failed on exit 1 or 4, and refused on exit 3. On exit 0, failed_step is null. These replace any value pre-written by --complete step 3 or --apply-armed step 2, and detail is replaced likewise. Only the interrupt trap writes interrupted."
+
+ This one rule covers the L4175 branch, both success paths, and everyday's failure steps, which also depend on the unstated mapping today.
+
+3. L4334: append "and the record reads result ok with failed_step null", so a stale pre-write after a successful --complete or --apply-armed fails a test.
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: accepted, folded into Implementation phases (the contract) and the sections that cite it.
+:EVIDENCE:
+Spec docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org:
+- --complete pre-write:
+ - L4166: "result interrupted, failed_step pacman;"
+ - L4167: detail '--complete interrupted during the kernel transaction — do not reboot; ...'
+- L4173, gate fail: "result failed, failed_step gate, exit 4" (explicit override).
+- L4175: "if stage 1 failed and the gate didn't fail (...): failed_step pacman, exit 1." No result.
+- L4172, gate pass: "gate = null". No result.
+- L4186, --apply-armed pre-write: "result interrupted, failed_step boot-transaction ...". L4190 and L4191 override it explicitly on failure. L4192, "Finish, exiting with the transaction's status (0 on a no-op)", sets no result.
+- L4198: "1. Compute the record." Finish gives no rule for result.
+- L4212: result is "ok, failed, refused or interrupted". L4271 lists exit codes only.
+- L4351: (i) asserts "exits 1 with failed_step pacman, gate null and the kernel-side entries still in packages as kind kernel". result is not asserted.
+- L4334: the success tests for --complete and --apply-armed assert only "packages is empty and the stamp is fresh".
+- L4402: Phase 2 grades WARN when result is failed, refused or interrupted.
+- L4643: "the record's result, failed_step and detail carry it to the panel". This implies the mapping without stating it.
+- Grep of Phase 1 for "result": every value is set at one of three kinds of site: an explicit refusal or failure (L4089, 4090, 4106, 4129, 4159, 4173, 4185, 4190, 4191, 4199), a pre-write (L4166, 4186), or the trap (L4272). There is no site for "result ok" and none on L4175.
+
+The agreed round-2 resolution for this case: "(i) A stage 1 that exits non-zero before committing, with the structural gate passing, ends with exit 1, result failed, failed_step pacman, gate null, ..."
+:END:
+
+** DONE R30: the roster-FIX routing test became an assertion that can't fail, and the terminal-kind branch has no GTK-free seam
+CLOSED: [2026-10-05 Mon 12:10]
+Where (round 3): Phase 2, APPLY L4412 (_press_lever routes terminal-kind remedies to the launcher); Phase 2 tests intro L4438 ('No test imports gui.py or GTK'); P2-apply L4458
+
+L4412 puts the routing decision inside gui.py's _press_lever: 'On the second, _press_lever calls a GTK-free panel.py launcher ... never _fire or doctor.iter_fix. That covers the row key, the topgrade_age card key and REVIEW & FIX's FIX.' The R30 resolution's test bullet was 'A roster FIX press reaches the launcher and never streams through _fire.' P2-apply L4458 tests something weaker. It checks that the row key and the roster's FIX 'resolve to apply_deferred with kind terminal', that the launcher calls the patched Popen with the foot argv, and that 'iter_fix is never called'. No test drives the press path, so nothing checks that a press on a terminal-kind rid reaches the launcher instead of _fire. Calling the launcher directly never calls iter_fix, so that assertion can't fail.
+
+Risk: The defect R30 and the earlier F40 exist to prevent can come back with the whole suite green. Suppose an implementer adds the kind, the launcher and the items builder but misses the branch in _press_lever, or wires only the row key. REVIEW & FIX's FIX on APPLY then runs 'upgrade-guarded --complete' through _fire and iter_fix inside the panel process. The kernel transaction gets no terminal, the gate verdict and reboot prompt can't be seen, and the transaction is tied to the panel's lifetime.
+
+Recommended change: Move the routing decision into panel.py itself rather than adding a predicate.
+
+Phase 2, APPLY, L4412: replace "On the second, =_press_lever= calls a GTK-free panel.py launcher that runs =Popen(['foot', '--hold', '-e', *argv], start_new_session=True)=, never =_fire= or =doctor.iter_fix=." with:
+
+"On the second, =_press_lever= makes one call, =panel.fire_press(rid, items, stream, fixture)=, and nothing else. For a terminal-kind rid, fire_press runs the launcher =Popen(['foot', '--hold', '-e', *argv], start_new_session=True)=, or on a fixture board returns 'fixture board, not applied' the way MERGE does. For any other rid it calls stream, which is gui.py's =_fire=. It never calls =doctor.iter_fix= for a terminal-kind rid."
+
+Phase 2 tests, P2-apply, L4458: replace "; =iter_fix= is never called" with:
+
+"; =panel.fire_press= given the row key's rid and items, and the roster FIX's (token ='apply_deferred:'=), calls the patched Popen once and never the stream callable; for =update= it calls the stream callable and never Popen; and with fixture set it calls neither."
+
+Optionally, add "a detached foot terminal opens and the wall streams nothing" to M-boot-armed's Expected (L4517). That makes the manual APPLY press check the branch explicitly.
+
+Should-fix, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: modified, folded into Implementation phases and the sections that cite it. Partially confirmed. Merged with T15, which reports the same defect: the routing branch sits in gui.py, which no test imports. I took T02's shape: _press_lever makes one GTK-free panel call that owns the choice, and that call has a fixture-board branch like MERGE's. I also took T15's interface, a zero-argument fire callable in place of a stream/_fire signature, so the call doesn't depend on _fire's arguments after Phase 2 removes force. T15's control case (update calls fire, never Popen) is in the test. The optional M-boot-armed Expected clause is included, because it is the only check that gui.py actually makes the call.
+:EVIDENCE:
+Spec (line numbers checked):
+- L4412: "On the second, =_press_lever= calls a GTK-free panel.py launcher ... never =_fire= or =doctor.iter_fix=. That covers the row key, the =topgrade_age= card key and REVIEW & FIX's FIX."
+- L4438: "No test imports gui.py or GTK." L4695 says the same.
+- L4458: "the row key and the roster's FIX both resolve to =apply_deferred= with kind terminal, and the panel.py launcher calls the patched =subprocess.Popen= ...; =iter_fix= is never called"
+- L4392: panel.guarded is kept as the GTK-free seam for the arm line.
+- L4605 (AC4) and L4702 (AC4 is verified by P1-gate, P1-complete, P2-row and P2-apply).
+- L4517: M-boot-armed "arms from APPLY" on ratio and velox, but no Expected clause names the foot terminal.
+- L3121: the earlier round's adopted bullet was "A REVIEW & FIX FIX press on apply_deferred Popens the foot argv and never streams through _fire."
+
+Code (~/.dotfiles/maint/src/maint):
+- gui.py:1408-1438: _press_lever calls model.press(token). On "armed" it builds the arm line; otherwise it calls self._fire(...).
+- gui.py:1440-1442: _on_lever calls _press_lever.
+- gui.py:784-785 and 835-836 (card keys) and gui.py:950-954 (_digest_key, the roster FIX) all call _on_lever, so the entry points share one branch.
+- gui.py:1474-1485: _fire streams doctor.iter_fix through _stream's daemon thread.
+- gui.py:1477: dry = bool(self.fixture).
+- gui.py:1665-1670: _on_merge refuses on a fixture board.
+- panel.py:200-202: key_token is rid + ":" + items, so item-less row and roster keys both carry the token "apply_deferred:".
+- panel.py:224-228: guarded() is a pure predicate.
+- panel.py:314-321: PanelModel.press returns "armed" or "fire" and does no dispatch.
+
+Tests:
+- tests/maint/test_panel.py:3-4 says the GTK view is never imported.
+- A grep of tests/maint for _press_lever or a gui import finds nothing.
+- tests/maint/panel_smoke.py drives presses over AT-SPI, but it isn't part of make test and the spec doesn't name it.
+:END:
+
+** DONE R34: minimal/'s alias files are symlinks to common/'s, so 'stays plain topgrade' and 'minimal files are unchanged' can't both hold
+CLOSED: [2026-10-05 Mon 12:10]
+Where (round 3): Phase 2, Alias and README, L4434 ('minimal/'s alias stays plain topgrade'); Phase 2 tests, P2-text, L4468 ('the minimal files are unchanged')
+
+The spec makes the sysupgrade alias in common/.zshrc.d/aliases.sh and common/.bashrc.d/aliases.sh conditional. It says minimal/'s alias stays plain topgrade, and P2-text adds a source check that both common files carry the conditional and the minimal files are unchanged.
+
+Risk: Once common's files change, the minimal 'files' carry the conditional too. A content check that minimal still reads plain topgrade fails. The only way to keep minimal at plain topgrade is to replace the symlinks with regular files, and that turns test_every_shared_path_is_a_symlink_in_minimal red and brings back the drift the dedup was built to stop. The implementer has to choose which rule to break. The runtime outcome is fine either way, since the conditional falls back to topgrade where upgrade-guarded is absent, but the spec's statement and its test are false as written.
+
+Recommended change: L4434: replace "minimal/'s alias stays plain topgrade." with "minimal/'s alias files are symlinks to these (tests/tier-dedup pins that), so a none install gets the same conditional, which falls back to topgrade because upgrade-guarded is never installed there."
+
+L4468: replace "and the minimal files are unchanged" with "and minimal/'s alias files are still symlinks resolving to them".
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: accepted, folded into Implementation phases (the contract) and the sections that cite it.
+:EVIDENCE:
+Spec L4434 (Phase 2, Alias and README): "...common/ is also stowed on dwm installs, which never get the scripts. minimal/'s alias stays plain topgrade."
+Spec L4468 (P2-text): "a source check that both common alias files carry the conditional and the minimal files are unchanged;"
+
+Output of git ls-files -s in ~/.dotfiles:
+ 100644 4a59203... common/.bashrc.d/aliases.sh
+ 100644 4a59203... common/.zshrc.d/aliases.sh
+ 120000 fd2654f... minimal/.bashrc.d/aliases.sh
+ 120000 f40d468... minimal/.zshrc.d/aliases.sh
+
+Output of ls -la:
+ minimal/.bashrc.d/aliases.sh -> ../../common/.bashrc.d/aliases.sh
+ minimal/.zshrc.d/aliases.sh -> ../../common/.zshrc.d/aliases.sh
+
+grep shows common/.{bash,zsh}rc.d/aliases.sh:42 is alias sysupgrade="topgrade".
+
+~/.dotfiles/tests/tier-dedup/test_tier_dedup.py:
+- The docstring says minimal's duplicates "are now relative symlinks into common/, making drift structurally impossible".
+- test_every_shared_path_is_a_symlink_in_minimal (L53-58) fails on any regular-file copy.
+- test_every_symlink_resolves_to_commons_copy (L60-71) requires each link to resolve to common's file.
+
+archsetup:1518-1520: the none) branch stows only minimal.
+
+A grep of the spec body (outside Review findings) finds minimal only at L4434 and L4468.
+:END:
+
+** DONE R42: the single Finish write isn't the last thing --complete does, so a HUP or Ctrl+C at the reboot prompt overwrites the finished record
+CLOSED: [2026-10-05 Mon 12:10]
+Where (round 3): Phase 1 Finish L4196-4200; --complete step 7 L4181; Exit codes and interrupts L4272; --complete pre-write detail L4167; The record writers L4220; Phase 2 WARN L4402, REBOOT L4426, APPLY launcher L4412
+
+R42 is otherwise present as agreed. Finish computes the record, folds a stamp failure into it, and then says '3. Write the record once and exit.' (L4200). L4330 and AC8 L4619 assert result failed and failed_step stamp. But --complete step 7 (L4181) reads 'Finish. Then prompt "Reboot now? [y/N]"', so the process keeps running after its single write. The trap (L4272) is not scoped. 'Every mode traps INT, TERM and HUP' and 'writes result interrupted with the step ... and the remedy (the pre-written detail where one exists)'. L4220 lists the trap as a record writer with no exception for after the finish.
+
+Risk: Two parts of the spec conflict: 'and exit' in Finish and 'Then prompt' in step 7. An implementer who follows the trap literally ships a record that contradicts a successful run. It tells the user 'do not reboot' while the panel offers REBOOT, and it breaks the single-write outcome R42 was meant to guarantee. Nothing is unsafe, because the message errs toward not rebooting and a later run rewrites it. But it is misleading after the most common APPLY outcome.
+
+Recommended change: Phase 1 Finish, L4200: replace "3. Write the record once and exit." with "3. Write the record once. From then on the trap writes nothing and only exits 130. Then exit, except that =--complete= goes on to its step 7 prompt."
+Exit codes and interrupts, L4272: after "and exits 130." add "After Finish's write it exits 130 without writing."
+P1-arm-boot, after L4363: add "- an INT or HUP at the reboot prompt exits 130 and leaves the record byte-identical to the one Finish wrote."
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: modified, folded into Implementation phases and the sections that cite it. Confirmed, and it overlaps T06: both say Finish 'exits' before step 7's prompt and the trap is still live there. They conflict on the outcome. T04 wants exit 130 with no rewrite; T06 wants an exit of 0. I took T06's semantics. When the prompt shows, the run has already succeeded and the record reads ok, so a signal or EOF at the prompt is a 'no' and the exit stays 0. Exit 130 would contradict the record. Finish step 3's half of this fix is carried in T01's Finish edit. The step 7, trap and test edits are here, and T06 adds nothing separate.
+:EVIDENCE:
+- Spec L4200: "3. Write the record once and exit."
+- Spec L4181: "7. Finish. Then prompt 'Reboot now? [y/N]' only when the exit is 0, stdin is a tty (the APPLY terminal is one), and the run armed or its gate passed in the =--since= form."
+- Spec L4272: "Every mode traps INT, TERM and HUP. The trap waits for the running child ..., writes result interrupted with the step ... and the remedy (the pre-written detail where one exists), keeps gate as written, and exits 130." It does not say what happens after Finish.
+- Spec L4220: the record is "Written by every mode's finish ..., the =--complete= pre-write, the =--apply-armed= pre-write and the interrupt trap." There is no post-finish exception.
+- Spec L4164-4167: the pre-write detail is '--complete interrupted during the kernel transaction — do not reboot; run upgrade-guarded --complete'.
+- Spec L4402: the row goes WARN on result interrupted and shows detail's last line. L4426: REBOOT is shown while the flag exists and gate is null.
+- Spec L4412: APPLY runs Popen(['foot', '--hold', '-e', *argv]). On this machine "foot --help" lists "-H,--hold remain open after child process exits", and "pacman -Q foot" reports foot 1.28.0-2.
+- Spec L4360-4369 (P1-arm-boot): only L4363 tests the prompt ("prompts for a reboot only when the exit is 0 and stdin is a tty"). No case covers a signal arriving at the prompt.
+:END:
+
+** DONE R53: --dry-run's private db reaches only the closure probes; the normative Sets bullet still takes the pending set from the stale system sync db
+CLOSED: [2026-10-05 Mon 12:10]
+Where (round 3): Phase 1, Sets L4093 (pending set); Phase 1, Modes and options L4082 (--dry-run); Dependency closure L4117, L4125, L4128; Rollout step (a) L4559 and M-split-live L4562; Design L85
+
+L4082: --dry-run 'creates a private CHECKUPDATES_DB with mktemp -d (removed on exit), runs checkupdates against it (exit 2 means empty), and passes that path as every closure probe's --dbpath.' L4093, which has no mode exception: 'Pending set: the rows of LC_ALL=C pacman -Qu (name old -> new) against the system sync db, minus rows marked [ignored].' No body line says that --dry-run's pending set is checkupdates' output. R53's 'current' text quoted the earlier spec as saying '--dry-run takes its pending set from checkupdates', and that clause did not survive the rewrite.
+
+Risk: An implementer who follows the normative Sets bullet builds a preview that resolves its pending set and its closure against two different dbs. The preview can then refuse when no refusal is due, or predict the wrong deferred set. That undermines the rollout's dry-run gate and the M-split-live comparison, which is the defect R53 was accepted to remove.
+
+Recommended change: At L4093, after "...minus rows marked =[ignored]= (pacman.conf IgnorePkg, such as bridge-utils on ratio)", add: "Under =--dry-run= it is instead the lines =checkupdates= prints against the run's private =CHECKUPDATES_DB= (Modes and options), which already drop =[ignored]= rows. Exit 2 means empty, and any other non-zero exit fails the preview with exit 1 and no record."
+
+Optionally, extend the --dry-run case at L4325 so the test pins the source: give the fake checkupdates and the fake pacman -Qu different rows, and assert that the printed pending set is the checkupdates rows.
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: accepted, folded into Implementation phases (the contract) and the sections that cite it.
+:EVIDENCE:
+- Spec L4093: "Pending set: the rows of =LC_ALL=C pacman -Qu= (=name old -> new=) against the system sync db, minus rows marked =[ignored]=". It has no --dry-run exception.
+- Spec L4082: "creates a private =CHECKUPDATES_DB= with =mktemp -d= ..., runs =checkupdates= against it (exit 2 means empty), and passes that path as every closure probe's =--dbpath=". It does not say the pending set is taken from this output.
+- Spec L4117 does have the carve-out: "runs against the run's one refreshed db (=--dry-run= passes its private db as =--dbpath=)". So the Sets bullet is the odd one out.
+- Spec L4052: phase bullets are normative over Design. L85 ("resolves against a private sync db of its own") therefore yields to L4093.
+- Spec L4081: dry-run "uses no sudo", so there is no -Sy and the system db stays stale. L754 records the system db showing 0 updates while checkupdates showed 395.
+- Spec L4125 / L4128: a probe line naming a package that is not pending "adds nothing", and "an iteration that adds no new name" refuses.
+- Spec L4325: the test checks only the probes' --dbpath, not the source of the pending set.
+- Spec L4559, L4562, L4592: the rollout gate and M-split-live compare against dry-run's predicted deferred set.
+- /usr/bin/checkupdates (pacman-contrib 1.13.1-1): L150 runs "fakeroot -- pacman -Sy ... --dbpath "$CHECKUPDATES_DB"". L155 runs "pacman -Qu --dbpath "$CHECKUPDATES_DB" ... | grep -v '\[.*\]'", so [ignored] rows are already dropped. L181 is "exit 2".
+:END:
+
+** DONE --complete's reboot prompt comes after a Finish that already exits, and the trap is live at the prompt
+CLOSED: [2026-10-05 Mon 12:10]
+Where (round 3): Phase 1, --complete sequence step 7 (line 4181); Phase 1, Finish (lines 4196-4200); Exit codes and interrupts (line 4272); The record failed_step list (line 4213); P1-arm-boot (line 4363)
+
+Step 7 reads 'Finish. Then prompt Reboot now? [y/N] ...', but Finish ends with 'Write the record once and exit.' The trap covers INT, TERM and HUP in every mode with no exception for the prompt, and failed_step has no value for an interruption there.
+
+Risk: An implementer has to decide where the prompt sits relative to the record write and the trap. If the prompt follows the write with the trap still armed, Ctrl+C or closing the window at 'Reboot now?' (a natural way to say no) overwrites a clean arming or a passed-gate record with result interrupted, sets an undefined failed_step, and exits 130. The row then shows WARN after a session that succeeded. No P1 case covers an interrupt at the prompt.
+
+Recommended change: Three edits.
+
+L4181: replace the step with "7. Finish writes the record. Then, only when the exit is 0, stdin is a tty (the APPLY terminal is one), and the run armed or its gate passed in the --since form, prompt 'Reboot now? [y/N]' before exiting. The interrupt trap no longer applies at the prompt: INT, TERM, HUP, EOF or any answer but y count as no, the record isn't rewritten, and the exit stays 0. Yes runs sudo -n systemctl reboot."
+
+L4200: change it to "3. Write the record once, then exit (for --complete, after step 7's prompt)."
+
+L4272: insert "until finish has written the record" after "traps INT, TERM and HUP".
+
+Then add a P1-arm-boot case after L4363: "an INT or HUP at the reboot prompt leaves the record byte-identical and exits 0."
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: modified, folded into Implementation phases and the sections that cite it. Confirmed. This is the same defect as T04, merged there. I adopted T06's semantics in T04's edits: the prompt comes after Finish's single write, a signal or EOF at the prompt counts as no, and the exit stays 0. T01 E1 carries Finish step 3 ('then exit, except that --complete first runs its step 7 prompt'). Nothing separate here, so no edit is applied twice.
+:EVIDENCE:
+The spec lines:
+- L4181: "7. Finish. Then prompt 'Reboot now? [y/N]' only when the exit is 0, stdin is a tty ..."
+- L4200: "3. Write the record once and exit."
+- L4272: "Every mode traps INT, TERM and HUP. The trap waits for the running child ..., writes result interrupted with the step ... and the remedy (the pre-written detail where one exists), keeps gate as written, and exits 130." It makes no exception for the prompt.
+- L4164-4167: the --complete pre-write detail '... — do not reboot; run upgrade-guarded --complete'.
+- L4402: the row is "WARN when result is failed, refused or interrupted (the text adds detail's last line ...)".
+- L4412: APPLY runs Popen(['foot', '--hold', '-e', ...]), so closing that window sends HUP.
+- L4213: failed_step has no value for an interrupt at the prompt.
+- L4363: the only P1 prompt case is "prompts for a reboot only when the exit is 0 and stdin is a tty".
+
+Shell check: bash -c 'trap "echo trapped-at-prompt; exit 130" INT HUP; (sleep 0.5; kill -HUP $$) & read -r ans' with stdin a FIFO that has no data printed "trapped-at-prompt", and the exit was 130.
+:END:
+
+** DONE The lever runner's 3600 s timeout SIGKILLs upgrade-guarded past its trap, so it bounds nothing and leaves the record stale
+CLOSED: [2026-10-05 Mon 12:10]
+Where (round 3): Phase 1, Everyday sequence step 3 sub-bullet (line 4136); Phase 2, Levers (line 4380); Exit codes and interrupts (line 4272); Design 'The record and the stamp' (line 93); Risks, Arch news (line 4692); AC7 (line 4614)
+
+Phase 2 keeps UPDATE and TOPGRADE on the existing lever runner with timeout 3600. Phase 1 calls a stuck informant-hook wait 'bounded by the lever runner's 3600 s on a lever run'. Design says the panel 'always reflects the last run' and that the trap writes the record on a kill. The everyday run has no pre-write, and the trap catches only INT, TERM and HUP.
+
+Risk: When a lever run passes 3600 s (an informant fetch that hangs inside 00-informant, or a large AUR build plus the sweep), upgrade-guarded is SIGKILLed with no trap and no record write. The panel keeps showing the previous run's result. Its sudo, pacman, yay or topgrade descendants keep running unsupervised, and a pacman still in the hook keeps db.lck, so later runs refuse. 'Bounded by 3600 s' bounds only the panel's wait.
+
+Recommended change: This is option (a), using SIGINT only, to match the boot unit.
+
+1. Phase 2, Levers (L4380): after "stay on the existing lever runner", add:
+
+ "except for the timeout: _execute_step starts a remedy marked long with start_new_session=True. Once its timeout passes, it sends SIGINT to the process group, waits up to 120 s, then SIGKILLs the group and reports 'timed out — interrupted'. Never SIGTERM, because pacman has no SIGTERM handler. upgrade-guarded's trap then records result interrupted (exit 130)."
+
+2. P2-levers (L4439): add a case. A fake argv that sleeps past a 1 s timeout receives SIGINT, its trap writes its marker, and no process in its group survives.
+
+3. Phase 1 step 3 (L4136) and Risks, Arch news (L4692): change "bounded by the lever runner's 3600 s" to:
+
+ "on a lever run, ended by the runner's 3600 s timeout as a SIGINT to the run's process group, which stops pacman at a package boundary and which the trap records as interrupted"
+
+4. Design L93 can then stand as written.
+
+If maint isn't to change, take the alternative. Replace those two passages with the true residual: a lever timeout SIGKILLs only the script, so no trap runs and the record stays at the previous run, while sudo/pacman continue orphaned holding db.lck and may die on SIGPIPE mid-package. Then name that path in Risks, Partial ecosystem state, and drop "the lever runner's timeout" from AC7's list of endings that swap nothing.
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: accepted, folded into Implementation phases (the contract) and the sections that cite it.
+:EVIDENCE:
+- ~/.dotfiles/maint/src/maint/doctor.py:161 runs =proc = cmd.run(step["argv"], timeout=r.get("timeout", 60))=. doctor.py:163 returns "tool missing or timed out".
+- ~/.dotfiles/maint/src/maint/cmd.py:22 runs =subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)= and catches TimeoutExpired.
+- CPython 3.14.7 subprocess.run, from inspect.getsource on this machine: on TimeoutExpired it calls =process.kill()= then =process.wait()=, and Popen.kill sends SIGKILL to the direct child only.
+- remedies.py:301 and :312 set ="timeout": 3600= for update and topgrade.
+- grep for start_new_session/killpg/os.setsid in maint finds only gui.py:1672/1756/1766 (MERGE/journal/net Popen). The lever runner has no process-group handling.
+- Spec L4272: the trap covers INT, TERM and HUP only. L4136 and L4692: "bounded by the lever runner's 3600 s". L93: "so the panel always reflects the last run". The everyday sequence (L4133-4148) has no pre-write; the only pre-writes are L4164 (--complete) and L4186 (--apply-armed).
+- Spec L4498 and L4684: pacman has no SIGTERM handler, and the unit uses SIGINT to the cgroup.
+- =bash -c 'pacman -Ql | head -1 >/dev/null; echo ${PIPESTATUS[0]}'= prints 141: pacman dies on SIGPIPE, at least in query mode.
+:END:
+
+** DONE 'minimal/'s alias stays plain topgrade' and 'the minimal files are unchanged' are impossible: they are symlinks to the common files
+CLOSED: [2026-10-05 Mon 12:10]
+Where (round 3): Phase 2, Alias and README (line 4434); P2-text (line 4468)
+
+Phase 2 rewrites the sysupgrade alias in common/.zshrc.d/aliases.sh and common/.bashrc.d/aliases.sh, says 'minimal/'s alias stays plain topgrade', and P2-text asserts 'the minimal files are unchanged'.
+
+Risk: P2-text as written either fails (reading through the symlink shows the new conditional) or is vacuous (the symlink target is unchanged), so an implementer has to guess its intent. At runtime minimal gets the same conditional. That is harmless, because it falls back to topgrade where upgrade-guarded is absent, but the spec describes a separate file that doesn't exist.
+
+Recommended change: Line 4434: replace "minimal/'s alias stays plain topgrade." with "minimal/'s aliases.sh files are symlinks into common/ (tests/tier-dedup pins this), so they carry the same conditional, which resolves to topgrade where upgrade-guarded isn't installed. Don't replace them with regular files."
+Line 4468: replace "and the minimal files are unchanged" with "and minimal/.zshrc.d/aliases.sh and minimal/.bashrc.d/aliases.sh are still symlinks resolving to them". Alternatively, drop the minimal clause, since the tier-dedup test already enforces this.
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: modified, folded into Implementation phases and the sections that cite it. Confirmed. This is the same finding as T03 and is merged there. T03's edits say the minimal files are symlinks that carry the conditional, and they replace the 'unchanged' check with a symlink check. That covers this finding's recommended wording.
+:EVIDENCE:
+Spec line 4434: "...common/ is also stowed on dwm installs, which never get the scripts. minimal/'s alias stays plain topgrade."
+Spec line 4468: "a source check that both common alias files carry the conditional and the minimal files are unchanged;"
+Running git ls-files -s in ~/.dotfiles shows:
+ 100644 ... common/.bashrc.d/aliases.sh
+ 100644 ... common/.zshrc.d/aliases.sh
+ 120000 ... minimal/.bashrc.d/aliases.sh
+ 120000 ... minimal/.zshrc.d/aliases.sh
+ls -la shows minimal/.bashrc.d/aliases.sh -> ../../common/.bashrc.d/aliases.sh and minimal/.zshrc.d/aliases.sh -> ../../common/.zshrc.d/aliases.sh.
+grep sysupgrade finds only common/.zshrc.d/aliases.sh:42 and common/.bashrc.d/aliases.sh:42, both alias sysupgrade="topgrade".
+~/.dotfiles/tests/tier-dedup/test_tier_dedup.py: test_every_shared_path_is_a_symlink_in_minimal fails on "regular-file duplicates in minimal/ — convert to symlinks into common/".
+archsetup:1518-1521: the none) branch runs stow on minimal only.
+Spec line 4690: the hold covers "the sysupgrade alias where upgrade-guarded is installed".
+:END:
+
+** DONE Some acceptance-criterion clauses map to test groups whose cases don't check them
+CLOSED: [2026-10-05 Mon 12:10]
+Where (round 3): Testing mapping (lines 4702, 4705, 4710, 4711); AC4 (line 4603); AC7 (line 4616); AC12 (line 4628); AC13 (line 4629); P1-steps (line 4320); P1-complete (line 4359); P1-installer (lines 4371-4373)
+
+AC12's 'installer writes the hook as 10-hypr-live-update-guard.hook' is mapped to 'P1-installer (TOML pin, hook name)'. AC13's selection rule (the oldest pre-pacman snapshot no earlier than a minute before the entry) and 'held_snapshot names it' are mapped to P1-complete. AC4's 'across a wall dismiss and a panel reopen' is mapped to P2-row and P2-apply. AC7's 'record the unread items before the clear' is mapped to P1-steps.
+
+Risk: These clauses can be ticked with nothing having checked them, and the target-selection rule is the one that decides whether the right fallback snapshot stays pinned.
+
+Recommended change: In P1-complete (spec L4359), after "and a structural-form gate places no hold.", add: "A fake =zfs list -H -p -t snapshot -o name,creation= offers four root-dataset pre-pacman snapshots: one created at since − 61 s, one at exactly since − 60 s, one inside the window, and one after a later everyday run. The hold targets the since − 60 s snapshot, and the record's held_snapshot equals that name. A follow-up --since gate with the same recorded since holds nothing new and releases nothing." The four-snapshot fixture is the whole fix; nothing else needs to change. Optionally, change the AC12 mapping text at L4710 from "P1-installer (TOML pin, hook name)" to "P1-installer (TOML pin, which locates the heredoc by its exact =10-hypr-live-update-guard.hook= path)". Make no change for the AC4 or AC7 parts.
+
+Should-fix, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: modified, folded into Implementation phases and the sections that cite it. Partially confirmed, and the finding was narrowed to the AC13 selection rule. The four-snapshot selection case goes into P1-complete. Because T11 changes when the post-gate hold applies, the case is folded into T11's single rewrite of the P1-complete hold bullet (T11 E6), with its fixture shaped to the new rule (held_snapshot older than since − 60 s). The AC12 hook-name gap is closed in the phase, not in Testing: the TOML pin test now locates the heredoc by its exact 10- path. No change for the AC4 or AC7 parts, which the check found already correct.
+:EVIDENCE:
+- Spec L4248: "the target is the oldest =<rootds>@pre-pacman_*= with creation ≥ gate.since − 60 s". L4252: "held_snapshot = target".
+- Spec L4359 (P1-complete, the only hold case): "a fake zfs logs hold and release. The hold is placed after a passing and after a failing =--since= gate, the previous hold is released, the same target is never re-held, and a structural-form gate places no hold." It asserts neither the selection nor the held_snapshot value.
+- Spec L4354: "the gate field survives a following everyday run". L4355: a follow-up --complete "runs the =--since= form against the recorded since".
+- Spec L4711: "AC13 …: P1-complete and P1-installer". L4373 (P1-installer): only the zfs-pre-snapshot prune of held snapshots.
+- Spec L4575 (D-zbm, "non-gating", L4573): "records the held snapshot in held_snapshot".
+- archsetup:2625 =cat > /etc/pacman.d/hooks/10-hypr-live-update-guard.hook << 'HOOKEOF'=. tests/installer-steps/test_pacman_hook_order.py:47-61 asserts one written hook ending hypr-live-update-guard.hook that sorts before 60-mkinitcpio-remove.hook, plus the ^\d{2}- prefix. scripts/testing/tests/test_desktop.py:59 asserts the exact 10- path in the VM.
+- Spec L4134: "News: =yay -Pwq=, before the first transaction, because yay judges news against installed build dates. It needs no informant." =man yay=: "-w, --news Print new news … News is considered new if it is newer than the build date of all native packages."
+- Spec L4410: "the panel reads the outcome from the record and the flag on its next probe". L4427: "The panel never infers arm or gate state from an exit code". L4447 (P2-row): "a gate-open fixture gives the CRIT text, REBOOT hidden and APPLY shown".
+:END:
+
+** DONE After an unfinished stage 1, the stated remedy (--complete) can never clear the gate
+CLOSED: [2026-10-05 Mon 12:10]
+Where (round 3): Phase 1, --complete sequence steps 3–5 (lines 4162–4175, detail at 4167); Phase 2 CRIT text (4401); Phase 4 README flow and recovery (4537)
+
+The pre-written detail says 'do not reboot; run upgrade-guarded --complete'. The CRIT row says 'run upgrade-guarded --complete after any fix'. The only manual step documented is diagnosis with kernel-modules-check (4537). The spec names no repair.
+
+Risk: The machine sits at CRIT 'do not reboot' indefinitely, and the one remedy the spec gives can't clear it. On velox, an operator with no documented way out eventually reboots into a /boot with no kernel.
+
+Recommended change: 1. Phase 1, --complete step 4 (spec 4168). Append:
+ "When the previous gate is non-null, stage 1 also names reinstall targets after the --ignore list, without --needed:
+ - for each pkgbase still in gate.pkgbases, the owner (pacman -Qqo) of its ${KMC_MODULES_DIR}/<kver>/vmlinuz, plus <pkgbase>-headers where installed;
+ - every DKMS-set member.
+ A same-version reinstall is an Upgrade path operation, so 90-mkinitcpio-install (install_kernel, which restores the preset and /boot/vmlinuz-<pkgbase>) and 70-dkms-upgrade/70-dkms-install fire again. 60-mkinitcpio-remove fires only on Remove. Without this, a stage 1 interrupted after the kernel committed, which skips every PostTransaction hook, can never pass a later gate."
+
+2. Step 3's detail (4167). Keep the wording; it becomes true once item 1 lands.
+
+3. P1-complete. Add:
+ "after an interrupted stage 1 whose kernel landed (fake /boot/vmlinuz-<pkgbase> missing, nothing kernel-side pending), the follow-up stage 1 argv names the gate's kernel package, its headers and the DKMS-set members as targets without --needed; with the fake hooks restoring the image, the gate passes and sets gate null."
+
+4. Phase 4 README bullet (4537). Add the manual equivalent:
+ "if the check reports a missing /boot image or initramfs, sudo pacman -S <pkgbase> <pkgbase>-headers <dkms packages> re-runs the post-transaction hooks; then run upgrade-guarded --complete."
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: modified, folded into Implementation phases and the sections that cite it. Confirmed. A stage 1 cut off after the kernel committed skips 90-mkinitcpio-install and the DKMS hooks. A follow-up -Su then has nothing kernel-side pending, so the gate's /boot item fails forever. I kept the reinstall-on-open-gate fix, with two narrowings. First, targets resolve with pacman -Q <pkgbase>, the same check step 5 already uses, rather than a new pacman -Qqo over vmlinuz, which finds nothing once the module tree is gone. Second, I dropped the README manual-pacman step, because --complete now does the reinstall itself and the README already names --complete as the only clearing path. The pre-write detail stays as it is, because it is now true.
+:EVIDENCE:
+Spec citations:
+- 4165–4167: pre-write sets gate.failed 'kernel transaction not finished' and the detail '... do not reboot; run upgrade-guarded --complete'.
+- 4168: stage 1 is a plain -Su with no named targets.
+- 4171: a follow-up run (previous gate non-null) uses --since.
+- 4173: a failed gate exits 4.
+- 4238: the gate requires /boot/vmlinuz-<pkgbase>.
+- 4401: CRIT text 'run upgrade-guarded --complete after any fix'.
+- 4537: the README gives diagnosis only.
+- 4354: P1-complete expects the follow-up --complete to clear the gate after an interrupt.
+- 4347 and 4603: interrupted stage 1 is a planned-for case.
+
+Live system (ratio):
+- /usr/share/libalpm/hooks/60-mkinitcpio-remove.hook: 'Operation = Remove', 'Target = usr/lib/modules/*/vmlinuz', 'When = PreTransaction'.
+- 90-mkinitcpio-install.hook: second [Trigger] is 'Operation = Install', 'Operation = Upgrade', 'Target = usr/lib/modules/*/vmlinuz'; 'When = PostTransaction'.
+- 70-dkms-install.hook: PostTransaction, Install/Upgrade on usr/src/*/dkms.conf, usr/lib/modules/*/build/include/ and usr/lib/modules/*/modules.order.
+- /usr/share/libalpm/scripts/mkinitcpio:
+ - remove_kernel does 'rm -f -- "${filelist[@]}"' (the kernel copy plus the images), then remove_preset moves a non-template preset to .pacsave;
+ - install_kernel (install_preset, then 'install -Dm644 -- "${line}" "${kernel}"') runs only for a */vmlinuz install target;
+ - every other trigger only sets package=1, which becomes 'mkinitcpio -P'.
+- /etc/mkinitcpio.d/linux-lts.preset: PRESETS=(default fallback), ALL_kver="/boot/vmlinuz-linux-lts". It differs from /usr/share/mkinitcpio/hook.preset, so it goes to .pacsave.
+- /usr/bin/mkinitcpio:133: error "specified kernel image does not exist".
+- strings on libalpm: 'transaction started' / 'transaction interrupted' / 'transaction completed'.
+- Versions: pacman 7.1.0.r9.g54d9411-2, mkinitcpio 42.1-1, dkms 3.4.3-2.
+:END:
+
+** DONE Power loss or a kill during stage 1 gets no snapshot hold, and Recovery points at the wrong snapshot
+CLOSED: [2026-10-05 Mon 12:10]
+Where (round 3): Phase 1, --complete step 5 (4174); The gate (4242); The snapshot hold (4248, 4255); Exit codes and interrupts (4272); Phase 4 Recovery (4541–4548); D-zbm (4572–4577)
+
+The hold is placed only after a --since gate runs. The trap exits 130 without running the gate, and power loss runs nothing. Both the gate's snapshot item and the hold target are 'creation ≥ since − 60 s', with no upper bound. Recovery boots 'the snapshot the record's held_snapshot names', and D-zbm drills only the failed-gate path.
+
+Risk: Power loss in the kernel transaction is the case that most needs ZFSBootMenu recovery. In that case Recovery names a stale snapshot or none, its =pacman -U --dbonly= steps fail on db.lck, and the kernel package is left unregistered. Separately, the snapshot check can pass on, and pin, a snapshot that cannot fall back to the old kernel.
+
+Recommended change: 1. Root-step list (4064-4070): add =zfs snapshot=.
+2. --complete step 3 (4162-4167): on a ZFS root, when this run opens the entry (the previous gate is null), after the pre-write and before stage 1:
+ - run =sudo -n zfs snapshot <rootds>@pre-pacman_<YYYY-MM-DD_HH-MM-SS>=, then =sudo -n zfs hold upgrade-guarded <it>=;
+ - release the previous held_snapshot, tolerating a missing one, set held_snapshot to the new snapshot, and write the record again;
+ - a failure prints 'snapshot hold failed: <reason>' and stage 1 still runs.
+ When the gate is already open, keep held_snapshot.
+3. The snapshot hold (4248): apply the post-gate hold only when held_snapshot is null or was created before gate.since − 60. The held snapshot then can't be pruned, so the gate item at 4242 can stay unchanged and can no longer pass on a snapshot taken after the kernel landed.
+4. Recovery (4541-4548), before the reconcile:
+ - "Remove /var/lib/pacman/db.lck if a power cut left it; nothing is running after a reboot."
+ - After the reconcile: "A power cut mid-transaction leaves the package being extracted with no [ALPM] line and a db entry without desc that pacman -Q still lists at the new version. Find it with =pacman -Dk= ('description file is missing'), delete that entry's directory under /var/lib/pacman/local, and =pacman -U --dbonly= its old version from the cache."
+ - End the reconcile with "=pacman -Dk= and =pacman -Qkk= are clean."
+5. D-zbm (4572-4577): add a variant that powers off the VM from the QEMU monitor during stage 1's extraction, boots held_snapshot, and runs Recovery. Expected: the old kernel set, and clean =pacman -Dk= and =pacman -Qkk=.
+
+Should-fix, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: modified, folded into Implementation phases and the sections that cite it. Partially confirmed; the recommended change reflects the narrowed finding. The hold is placed only after a --since gate. A kill or power cut in stage 1 therefore leaves no hold and a stale held_snapshot, and Recovery boots that stale name. I kept the fix: when --complete opens the entry, it takes and holds its own pre-stage-1 snapshot, and the post-gate hold applies only when that hold is missing. I also kept the Recovery additions (db.lck, the missing-desc entry, a clean pacman -Dk) and the non-gating D-zbm power-off variant. I verified that zfs-pre-snapshot's 60 s skip keys on its own lockfile, not on existing snapshots. The hook may therefore still cut its own snapshot seconds later, or fail on a same-second name, which it only warns about. Both are harmless, so no extra rule is needed. Merges: T09's selection case and T18's hold-failure case go into one rewrite of the P1-complete hold bullet (E6). T18 owns the failure bullet of The snapshot hold. T21 owns Recovery's opening. Design and Risks get pointer fixes. AC13 still holds as written.
+:EVIDENCE:
+Spec lines checked:
+- 4174 and 4248-4255: the hold happens only "after a --since-form gate on a ZFS root".
+- 4272: the trap "writes result interrupted ... keeps gate as written, and exits 130". It runs no gate and places no hold.
+- 4242 and 4248: the only bound is "creation ≥ since − 60 s".
+- 4206 and 4217: held_snapshot is carried forward.
+- 4541: "After booting the snapshot the record's held_snapshot names from ZFSBootMenu".
+- 109: "The recorded name is the snapshot recovery boots".
+- 4688: "an unplanned reboot while the gate is open ... is recovered from ZFSBootMenu".
+- 4572-4577: D-zbm covers only the failed gate.
+- 4132-4152: the everyday sequence never refuses while the gate is open.
+- 4685: power loss can leave "a half-extracted package and a stale db.lck".
+
+Code and system:
+- scripts/zfs-pre-snapshot:14 sets KEEP=10. Lines 35-40 prune on every snapshot it creates.
+- archsetup:2421-2433: 05-zfs-snapshot.hook runs on every Upgrade, Install and Remove transaction (Target = *), PreTransaction.
+- /usr/share/libalpm/hooks/60-mkinitcpio-remove.hook is a PreTransaction Path Remove trigger on usr/lib/modules/*/vmlinuz that runs "mkinitcpio remove". It removes the /boot image during a kernel upgrade, so a power cut in that window leaves velox with no /boot/vmlinuz-linux-lts.
+- Installed pacman is 7.1.0.
+
+Scratch db test (a throwaway =--dbpath=, local/linux-lts-6.18.30-1/ holding only mtree):
+- =pacman -Q= printed "linux-lts 6.18.30-1" and exited 0.
+- =pacman -Qi linux-lts= printed "error: could not open file .../desc".
+- =pacman -Dk= printed "error: 'linux-lts-6.18.30-1': description file is missing".
+:END:
+
+** DONE KillMode=control-group sends SIGINT to the output mirror too, so pacman can die of SIGPIPE mid-package
+CLOSED: [2026-10-05 Mon 12:10]
+Where (round 3): Phase 1, --apply-armed step 3 (4187); Phase 3, The unit (4489, 4491) and Disarm and failure (4498); AC6 (4611); M-boot-midtx (4520)
+
+All boot-run output is mirrored to the journal with systemd-cat while it also goes to tty1. The unit's KillSignal=SIGINT is sent with KillMode left at control-group. The spec relies on pacman stopping at a package boundary and releasing db.lck.
+
+Risk: A boot-run timeout can leave a half-extracted GPU/compositor package and a stale db.lck. That is the outcome KillSignal=SIGINT exists to prevent, and AC6's 'no db.lck remains' claim fails intermittently because it is a race.
+
+Recommended change: Phase 1, --apply-armed step 3 (4187): after the systemd-cat sentence, add: 'The mirror must outlive the unit's SIGINT, because KillMode=control-group signals it too, and pacman dies of SIGPIPE if the reader of its output is gone. Start the mirror asynchronously, for example =exec > >(tee >(systemd-cat -t archsetup-boot-upgrade)) 2>&1=, which bash starts with SIGINT ignored; otherwise run =trap "" INT= before exec. Never use a foreground pipeline.'
+
+Phase 3, Disarm and failure (4498): add 'the output mirror ignores SIGINT (Phase 1, --apply-armed step 3)'.
+
+P1-arm-boot (after 4368): add a case. Run a fake --apply-armed under setsid with a fake pacman that traps INT, writes to stderr after the signal, then removes a fake lock file and exits 130. Send SIGINT to the whole process group to emulate control-group. Assert that the fake pacman was not killed by SIGPIPE, the fake lock is gone, and the record reads interrupted.
+
+Don't adopt the KillMode=mixed alternative unless the trap at 4272 is also changed to forward SIGINT to its child.
+
+Should-fix, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: modified, folded into Implementation phases and the sections that cite it. Partially confirmed; the recommended change reflects the narrowed finding. KillMode=control-group SIGINTs a foreground-pipeline mirror, and pacman dies of SIGPIPE writing its interrupt message. I widened the fix from the boot form to a rule in Exit codes and interrupts. The same group-wide SIGINT also reaches every mode: Ctrl+C in a terminal --complete during the kernel transaction, and T07's lever-timeout SIGINT. Any in-script tee or stderr capture would kill pacman mid-package the same way. The --apply-armed step points at the rule with the bash example. KillMode stays control-group, and T12's own advice is not to switch to mixed.
+:EVIDENCE:
+Spec:
+- 4187: 'All output is mirrored to the journal with systemd-cat -t archsetup-boot-upgrade'. No construction is given.
+- 4489: 'KillSignal=SIGINT, with KillMode left at control-group'.
+- 4491: StandardInput=null, StandardOutput=tty, TTYPath=/dev/tty1.
+- 4498: 'A timeout sends SIGINT to the cgroup. pacman ... stops at a package boundary and releases db.lck'.
+- 4272: the trap 'waits for the running child'. It does not forward the signal.
+- 4368: the P1 test only checks that output goes 'through the fake systemd-cat'.
+- 4611 (AC6) and 4684 (Risks) claim no db.lck remains after a SIGINT-ended timeout.
+
+Live checks on ratio:
+- man systemd.kill: control-group means 'all remaining processes in the control group of this unit will be killed'. Default is control-group.
+- man systemd.exec: 'If the TTY is used for output only, the executed process will not become the controlling process of the terminal'.
+- man sudoers, use_pty: 'If the sudo process is not attached to a terminal, use_pty has no effect'. sudo is 1.9.17p2.
+- =bash -c 'pacman -Ql glibc | head -n1 >/dev/null; echo ${PIPESTATUS[*]}'= printed =141 0=, so pacman 7.1.0 dies of SIGPIPE.
+- strings /usr/bin/pacman shows the 'Interrupt signal received' and 'Hangup signal' messages beside alpm_trans_interrupt. nm shows sigaction only.
+
+SIGINT disposition by mirror construction, bash 5.3.20, run from a parent with SIGINT/SIGQUIT reset to SIG_DFL, reading /proc/<pid>/status SigIgn:
+
+| Construction | SigIgn | SIGINT |
+|---|---|---|
+| foreground pipeline element (=echo \| sh -c ...=) | 0x0 | dies |
+| =exec > >(tee ...)= child | 0x6 | ignored |
+| =<(...)= / =>(...)= | 0x6 | ignored |
+| pipeline inside a procsub | 0x6 | ignored |
+| tee with a nested =>(...)= | 0x6 | ignored |
+| explicit =&= | 0x6 | ignored |
+
+Sibling scripts: scripts/hypr-live-update-guard starts with #!/bin/sh; scripts/zfs-pre-snapshot starts with #!/bin/bash. The spec names no language for upgrade-guarded.
+:END:
+
+** DONE The everyday run never detects a kernel or DKMS change made by yay, so REBOOT stays offered
+CLOSED: [2026-10-05 Mon 12:10]
+Where (round 3): Phase 1, Everyday sequence step 7 (4142); Invariants (4206); Phase 2, Freshness and REBOOT (4426); Risks (4691)
+
+An AUR upgrade that needs a newer kernel-set or DKMS-set package 'installs it ungated', and Risks accepts this. Only --complete writes gate, so the everyday run opens no entry and records no kernel-side change.
+
+Risk: This reproduces the velox failure chain the spec exists to prevent (a new kernel, an unverified zfs build, an initramfs possibly without zfs.ko), with the panel offering REBOOT and no row saying anything.
+
+Recommended change: 1. Everyday step 7 (4140-4142): replace the 'installs it ungated' sub-bullet with this:
+
+"Before yay, record =pacman -Q= of the kernel-set and DKMS-set members and T7 = now. After yay, whatever its exit, recompute the kernel set and compare. If any member's version changed, or a new kernel-set member appeared, open gate as --complete step 3 does: {pkgbases: the previous pkgbases ∪ the pkgbases of the changed kernels ∪ (on a DKMS change) every kver with an =installed= line, since: the previous since or T7, failed: 'kernel-side package moved by the AUR step'}. Then set failed_step aur, detail '<names> moved by yay -Sua — do not reboot; run upgrade-guarded --complete', exit 1."
+
+2. Everyday step 9 (4147): add a first case, "gate is non-null: =sudo -n rm -f= the flag". This keeps invariant 4203.
+
+3. Invariants (4206): change it to "Only =--complete= and everyday step 7's kernel-change check write gate; only =--complete= closes it. Every other mode carries gate and held_snapshot forward unchanged." --complete then takes the --since gate form on its own (4171, previous gate non-null) and never prints 'nothing to complete' (4161).
+
+4. Risks (4691), Non-Goals (63) and Design (89): say the change is detected and gated rather than silent. Reconcile 4687's "land only in --complete's stage 1".
+
+5. P1-steps: add a case. A fake yay that bumps a kernel-set member's version opens gate, removes an existing flag, exits 1 with failed_step aur, and doesn't stamp. A following --complete runs kernel-modules-check in the --since form.
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: accepted, folded into Implementation phases (the contract) and the sections that cite it.
+:EVIDENCE:
+Spec lines:
+- 63: "An AUR upgrade that needs a newer kernel-set or DKMS-set package is the one ungated path, named in Risks."
+- 4142: "One that needs a newer kernel-set or DKMS-set package installs it ungated (Risks names this)."
+- 4691: "installs it ungated; no installed foreign package has such a dependency today."
+- 4206: "Only =--complete= writes gate."
+- 4108: everyday deferred set = post-transaction pending ∩ held. A package yay upgraded is no longer pending.
+- 4227: the everyday stamp needs only that every step exited 0 and packages is empty.
+- 4426: "REBOOT is hidden while gate is non-null ... Otherwise it is shown while the flag exists ... and is otherwise unchanged."
+- 4161: --complete with "nothing is pending and gate is null: print 'nothing to complete' ... exit 0".
+- 4164: the pre-write and gate entry happen only "when a kernel-set or DKMS-set member is pending, or gate is non-null".
+- 4687: "they land only in --complete's stage 1, behind the kernel-modules-check gate ... While it is open, nothing arms or offers a reboot."
+- 4203: "No arm flag exists while gate is non-null."
+- 4147-4151: step 9's 'otherwise' case re-arms.
+- 4558: rollout step (a) checks only "pacman -Qmq lists no DKMS-set package".
+
+Code:
+- ~/.dotfiles/maint/src/maint/gui.py:1509-1511 calls self.model.offer_reboot() when rid is update or topgrade and the primary event is ok.
+- ~/.dotfiles/maint/src/maint/probes/packages.py:131-146: reboot_required is true when /usr/lib/modules/$(uname -r) is gone.
+
+Commands:
+- /usr/share/libalpm/hooks has 70-dkms-install.hook, 70-dkms-upgrade.hook and 90-mkinitcpio-install.hook.
+- On ratio, the dependencies of the foreign packages show nothing kernel- or DKMS-related. linux-lts-strix depends on coreutils, initramfs and kmod; mkinitcpio-firmware depends on linux-firmware*, which is never a kernel-set member. So there is no instance today, as the spec and the finding both say.
+:END:
+
+** DONE REBOOT can stay pressable for up to 30 s after the gate opens
+CLOSED: [2026-10-05 Mon 12:10]
+Where (round 3): Phase 2, APPLY (4412) and Freshness and REBOOT (4426)
+
+REBOOT is hidden while gate is non-null, but only when the panel re-renders from a probe. APPLY's launcher triggers no re-probe, and the reboot remedy doesn't check the gate when it fires.
+
+Risk: If REBOOT was showing before APPLY (after a successful UPDATE, or while armed), it stays visible and can be confirmed during stage 1, until the next full tick. A shutdown sends pacman SIGTERM, which it doesn't handle.
+
+Recommended change: Two edits, both in the Phase 2 body.
+
+1. In Phase 2 "Freshness and REBOOT" (4426), append: "The reboot remedy re-reads the record (cache.get upgrade_deferred) when it fires. While gate is non-null it runs nothing and yields a fail event: 'gate open — do not reboot: <gate.failed>; run upgrade-guarded --complete'. This closes the window between --complete's pre-write and the panel's next probe, and covers a --complete started from a shell or by maint fix reboot." The check goes in doctor.iter_fix (GTK-free) so the panel key, REVIEW & FIX and the CLI all get it. Amend Invariants 4204 to say the same: "the panel hides REBOOT once it has probed, and the reboot remedy refuses at fire time while gate is non-null."
+
+2. Under P2-row (around 4447), add a test: "On a gate-open fixture, iter_fix('reboot') yields fail with the gate text and never calls the priv reboot argv, even with reboot_offer set."
+
+Optional: have the APPLY launcher also clear reboot_offer, so a stale post-UPDATE latch doesn't come back after a successful --complete that didn't need a reboot. An immediate re-probe at launch isn't needed, because the gate hasn't been written yet at that point.
+
+Should-fix, not blocking. Verification: confirmed.
+Disposition: accepted, folded into Implementation phases (the contract) and the sections that cite it.
+:EVIDENCE:
+Spec 4204: "No reboot is offered while gate is non-null: the panel hides REBOOT".
+Spec 4601-4602 (AC4): the gate is open "from the start of its stage 1" and "REBOOT is hidden whatever the arm flag, reboot_required or offer_reboot say", "whether the run started from the panel or a shell".
+Spec 4162-4168: the gate pre-write is --complete step 3, after step 1 (news capture, informant clear) and step 2 (-Sy, flag removal, closure probe), immediately before stage 1.
+Spec 4412-4413: the launcher only runs Popen(['foot','--hold','-e',*argv], start_new_session=True), "never _fire or doctor.iter_fix"; "the panel reads the outcome ... on its next probe".
+Spec 4426: the gate hides REBOOT, but the offer_reboot latch is otherwise kept.
+
+dotfiles maint/src/maint/gui.py:
+- 92: _FULL_SECONDS = 30.
+- 1317: the full refresh runs on a 30 s timer.
+- 1376-1384: _full_tick refreshes unless self.firing.
+- 1325-1327: refresh_full returns early while busy.
+- 491: the REBOOT key renders from model.reboot_key_visible().
+- 1510-1511: offer_reboot() runs after any ok update/topgrade lever.
+
+panel.py:
+- 306: reboot_offer is reset only in __init__.
+- 501-504: offer_reboot sets it True.
+- 506-510: reboot_key_visible returns the latch or reboot_required.
+
+The reboot remedy has no fire-time check:
+- remedies.py:314-320: kind priv, verb reboot, no record read.
+- priv.py:340-341, 376: argv ["systemctl", "reboot"].
+- doctor.py:192-252: iter_fix has no reboot precondition.
+
+The spec's own statements on what a SIGTERM does to stage 1 (rather than the SIGINT pacman honors):
+- 115: pacman honors SIGINT at a package boundary.
+- 2082-2086: 60-mkinitcpio-remove runs PreTransaction, and PostTransaction hooks don't run if the transaction fails.
+:END:
+
+** DONE R11: P2-apply never fires a key, so the roster-FIX routing it was meant to pin goes untested
+CLOSED: [2026-10-05 Mon 12:10]
+Where (round 3): Phase 2 APPLY L4412; Phase 2 tests intro L4438; P2-apply L4458; Guard UX rationale L4392
+
+L4412 puts the terminal-kind branch inside _press_lever: 'On the second, _press_lever calls a GTK-free panel.py launcher ... never _fire or doctor.iter_fix'. L4438 says 'No test imports gui.py or GTK'. So L4458 can only check that the row key and the roster's FIX 'resolve to apply_deferred with kind terminal', and separately that the launcher Popens the foot argv. Its 'iter_fix is never called' clause has no fire path to observe. The R11 resolution asked for a test 'fired from the row key and from the roster's FIX ... and iter_fix is never called'.
+
+Risk: If _press_lever's terminal branch is missing or wrong, APPLY from either the row key or the roster's FIX runs upgrade-guarded --complete through iter_fix inside the panel's daemon stream thread (gui.py:1443-1460). Closing the panel can then kill it mid-kernel-transaction, which is the failure F40/R11 closed. Every automated test would still pass, because L4458 never exercises the routing.
+
+Recommended change: Phase 2 APPLY, L4412: replace "On the second, =_press_lever= calls a GTK-free panel.py launcher that runs =Popen([...], start_new_session=True)=, never =_fire= or =doctor.iter_fix=." with "On the second, =_press_lever= delegates to a GTK-free =panel.press_fire(rid, items, fire)=. For a terminal-kind remedy it calls the panel.py launcher, which runs =Popen(['foot', '--hold', '-e', *argv], start_new_session=True)=. Otherwise it calls =fire= (gui's =_fire=). For the reason given at L4392, this is the only GTK-free seam for the choice, so it never reaches =_fire= or =doctor.iter_fix= for a terminal kind."
+
+Phase 2 tests, L4458: replace "...and the panel.py launcher calls the patched =subprocess.Popen= with ... =start_new_session=True=; =iter_fix= is never called" with "driving =panel.press_fire= with the row key's and the roster FIX key's rid and items calls the patched =subprocess.Popen= with =['foot', '--hold', '-e', 'upgrade-guarded', '--complete']= and =start_new_session=True=, and never calls the passed =fire= stub or a patched =doctor.iter_fix=. Control case: =panel.press_fire('update', [], fire)= calls =fire= and never calls Popen."
+
+Optional, not blocking. Verification: confirmed.
+Disposition: modified, folded into Implementation phases and the sections that cite it. Confirmed. This is the same defect as T02 and is merged there. T02's edit moves the terminal-kind choice into GTK-free panel.fire_press(rid, items, fire, fixture), with a zero-argument fire callable. That is T15's interface. The P2-apply rewrite drives fire_press with both the row key's and the roster FIX's rid and items, asserts that neither the fire stub nor iter_fix is called, and includes T15's update control case.
+:EVIDENCE:
+Spec docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org:
+- L4412: "On the second, =_press_lever= calls a GTK-free panel.py launcher ... never =_fire= or =doctor.iter_fix=."
+- L4438: "No test imports gui.py or GTK."
+- L4458: "the row key and the roster's FIX both resolve to =apply_deferred= with kind terminal, and the panel.py launcher calls the patched =subprocess.Popen= ...; =iter_fix= is never called"
+- L4392: "=_press_lever= lives in gui.py, which needs GTK, so this is the only GTK-free seam for the arm line."
+- L4414: iter_fix itself runs apply_deferred's argv with inherited stdio for the CLI form.
+- L4605 (AC4): "APPLY, from the row or from REVIEW & FIX's FIX, opens ... in a detached =foot= terminal and never runs it inside the panel." L4708: AC4 is verified by P2-apply among others.
+
+Code ~/.dotfiles/maint/src/maint/gui.py:
+- 1408-1438: _press_lever, where the second press calls self._fire(...) unconditionally.
+- 1474-1484: _fire, which calls doctor.iter_fix inside _stream's daemon thread (1443-1460).
+- 785, 954, 1440-1445: card keys, roster fix keys and strip keys all reach _press_lever. _fire has no other caller.
+- 94 and 1672: the existing MERGE Popen lives in gui.py and is untested.
+
+Tests: ~/.dotfiles/tests/maint/test_panel.py:3-7 says "The GTK view (maint/gui.py) is never imported here". gui appears only in that docstring.
+
+Mitigation: Phase 3 M-boot-armed says "On ratio and velox the test arms from APPLY".
+:END:
+
+** DONE R19: the tripped arm line drops the argv, so UPDATE names TOPGRADE's command
+CLOSED: [2026-10-05 Mon 12:10]
+Where (round 3): Phase 2 Guard UX L4393; Levers L4380
+
+L4393 reads: 'On a tripped read, the arm line for either key reads "<label> armed — will defer at least <guard matches> (kernels and DKMS modules are always held) — press again to run upgrade-guarded"'. UPDATE's argv is ['upgrade-guarded', '--no-topgrade'] (L4380), so UPDATE's tripped arm line names the bare 'upgrade-guarded', which is TOPGRADE's argv, and shows no argv at all.
+
+Risk: With a tripped guard read, UPDATE's act line tells the user a press runs the bare upgrade-guarded, the full sweep, when it actually runs upgrade-guarded --no-topgrade. That breaks maint's existing arm-line parity criterion on exactly the two levers this spec repoints. An implementer has to choose between the spec's literal string and the parity rule.
+
+Recommended change: At L4393, change the tail of the tripped string from "press again to run upgrade-guarded'" to "press again to run $ <argv>'", where <argv> is panel.arm_argv's string. That is 'upgrade-guarded --no-topgrade' for UPDATE and 'upgrade-guarded' for TOPGRADE, the same as the untripped line. At L4442, extend the bullet to read "=viewmodel.arm_line= with a tripped read gives the arm-line string with each label, ending '$ upgrade-guarded --no-topgrade' for UPDATE and '$ upgrade-guarded' for TOPGRADE".
+
+Optional, not blocking. Verification: confirmed.
+Disposition: accepted, folded into Implementation phases (the contract) and the sections that cite it.
+:EVIDENCE:
+- Spec L4380: "UPDATE's argv is =['upgrade-guarded', '--no-topgrade']=; TOPGRADE's is =['upgrade-guarded']=."
+- Spec L4393: "On a tripped read, the arm line for either key reads '<label> armed — will defer at least <guard matches> (kernels and DKMS modules are always held) — press again to run upgrade-guarded'."
+- Spec L4442: "=viewmodel.arm_line= with a tripped read gives the arm-line string with each label". This pins the literal string.
+- ~/.dotfiles/maint/src/maint/viewmodel.py:99-107. The docstring says "The argv is the exact dry-run string (the parity criterion)". The guarded form ends "or apply from a TTY $ {argv}" and the plain form ends "press again to run $ {argv}".
+- ~/.dotfiles/maint/src/maint/panel.py:205-207. arm_argv is "The exact command string the arm-press shows — identical to the dry-run's argv (the parity acceptance criterion)".
+- ~/.dotfiles/maint/src/maint/gui.py:1396-1433. _guard_arm_line passes argv through to viewmodel.arm_line for guarded rids, and the plain path calls arm_line(title, argv).
+- ~/.dotfiles/tests/maint/panel_smoke.py:396-398 checks "act line names the exact argv (dry-run parity)". That check covers REMOVE, not UPDATE, but it shows the convention.
+:END:
+
+** DONE R27: only the row handles a malformed record; topgrade_freshness reads the same record through the shared helper with no defined behavior
+CLOSED: [2026-10-05 Mon 12:10]
+Where (round 3): Phase 2, The deferred row L4397 (shared helper) and L4399 (malformed record reads UNPROBED, 'never crashes the envelope'); Phase 2, APPLY L4409-4411 (topgrade_age items builder and doctor.review key on evidence deferred); The record L4221 (topgrade_freshness is a reader)
+
+The R27 resolution covers a missing or malformed record only for the upgrade_deferred row. N is computed 'through a helper shared with topgrade_freshness' (L4397), and topgrade_freshness 'adds evidence {deferred: N}, computed by the shared helper' (L4410). For a malformed record, the spec doesn't say what that helper returns or what topgrade_freshness reports. A missing record is covered (L4452).
+
+Risk: An implementer has to decide how topgrade_freshness handles a record whose data lacks packages. If the shared helper raises there, build_status raises, and every metric on the board is lost, not just the deferred row. The topgrade_age card's APPLY key and REVIEW & FIX's choice between APPLY and TOPGRADE also depend on that evidence value, and nothing defines it.
+
+Recommended change: At L4397, append one sentence: "The helper returns None for a missing record or one whose data lacks the documented fields (packages a list of {name, old, new, kind}); the row maps None-from-malformed to its UNPROBED text, and =topgrade_freshness= grades unchanged with evidence ={deferred: 0}=, so the =topgrade_age= card shows no APPLY and REVIEW & FIX keeps TOPGRADE."
+
+At L4453, change the test bullet to: "a record without packages reads UNPROBED, =topgrade_age= still grades from its stamp with evidence deferred 0 and no APPLY, and build_status returns an ok envelope."
+
+Optional, not blocking. Verification: confirmed.
+Disposition: accepted, folded into Implementation phases (the contract) and the sections that cite it.
+:EVIDENCE:
+Spec: L4397 "through a helper shared with =topgrade_freshness="; L4399 the malformed-record UNPROBED rule is scoped to the row only; L4409 the items builder keys on "=topgrade_age= while its evidence deferred > 0"; L4410 "=topgrade_freshness= adds evidence ={deferred: N}=, computed by the shared helper"; L4411 doctor.review drops topgrade on evidence deferred > 0; L4452 covers only the no-record case for topgrade_age; L4453 "a record without packages reads UNPROBED" asserts the row only.
+
+Code:
+- ~/.dotfiles/maint/src/maint/status.py:41: topgrade_freshness is called bare inside the metrics list; the only try/except is at L22-24, around thresholds.load.
+- ~/.dotfiles/maint/src/maint/cache.py:35-42: get returns (entry["data"], age) with no shape validation, catching only OSError, ValueError, KeyError and TypeError on the envelope.
+- ~/.dotfiles/maint/src/maint/probes/__init__.py:5-6: "collectors never raise past their own metric".
+- ~/.dotfiles/maint/src/maint/probes/updates.py:115-133: the current topgrade_freshness has no evidence and no validation path.
+:END:
+
+** DONE R22: the snapshot hold runs after the gate line is re-emitted as the last stderr line, so a hold failure takes over the last line and detail
+CLOSED: [2026-10-05 Mon 12:10]
+Where (round 3): --complete step 5, L4173-4174; The snapshot hold L4253; The record, detail L4214; AC4 L4604; P1-complete L4353
+
+Step 5 lists 'fail: ... exit 4, with that line re-emitted as the run's last stderr line' and then 'after a --since-form gate on a ZFS root, pass or fail: the snapshot hold'. A hold or release failure 'prints snapshot hold failed: <reason> and changes neither result nor exit' (L4253), but the spec doesn't say which stream it goes to or where it falls relative to the re-emitted gate line. detail is defined as the run's last stderr line (L4214).
+
+Risk: Take a failing --since gate on a ZFS root where 'sudo -n zfs hold' also fails, for example when the NOPASSWD rule is missing. Implemented in the listed order, 'snapshot hold failed: …' (and zfs's own error) becomes the run's last stderr line. detail and the WARN fallback text then name the hold failure, not the failing gate item. That contradicts AC4 and the claim that a hold failure changes nothing but a warning.
+
+Recommended change: Three small edits:
+1. L4173: replace "with that line re-emitted as the run's last stderr line" with "detail = that line, and the finish re-emits it as the run's last stderr line, after the snapshot hold".
+2. L4253: append "to stderr, before the finish prints the run's reason line".
+3. At the end of the L4359 test bullet, add: "A fake zfs whose hold exits 1 prints 'snapshot hold failed', and result and exit are unchanged. After a failing gate the run still exits 4, with the gate item as its last stderr line and as detail."
+To cover the L4175 exit-1 path as well, add to Finish step 3 (L4200): "On a non-zero exit, print detail's last line to stderr last."
+
+Optional, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: modified, folded into Implementation phases and the sections that cite it. Partially confirmed; the recommended change reflects the narrowed finding. In the listed order, a 'snapshot hold failed' line after the re-emitted gate line becomes the last stderr line and the detail. I kept the fix with three placement changes. 'Print detail last on a non-zero exit' lives in T01's single Finish edit, so it also covers the stage-1-failed exit-1 path. The hold-failure test lives in T11's single P1-complete hold-bullet rewrite. The failure bullet here also names a snapshot failure, for T11's pre-stage-1 snapshot.
+:EVIDENCE:
+Spec docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org:
+- L4173: "fail: gate.failed = its last stderr line, result failed, failed_step gate, exit 4, with that line re-emitted as the run's last stderr line;"
+- L4174: "after a --since-form gate on a ZFS root, pass or fail: the snapshot hold;"
+- L4175: stage 1 failed and gate didn't fail → failed_step pacman, exit 1. The hold also runs on this path.
+- L4253: "A hold or release failure prints 'snapshot hold failed: <reason>' and changes neither result nor exit." No stream is given and no position relative to the reason line.
+- L4214: detail "is the run's last stderr line".
+- L4271: "On any non-zero exit, the last stderr line names the reason."
+- L4149: the closure-refusal precedent: "the refusal's own lines, which the finish prints last".
+- L4196-4200: Finish (compute, stamp, write) says nothing about printing the reason last.
+- L4353: P1-complete: a failed gate "exits 4 with that line last on stderr".
+- L4359: the hold test uses a fake zfs that only logs, with no failing-hold case.
+- L4604: AC4 requires exit 4 "with the failing item as its last stderr line and in the CRIT text".
+- grep: 'hold failed' appears only at L4253.
+No implementation exists yet: there is no upgrade-guarded or kernel-modules-check in scripts/ or tests/.
+:END:
+
+** DONE --apply-armed step 7's 'target not found' case can't be reached, because step 6's -Sp refuses it first with a different result and exit
+CLOSED: [2026-10-05 Mon 12:10]
+Where (round 3): Phase 1, --apply-armed sequence steps 6-7 (lines 4190-4191); Risks, Boot package source (line 4682)
+
+Step 6 runs pacman -Sp on the versioned list, and any failure exits 3 with result refused. Step 7 says 'A version the db no longer carries fails as target not found ... On failure: result failed, failed_step boot-transaction, exit 1.'
+
+Risk: The stale-flag-after-a-bare-refresh case in Risks ends as refused/3, not failed/1. A test written from step 7 expects the wrong result and exit code. The unit fails either way, so the impact is small.
+
+Recommended change: Line 4190: after "...exits 3: result refused, failed_step boot-transaction." insert "A version the db no longer carries (a refresh since arming that didn't re-arm) fails here as target not found."
+
+Line 4191: replace "A version the db no longer carries fails as target not found, and a missing cache file fails on download; both fail before any package changes." with "A missing cache file fails on download, before any package changes."
+
+Leave the rest of step 7 ("On failure: result failed, failed_step boot-transaction, exit 1.") unchanged. No edit is needed at line 4682.
+
+Optional, not blocking. Verification: confirmed.
+Disposition: accepted, folded into Implementation phases (the contract) and the sections that cite it.
+:EVIDENCE:
+Spec line 4190: "=LC_ALL=C pacman -Sp --print-format '%n' '<n>=<v>'...=, offline and read-only. A failure, or a kernel-set or DKMS-set member in its output, exits 3: result refused, failed_step boot-transaction."
+
+Spec line 4191: "=sudo -n pacman -S --noconfirm --needed '<n>=<v>'...= from the cache. A version the db no longer carries fails as target not found, and a missing cache file fails on download; both fail before any package changes. On failure: result failed, failed_step boot-transaction, exit 1."
+
+Spec line 4193: "There is no refresh, news capture, kernel step, gate, yay or sweep at boot, and no network dependency."
+
+Spec line 4682 (Risks, Boot package source): a bare pacman -Sy or yay between arming and reboot "breaks that; the boot run then fails before any package changes, the record names it".
+
+Ratio, read-only:
+- LC_ALL=C pacman -Sp --print-format '%n' 'zlib=0:0.0-1' prints "error: target not found: zlib=0:0.0-1" and exits 1.
+- LC_ALL=C pacman -Sp --print-format '%n %v' 'zlib=1:1.3.2-3' (the current sync version) prints "zlib 1:1.3.2-3" and exits 0.
+
+So the stale-version case fails at -Sp, and the stage-6 rule sends it to refused/3.
+
+grep shows "target not found" / "no longer carries" appears in the body only at line 4191. None of the test bullets at lines 4366-4369 asserts the step-7 outcome for a stale version.
+:END:
+
+** DONE 'The only list of root steps' and 'every root step is sudo -n' leave out yay's own sudo in step 7
+CLOSED: [2026-10-05 Mon 12:10]
+Where (round 3): Phase 1, Identity and privilege (lines 4064-4071); Everyday sequence step 7 (line 4140); Design (line 83); Readiness, Security (line 4650)
+
+Phase 1 says every root step is sudo -n, and lists them as 'the only list of root steps'. Everyday step 7 runs yay -Sua --noconfirm, which escalates its own pacman calls.
+
+Risk: The completeness claim that Readiness relies on is false. Without the NOPASSWD rule, a shell run prompts inside yay rather than failing with a reason as Design line 83 promises. A lever run fails only because there's no tty.
+
+Recommended change: This is the smallest edit. It's a wording fix, not a behavior change.
+- Line 4064: change "This is the only list of root steps:" to "This is the only list of the script's own root steps:".
+- Add one bullet after line 4070: "yay (Everyday step 7) and the topgrade sweep (step 8, through topgrade.toml's pre_sudo =sudo -v=) escalate on their own through plain sudo, not sudo -n. Both run only after steps 4 and 6 have succeeded under sudo -n, so a machine without the NOPASSWD rule fails at the refresh, with a reason, before either one runs."
+- Line 4650: change "is the only list of root steps" to "is the only list of the script's own root steps; yay and the sweep escalate through their own sudo (Phase 1, Identity and privilege)".
+- Line 83 can stay as written, because the refresh failing first keeps its promise true.
+
+Passing --sudoflags=-n to yay is a reasonable alternative. On its own it doesn't close the gap, because topgrade's pre_sudo still runs plain =sudo -v=.
+
+Optional, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: accepted, folded into Implementation phases (the contract) and the sections that cite it.
+:EVIDENCE:
+Spec lines in docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org:
+- 4064: "Every root step is =sudo -n= ... This is the only list of root steps:" The listed items cover pacman, informant, the flag writes, zfs, reboot and lsinitcpio. Neither yay nor topgrade is there.
+- 4650: "Phase 1, Identity and privilege, is the only list of root steps."
+- 4137: step 4 is "One =sudo -n pacman -Sy=. On failure: failed_step refresh, exit 1."
+- 4139: step 6 is =sudo -n pacman -Su=, and "On failure ... steps 7 and 8 are skipped."
+- 4140: step 7 is "=yay -Sua --noconfirm=, always."
+
+Commands run on ratio:
+- =yay --help | grep sudo= lists --sudo, --sudoflags and --sudoloop.
+- ~/.config/yay/ doesn't exist.
+- =yay -Pg= shows "sudobin": "sudo", "sudoflags": "", "sudoloop": false. Installed version is yay 13.0.1-1.
+- ~/.config/topgrade.toml (symlink to .dotfiles/common/.config/topgrade.toml), lines 12 and 15: =pre_sudo = true= and =sudo_command = "sudo"=.
+- =topgrade --dry-run --no-ask-retry --disable system git_repos containers= prints "―― Sudo ――" and then "Dry running: /usr/bin/sudo -v". Installed version is topgrade 17.12.2-1.
+:END:
+
+** DONE Recovery depends on held_snapshot, but nothing the user sees ever shows it
+CLOSED: [2026-10-05 Mon 12:10]
+Where (round 3): Phase 4, Recovery (line 4541); D-zbm (line 4575); Phase 1, The snapshot hold (line 4253); Phase 2, The deferred row evidence (line 4405); Readiness, Observability (line 4654)
+
+Recovery starts 'After booting the snapshot the record's held_snapshot names from ZFSBootMenu', and Design line 109 calls it 'the snapshot recovery boots'. The hold prints only on failure, and the row's evidence lists name, old, new, kind, detail and news, with no held_snapshot.
+
+Risk: At the ZFSBootMenu prompt after a failed boot, the person has to pick the right pre-pacman snapshot without having been shown its name. A newer pre-pacman snapshot holds the new, unverified kernel.
+
+Recommended change: Smallest edit, in Phase 4, Recovery (L4541): replace the opening clause with:
+
+"From ZFSBootMenu, boot the snapshot the hold pins. It is the record's held_snapshot. When the root won't boot, find it from the ZFSBootMenu recovery shell as the =<rootds>@pre-pacman_*= row with userrefs above 0 in =zfs list -H -t snapshot -o name,userrefs zroot/ROOT/default=, and confirm the tag with =zfs holds <snap>=. Never pick the newest pre-pacman snapshot, which can already hold the new kernel. Then, before the next =upgrade-guarded= run:"
+
+Optional, if a visible copy is wanted: in Phase 2, The deferred row, add held_snapshot to the evidence list (L4405), "...; held_snapshot when set; and the news lines...". The success-path print and the CRIT text change aren't needed.
+
+Optional, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: accepted, folded into Implementation phases (the contract) and the sections that cite it.
+:EVIDENCE:
+Spec lines:
+- L4253: "A hold or release failure prints 'snapshot hold failed: <reason>'" (nothing prints on success).
+- L4405: the evidence is "name, old, new and kind rows; detail in full; and the news lines", with no held_snapshot.
+- L4654: Observability lists the deferred set, result, detail, gate, armed state and news, with no snapshot.
+- L4217: held_snapshot exists only as a record field.
+- L4541: "After booting the snapshot the record's held_snapshot names from ZFSBootMenu".
+- L4688: Recovery is for an unplanned reboot while the gate is open, or a failure the gate misses (machine not booted).
+- L4575: in the drill, the record is read while the system is still up, so the drill is unaffected.
+
+Code:
+- scripts/zfs-pre-snapshot:27-28: names are pre-pacman_$(date +%Y-%m-%d_%H-%M-%S), so the name carries no marker of which snapshot is held.
+- docs/design/2026-07-15-velox-boot-failure-handoff.org:16-18: earlier velox triage was done from the ZBM recovery shell, so that shell is the realistic place where the name is needed.
+:END:
+
+** DONE After power loss during the boot transaction, the next boot surfaces nothing outside the panel
+CLOSED: [2026-10-05 Mon 12:10]
+Where (round 3): Readiness, Errors (4649); Phase 3, Disarm and failure (4496–4497); Phase 1, --apply-armed step 2 (4186)
+
+Readiness says every failure path ends at the tty1 login 'with the record naming the failure and the unit failed'.
+
+Risk: On velox with autologin, Hyprland may fail to start and drop to a tty1 shell that shows only 'Hyprland session ended', with no hint about db.lck or the remedy.
+
+Recommended change: Smallest edit, spec line 4649. Replace "Every failure path ends at the tty1 login, with the record naming the failure and the unit failed." with: "A failure, timeout or SIGKILL ends at the tty1 login in the same boot, with the record naming the failure and the unit failed. After power loss, the next boot skips the unit on its condition and no failed state survives, so the pre-written interrupted record (the deferred row, or maint status from a console) and the db.lck refusal are what remain."
+
+Optional: append to Risks 4684 after "names it with the manual remedy": "; maint's failed-units row does not show it, because the next boot skips the unit."
+
+A login-time notice or /etc/issue line is not needed. Add one only if I want a proactive tty surface.
+
+Optional, not blocking. Verification: partially confirmed (the recommended change reflects the narrowed finding).
+Disposition: accepted, folded into Implementation phases (the contract) and the sections that cite it.
+:EVIDENCE:
+- Spec 4649: "The boot unit is bounded and self-disarming ... success, failure, timeout, SIGKILL and power loss all disarm ... Every failure path ends at the tty1 login, with the record naming the failure and the unit failed."
+- Spec 4475: ConditionPathExists=/var/lib/archsetup/apply-upgrade-on-boot.
+- Spec 4484-4485: RuntimeDirectory=archsetup-boot-upgrade; ExecStartPre=+/usr/bin/mv -f <flag> /run/archsetup-boot-upgrade/armed.list. /run is tmpfs, so neither the flag nor the moved list survives power loss.
+- Spec 4497 and 4653: the failed_units promise covers "a failed or timed-out run" only.
+- ~/.dotfiles/maint/src/maint/probes/systemd.py:70-76: failed_units runs systemctl list-units --state=failed, which only sees units failed in the current boot. A unit skipped by its condition is inactive, not failed.
+- Spec 4186: the record is pre-written as interrupted / boot-transaction with the --complete remedy, so it survives power loss.
+- Spec 4089: db.lck present means exit 3 with detail 'pacman database locked (<path>): confirm no pacman is running, then remove it by hand'.
+- Spec 4684 (Risks) already accepts that "Power loss ... can still leave a half-extracted package and a stale db.lck ... every later transacting run refuses and names it with the manual remedy."
+- Spec 4517 and 4608: maint status is used as a CLI read of maint state, so the deferred row is not panel-only.
+- ~/.dotfiles/hyprland/.profile.d/99-hyprland-autostart.sh: on Hyprland exit it prints only "Hyprland session ended. Type 'start-hyprland' to restart." That part of the finding holds, but it is the generic behavior of any failed session.
+:END:
+
+** TODO Stale snapshot-hold clause in Decision 5's Consequences
+Where: round 3 residual from the final repair pass; the problem text names the passages.
+
+Gaps 3 and 4 replaced the 'only a later --complete or session moves the hold' clause at The snapshot hold (L5003), AC13 (L5390) and Design (L111). The same clause is still in the kernel Decision's Consequences at L193: 'the hold moves only when a later session holds a newer one'. That contradicts everyday step 7 (L4888), the first and last bullets of The snapshot hold (L4996, L5003), Invariants (L4954) and Readiness (L5401). The same sentence also still names the hold target as 'the oldest pre-pacman snapshot taken since a minute before the entry opened'. That is only the --since-gate fallback. Since T11, --complete step 3 (L4915) takes its own snapshot and holds it. The precedence line at L4797 makes Decisions non-normative, so no implementer is misled. That is why this is should-fix and not blocking.
+
+Recommended change: Decisions, 'Kernel set is held on every everyday run...', Consequences (L193). OLD: "On a ZFS root the session places a ZFS hold on the oldest pre-pacman snapshot taken since a minute before the entry opened, so the pre-snapshot prune can't destroy the fallback before it's needed; the hold moves only when a later session holds a newer one." NEW: "On a ZFS root, a session that opens the gate entry snapshots the root and places a ZFS hold on that snapshot before stage 1, and an everyday run whose AUR step opens the entry holds the oldest pre-pacman snapshot taken no earlier than a minute before that step, so the pre-snapshot prune can't destroy the fallback before it's needed; the hold moves only when a later session, or an everyday run whose AUR step opens the entry, holds a newer one."
+
+Should-fix, not blocking. Verified against the code in the final repair pass; left open because the review loop was bounded there.
+
+** TODO Untested everyday-run hold and release behind AC13
+Where: round 3 residual from the final repair pass; the problem text names the passages.
+
+AC13 (L5390) makes two claims about the everyday run. An everyday run whose AUR step opens the entry holds the oldest pre-pacman snapshot taken no earlier than a minute before that step. And, from the gap-3 edit, such a run 'holds a newer one and releases it'. Testing maps AC13 only to P1-complete and P1-installer (L5472), and neither group runs everyday step 7. The step-7 hold is checked only in P1-steps (L5075), which is not mapped to AC13. No case anywhere checks that step 7 releases the previous held_snapshot. So an implementation whose step 7 places the new hold but skips the release passes every case mapped to AC13. That leaves two snapshots with an upgrade-guarded hold and only one named in the record. It breaks 'exactly one extra snapshot per machine stays pinned' (L5003). Rollback step 3 (L5344) then releases only the named one, and Recovery's 'row with userrefs above 0' (L5299) finds two.
+
+Recommended change: (1) Phase 1 tests, P1-steps (L5075). OLD: "and on a ZFS root holds the oldest pre-pacman snapshot created at or after that time − 60 s and names it in held_snapshot;" NEW: "and on a ZFS root, starting from a record with gate null and held_snapshot naming an older snapshot, holds the oldest pre-pacman snapshot created at or after that time − 60 s, releases the previous held_snapshot and names the new one in held_snapshot;" (2) Testing / Verification / Rollout (L5472). OLD: "- AC13 (the held snapshot on a ZFS root): P1-complete and P1-installer." NEW: "- AC13 (the held snapshot on a ZFS root): P1-complete, P1-steps and P1-installer. P1-steps carries the everyday AUR-step hold and its release of the previous snapshot."
+
+Should-fix, not blocking. Verified against the code in the final repair pass; left open because the review loop was bounded there.
+
+** TODO First-failure precedence for failed_step and detail when several steps fail
+Where: round 3 residual from the final repair pass; the problem text names the passages.
+
+This ambiguity predates T01, but it sits in the rule T01 rewrote. Finish step 1 (L4946) takes detail from 'the failing step', in the singular. In one everyday run, three steps can each fail. Step 7 sets failed_step aur, and step 8 still runs (L4886). Step 8 sets failed_step topgrade (L4889). Step 9's arm sets failed_step download with detail 'disarmed: <reason>' (L4898). Nothing says which failed_step and detail the record keeps when two of them fail. An implementer who assigns in order keeps the last failure. Then a failed sweep overwrites step 7's '<names> moved by yay -Sua — do not reboot; run upgrade-guarded --complete' detail. It also makes AC10's 'failed_step is aur' false whenever the sweep fails too. No machine is put at risk: when step 7 opens the gate, gate.failed keeps the row CRIT and REBOOT refused.
+
+Recommended change: Finish, step 1 (L4946). After "On a non-zero exit, detail is the failing step's own reason, never the pre-written text." insert: "When more than one step fails in one run (everyday steps 7, 8 and 9 can), failed_step and detail are the first failing step's; each step's own failed_step is what the record holds when that step is the first failure."
+
+Optional, not blocking. Verified against the code in the final repair pass; left open because the review loop was bounded there.
+
+** TODO Ambiguous version rule in the power-cut recovery step
+Where: round 3 residual from the final repair pass; the problem text names the passages.
+
+Gap 6's fix landed, and the libalpm facts in it hold. In add.c commit_single_pkg, _alpm_remove_single_package(oldpkg, newpkg) runs at L485 and ends in _alpm_local_db_remove (remove.c L742). That comes before _alpm_local_db_prepare (L493) and extraction (L500 on). The 'upgraded/installed/reinstalled' logaction runs only after _alpm_local_db_write (L599-630). pacman -Dk's 'description file is missing' matches a prepared directory with no desc. local_db_populate skips such an entry as 'corrupted database entry', so the earlier reconcile -U --dbonly calls still work while the entry exists. The new version rule has two holes, though. (1) 'the one named by the last [ALPM] line' is ambiguous when that line is 'upgraded P (a -> b)' or 'downgraded P (a -> b)', since the line names two versions and the root holds the right-hand one. (2) The rule assumes an upgrade. A cut during a fresh install has no old entry: stage 1 lands every pending package outside the GPU closure, so new dependencies are realistic. Then there is no earlier line, or the last earlier line is 'removed P (x)'. Read literally, the rule then gives no version at all, or registers x, a package the booted root doesn't have. The closing pacman -Qkk over re-registered packages would then report missing files, and nothing tells the user what to do.
+
+Recommended change: Phase 4, Recovery, power-cut bullet (L5306). Old: "then =sudo pacman -U --dbonly= from the cache the version the booted root holds: the one named by the last =[ALPM]= line for that package timestamped before the snapshot's creation." New: "then =sudo pacman -U --dbonly= from the cache the version the booted root holds: the version that the last =[ALPM]= line for that package timestamped before the snapshot's creation left installed (on an upgraded or downgraded line, the one after =->=). When that line is a removed line, or there is no such line, the booted root doesn't have the package, so register nothing."
+
+Should-fix, not blocking. Verified against the code in the final repair pass; left open because the review loop was bounded there.
+
+** TODO Overbroad bare-wait qualifier in Exit codes and interrupts
+Where: round 3 residual from the final repair pass; the problem text names the passages.
+
+Gap 7's fix landed and its core claim matches the bash 5.3.20 man page: a bare wait waits for running background jobs and for the last-executed process substitution only when its PID still equals $!. The informant-clear example is also right, because --apply-armed starts the mirror at step 3 and runs the informant clear in the foreground at step 4. The qualifier 'which is true during any foreground step after the mirror starts' overclaims. $! changes as soon as the script starts another background job or process substitution. 'The trap waits on the transacting child's PID' means step 7's pacman runs as a background child, so during Finish's foreground stamp, with the trap still armed, $! is the transaction child and a bare wait returns at once. I checked this with two small test scripts under bash 5.3.20. A bare wait in the trap after a background child exited 130 without hanging. A later '< <(...)' moved $! off the mirror. A test written from 'any foreground step' would fail for any step after step 7. The rule itself is correct.
+
+Recommended change: Phase 1, Exit codes and interrupts (L5022). Old: "which is true during any foreground step after the mirror starts (the informant clear, for one)." New: "which stays true from the moment the mirror starts until the script next starts a background job or another process substitution, so during =--apply-armed='s informant clear, for one."
+
+Optional, not blocking. Verified against the code in the final repair pass; left open because the review loop was bounded there.
+
+** TODO ZFSBootMenu snapshot boot instead of a MOD+R rollback in Recovery and D-zbm
+Where: round 3 residual from the final repair pass; the problem text names the passages.
+
+The gap 8 fix landed at line 5338 and matches the harness. qemu_cmd in scripts/testing/lib/vm-utils.sh has no -no-reboot or -no-shutdown, so system_reset keeps QEMU running, and run-test.sh:139-141 does restore the clean-install snapshot. The problem is that the variant, the base drill (5336) and Recovery (5299) all say 'boot held_snapshot from ZFSBootMenu', but ZFSBootMenu can't boot a snapshot in place. In its snapshot view, ENTER duplicates the snapshot into a new boot environment (zfs send | recv), MOD+X clones and promotes, and MOD+C clones. Only MOD+R ('roll back snapshot') leaves zroot/ROOT/default as the root. Two concrete failures follow. (1) With MOD+C, the next --complete has the gate already open, so step 3 takes no snapshot, and the --since gate looks for <rootds>@pre-pacman_* (rootds from findmnt). The clone has no snapshots of its own, and zfs-pre-snapshot:11 hardcodes $POOL/ROOT/default. So every later --complete exits 4 on the snapshot item and the gate never closes, which is the mismatch-fails-closed case at line 926. ENTER and MOD+X leave the same hook/rootds split, so any entry everyday step 7 opens later fails the same way. (2) With MOD+X, the promote renames the held snapshot to <newBE>@pre-pacman_*. The variant's Expected ('zfs holds shows the upgrade-guarded tag ... and held_snapshot names it') then fails, and later releases of held_snapshot miss the real hold, so it stays pinned. Also, make test-keep starts QEMU with -display none and -serial file:, so the VM gives no keyboard input to ZFSBootMenu, and its zbm.timeout is 3 s (archangel installer:1115).
+
+Recommended change: In Phase 4, Recovery, replace "Boot the held snapshot from ZFSBootMenu: the record's held_snapshot, which the deferred row's evidence shows." with "Roll zroot/ROOT/default back to the held snapshot from ZFSBootMenu, then boot zroot/ROOT/default. The held snapshot is the record's held_snapshot, which the deferred row's evidence shows. Open the snapshot list (MOD+S), import read/write first if asked (MOD+W), select it, and press MOD+R ('roll back snapshot'). Never use ENTER (duplicate), MOD+X (clone and promote) or MOD+C (clone). Each one boots a new boot environment while zfs-pre-snapshot keeps snapshotting zroot/ROOT/default, so later gates fail closed on the snapshot item, and clone and promote also renames the held snapshot away from held_snapshot." In D-zbm line 5336, replace "Boot that snapshot from ZFSBootMenu, then run the Recovery reconcile." with "Roll back to that snapshot from ZFSBootMenu as Recovery says, sending the keys with the QEMU monitor's sendkey and watching with screendump (the VM has no display or serial input), then run the Recovery reconcile." In the variant (line 5338), replace "Boot held_snapshot from ZFSBootMenu and run Recovery." with "Roll back to held_snapshot from ZFSBootMenu the same way, and run Recovery."
+
+Should-fix, not blocking. Verified against the code in the final repair pass; left open because the review loop was bounded there.
+
+** TODO REBOOT-hidden claim in Design and Decision 5 that ignores the probe window
+Where: round 3 residual from the final repair pass; the problem text names the passages.
+
+The gap 10 fix landed in AC4 (line 5363). Its wording matches Phase 2, Freshness and REBOOT (5182-5183), and the strip REBOOT key really does reach doctor.iter_fix (gui.py _on_strip_key, then _press_lever, then _fire, then iter_fix at 1483), and P2-row covers the refusal. But the claim T14 showed false is still in two other places. Design line 105 says 'From that moment until a gate passes, the deferred row is CRIT with REBOOT hidden', with 'that moment' being the pre-stage-1 record write. Decision 'Kernel set is held...' Consequences (line 193) says 'From the start of a kernel-side stage 1 until a gate passes, the panel holds the deferred row at CRIT with REBOOT hidden'. The panel shows neither until its next probe (gui.py _FULL_SECONDS = 30). The fire-time refusal is what closes that window, and neither paragraph mentions it.
+
+Recommended change: Line 105: replace "From that moment until a gate passes, the deferred row is CRIT with REBOOT hidden, no arm flag exists, and the session never offers a reboot." with "From that moment until a gate passes, no arm flag exists and the session never offers a reboot. The panel shows the deferred row at CRIT with REBOOT hidden from its next probe, and a reboot fired before that probe refuses (Phase 2, Freshness and REBOOT)." Line 193: replace "From the start of a kernel-side stage 1 until a gate passes, the panel holds the deferred row at CRIT with REBOOT hidden, so" with "From the start of a kernel-side stage 1 until a gate passes, the panel holds the deferred row at CRIT with REBOOT hidden from its next probe, and maint's reboot remedy refuses at fire time, so".
+
+Should-fix, not blocking. Verified against the code in the final repair pass; left open because the review loop was bounded there.
+
+** TODO Reset cue and missed-extraction handling in the D-zbm power-cut variant
+Where: round 3 residual from the final repair pass; the problem text names the passages.
+
+The variant's cue to reset is when 'stage 1 prints its '(n/m) upgrading' lines'. pacman prints that form only when stdout is a tty, because it turns off the progress bar otherwise. Without one it prints 'upgrading <pkg>...' instead (pacman 7.1.0 has both format strings, '(%*zu/%*zu) %ls' and 'upgrading %s...'). A plain 'ssh host upgrade-guarded --complete' has no tty, so the cue as written never appears. The Expected also assumes a package was 'cut off mid-extraction', but a hand-sent system_reset can easily land between packages or during the PostTransaction hooks. Nothing tells the operator how to recognize that or what to do then.
+
+Recommended change: In the variant (line 5338), replace "run =upgrade-guarded --complete= over SSH. While stage 1 prints its '(n/m) upgrading' lines," with "run =upgrade-guarded --complete= over =ssh -t= (pacman prints '(n/m) upgrading <pkg>' only on a tty; without one it prints 'upgrading <pkg>...'). As the kernel headers package's upgrading line appears,". After "and run Recovery." add "If pacman.log shows the reset landed outside extraction (every target has an =[ALPM]= line, or 'running post-transaction hooks' is logged), repeat on a fresh =make test-keep FS_PROFILE=zfs= VM."
+
+Optional, not blocking. Verified against the code in the final repair pass; left open because the review loop was bounded there.
+
+** TODO yay -Pwq versus yay -Sua scope in the sudo-refusal test and Readiness
+Where: round 3 residual from the final repair pass; the problem text names the passages.
+
+The fixes for gaps 12 and 13 landed and match the code and tools: sudo 1.9.17p2's sudoers.so carries 'a password is required', the installer writes '%$username ALL=(ALL) NOPASSWD: ALL' (archsetup:1414), common/.config/topgrade.toml has pre_sudo = true and sudo_command = "sudo", and the boot form's first sudo step that isn't tolerated is step 7. The gap 11 wording is also correct: reboot_required() in maint/src/maint/probes/packages.py:131-146 is WARN only when /usr/lib/modules/$(uname -r) is gone, and the 60-depmod hook script rmdirs that tree once the package files leave. One new contradiction came in with the gap 13 test. The new P1-steps bullet asserts 'neither yay nor the sweep is invoked' when the fake sudo refuses -n at the refresh. Everyday step 2 runs =yay -Pwq= before the refresh (step 4), and another P1-steps bullet requires that it runs before the first transaction. So the fake yay is always invoked in that run, and an implementer who asserts on the fake yay's call log gets a test that can't pass. The gap 12 Readiness parenthetical says the same thing less sharply: 'before yay or the sweep runs', though yay -Pwq has already run. Identity and privilege ('yay (everyday step 7)') and Design ('escalate') are already scoped correctly.
+
+Recommended change: (1) Implementation phases > Phase 1 > Phase 1 tests > P1-steps, the fake-sudo bullet. OLD: "and as the run's last stderr line, and neither yay nor the sweep is invoked;" NEW: "and as the run's last stderr line, and neither =yay -Sua= nor the sweep is invoked (=yay -Pwq=, step 2, has already run);". (2) Readiness dimensions > Security & privacy. OLD: "(at the refresh in the everyday run and =--complete=, before yay or the sweep runs;" NEW: "(at the refresh in the everyday run and =--complete=, before =yay -Sua= or the sweep runs;".
+
+Should-fix, not blocking. Verified against the code in the final repair pass; left open because the review loop was bounded there.
+
+* Implementation phases
+
+Three ordered commit groups across two repos: archsetup Phase 1, dotfiles Phase 2, then archsetup Phase 3. Phase 1's group holds both scripts, the installer step, the thresholds TOML rewrite, post-rebuild-check's check 9, the userrefs-aware prune in =scripts/zfs-pre-snapshot=, and the =docs/workflows/system-health-check.org= edit as a doc commit after the code. Phase 4 is the dotfiles =maint/README.md= flow-and-recovery commit plus the per-machine rollout. A phase may land as several commits if each is green on its suite (=make test-unit=, or =tests/maint/=).
+
+Ordering invariants:
+- Rollout step (a) on a machine follows Phase 1's last commit.
+- No Phase 2 commit reaches a machine before step (a) there.
+- The deferred row and APPLY land in one commit.
+- The lever repoint, the deletion of doctor.py's stamp, the guard-UX and =--force= removal, and the =sysupgrade= alias land in one commit.
+
+Where Design, Decisions, Acceptance criteria, Readiness, Risks or Testing restate a phase bullet, the phase bullet is normative.
+
+Every automated test uses unittest, never pytest. archsetup's =make test-unit= globs =tests/*/test_*.py=, so the new suites need no list edit. Each phase lists its named test groups once, and Testing maps the acceptance criteria to them. Manual tests are entries in =todo.org= under Manual testing and validation, each with what it verifies, one action per step and an Expected line.
+
+** Phase 1 — The split-upgrade script (archsetup)
+
+*** Files and install
+- =scripts/upgrade-guarded= and =scripts/kernel-modules-check= install to =/usr/local/bin= through the =hyprland()= installer step that installs =hypr-live-update-guard=. Both are usable from a TTY as soon as they're installed.
+- The guard hook is =/etc/pacman.d/hooks/10-hypr-live-update-guard.hook=. The legacy unprefixed name is never read: it sorts after =60-mkinitcpio-remove=, so a blocked bare =pacman -Syu= can delete the initramfs before the guard aborts.
+
+*** Identity and privilege
+- =upgrade-guarded= runs as the invoking user, because the record and the stamp live in that user's =$HOME= and yay refuses root. EUID 0 exits 3 and leaves the record untouched.
+- Every root step the script itself runs is =sudo -n=, so a missing NOPASSWD rule (today =%cjennings NOPASSWD: ALL=) fails instead of prompting. This is the only list of the script's own root steps:
+ - pacman =-Sy=, =-Su=, =-S= and =-Sw=;
+ - =timeout 60 sudo -n informant read --all=, because =/var/lib/informant.dat= is 0664 root:informant and the user isn't in that group;
+ - the arm flag's =install=, =mv= and =rm=;
+ - =zfs snapshot=, =zfs hold= and =zfs release=;
+ - =systemctl reboot=;
+ - inside =kernel-modules-check=, =lsinitcpio <image>=, because mkinitcpio writes the images root 0600.
+- The boot unit's =ExecStartPre=+= move of the flag is the only root step outside sudo.
+- yay (everyday step 7) and the sweep (step 8, through =topgrade.toml='s =pre_sudo= =sudo -v=) escalate through their own plain sudo, not =sudo -n=. Both run only after steps 4 and 6 succeeded under =sudo -n=, so a machine without the NOPASSWD rule fails at the refresh, with a reason, before either runs.
+- Every pacman call whose output is parsed runs under =LC_ALL=C=.
+
+*** Modes and options
+- Exactly one mode per invocation: everyday (no mode flag), =--complete=, =--apply-armed= or =--dry-run=.
+- =--no-topgrade= is the only option and is valid only in everyday mode; it skips the sweep. Any other flag or combination exits 2 and writes no record.
+- everyday is the live split run. UPDATE runs =upgrade-guarded --no-topgrade=, TOPGRADE runs =upgrade-guarded=, and the =sysupgrade= alias runs =upgrade-guarded= where it is installed (Phase 2).
+- =--complete= is the dedicated session, run from any terminal or TTY, or from the detached terminal the panel's APPLY opens.
+- =--apply-armed= is the boot form. Only the boot unit runs it.
+- =--dry-run= prints the pending set, the held set with kinds, the GPU closure, the predicted deferred set and every argv.
+ - It writes nothing (no record, stamp or flag), uses no sudo and takes no lock.
+ - It creates a private =CHECKUPDATES_DB= with =mktemp -d= (removed on exit), runs =checkupdates --nocolor= against it (exit 2 means empty), and passes that path as every closure probe's =--dbpath=. =--nocolor= is required: without it, checkupdates passes =--color always= to its =pacman -Qu= whenever its stderr is a tty and pacman.conf sets =Color= (archsetup enables it), so a preview run from a terminal would read ANSI escapes into every package name. It never uses the shared =${TMPDIR:-/tmp}/checkup-db-${UID}=.
+
+*** Preconditions
+Checked in this order, before anything else runs. =--dry-run= writes no record on any of them.
+- Usage, per Modes and options: exit 2, no record write.
+- EUID 0: exit 3, record untouched.
+- Lock (every mode but =--dry-run=): an exclusive, non-blocking flock on =upgrade-guarded.lock= beside the record, creating the directory and the file if missing, through a descriptor held until the process exits (sh: =exec 9>>"$lock"; flock -n 9=). If it is held: 'another upgrade-guarded run is in progress', exit 3, record untouched.
+- =${UPGRADE_GUARDED_DB_LCK:-/var/lib/pacman/db.lck}= present (everyday, =--complete= and =--apply-armed=): exit 3, result refused, failed_step precondition, detail 'pacman database locked (<path>): confirm no pacman is running, then remove it by hand'. The script never deletes it.
+- The hook (seam =UPGRADE_GUARDED_HOOK=) unreadable or with no =Target= lines (every mode but =--apply-armed=): exit 3, result refused, failed_step precondition, the path named. It never proceeds with an empty pattern list, which is deliberately unlike maint's =guard.trips=.
+
+*** Sets
+- Pending set: the rows of =LC_ALL=C pacman -Qu= (=name old -> new=) against the system sync db, minus rows marked =[ignored]= (pacman.conf IgnorePkg, such as bridge-utils on ratio). Under =--dry-run= it is instead the lines =checkupdates --nocolor= prints against the run's private =CHECKUPDATES_DB= (Modes and options), which already drop =[ignored]= rows; exit 2 means empty, and any other non-zero exit ends the preview with exit 1 and writes nothing.
+ - For =pacman -Qu= (every mode but =--dry-run=), exit 1 with empty stdout and no =error:= line on stderr means empty. Warnings are tolerated. checkupdates' exit 1 (such as '==> ERROR: Cannot fetch updates') is never read as empty.
+ - Any other non-zero =pacman -Qu= exit is a failure. Before the transaction: failed_step refresh, exit 1, no transaction. After it: failed_step pacman, exit 1, packages = the pre-transaction pending set ∩ what the run held, and no stamp.
+- GPU patterns: the hook's =Target= lines verbatim, 15 today. Globs such as =mesa-*= stay globs: pacman accepts globs in =--ignore=, a pattern with nothing pending is a no-op, and =-Su= never reinstalls at the same version, so no version lookup is needed.
+- Kernel set: for each existing =${KMC_MODULES_DIR:-/usr/lib/modules}/<kver>/vmlinuz= (the glob expands with nullglob), its owner from its own =pacman -Qqo=, plus =<pkgbase>-headers= where installed, with pkgbase read from =<kver>/pkgbase=.
+ - A path no package owns is left out, and no match is an empty set.
+ - =linux-firmware*= and =linux-api-headers= are never members.
+ - The set is held whole by name, so a kernel and its headers move together. Only pending members move, so a foreign kernel (ratio's =linux-lts-strix=, a local build with no headers and no zfs module, the GRUB default) is never touched.
+ - Ratio has three kernels today (linux and linux-lts with their headers, plus linux-lts-strix); velox has linux-lts and its headers.
+- DKMS set: the owners of each existing =${UPGRADE_GUARDED_SRC_DIR:-/usr/src}/*/dkms.conf= (nullglob, one =pacman -Qqo= per path). No match is an empty set, not a failure. Today it is =zfs-dkms=, which comes from the archzfs repo on ratio. Its exact pin (=zfs-utils=2.4.4=) isn't listed by name: =zfs-utils= joins only through the closure, exactly when an upstream bump breaks the pin and never for a pkgrel-only bump.
+- live: =pgrep -x Hyprland= succeeds, the guard's own test (seam =UPGRADE_GUARDED_HYPR_RUNNING=1|0=). A console reached with Ctrl+Alt+F2 while Hyprland runs on tty1 is live.
+- Kinds: every held entry is kind kernel or kind gpu. kernel is the stage-A closure result (the kernel set, the DKMS set and what the closure adds to them). gpu is the pending GPU-pattern matches plus the stage-B additions, and under =--complete= the GPU closure.
+- Held set (everyday): the =--ignore= list. It always holds the stage-A result. Only when live does it add the GPU patterns, verbatim, and the stage-B result. Each entry is its own =--ignore=<entry>= argv element.
+- GPU closure (=--complete=): the closure seeded with the GPU patterns, whether or not live. If it contains a kernel-set or DKMS-set member, the run refuses (exit 3, result refused, failed_step closure, no transaction), because landing that member would put a kernel behind the boot form or ahead of the gate.
+- Deferred set, which is exactly the record's packages and covers the whole closure, not just the pattern matches:
+ - everyday: the post-transaction pending set ∩ the held set;
+ - =--complete=: the post-transaction pending set ∩ the GPU closure (kind gpu), plus the kernel-set members, DKMS-set members and previous kind-kernel entries still pending after the transaction (kind kernel). The kernel part is empty after a stage 1 that landed it;
+ - =--apply-armed=: the previous packages minus the entries installed at or above their =new= (=pacman -Q= plus =vercmp=);
+ - every refusal, and any run that ends before its first transaction (a refresh failure included), keeps the previous packages;
+ - 'nothing to complete' writes it empty;
+ - an IgnorePkg row never appears in it, because the pending set drops =[ignored]= rows.
+
+*** Dependency closure
+- Under =--noconfirm= one unresolvable dependent fails the whole =-Su= (pacman's skip prompt defaults to no), so the script has pacman compute what else has to wait before it transacts.
+- Probe: =LC_ALL=C pacman -Sup --noconfirm --print-format '%n' --ignore=<entry>...=. It is read-only, unprivileged, lock-free and download-free, and runs against the run's one refreshed db (=--dry-run= passes its private db as =--dbpath=).
+- Seeds:
+ - everyday stage A: the kernel set ∪ the DKMS set;
+ - everyday stage B, only when live: the stage-A result ∪ the GPU patterns;
+ - =--complete=: the GPU patterns.
+- Loop: run the probe with the seed plus every name added so far.
+ - Exit 0: stdout is the target list, and the loop ends.
+ - Non-zero exit: stderr must be exactly the one line 'error: failed to prepare transaction (could not satisfy dependencies)'. Each stdout line, with an optional leading =::= and its space stripped, is classified:
+ - "unable to satisfy dependency '<d>' required by <X>": add X if X is pending. Otherwise X is a dependency the transaction would newly install, and the line adds nothing, because the pending package that pulls X in is named on its own line in the same output;
+ - "installing <X> (<v>) breaks dependency '<d>' required by <Y>": add X if Y is held, else refuse. An unheld Y is installed and not pending, so it is an orphan, foreign or AUR package (the 2026-08-25 =qemu-block-gluster= case), and holding X would hold it and its dependents indefinitely instead of naming Y.
+ - There is no ignore list for warning or skip-prompt lines, because pacman suppresses them in print mode.
+- Refuse on another header reason, any other stderr line, a stdout line matching neither form (a conflict, a missing target, a replace), an iteration that adds no new name, or more than |pending|+1 iterations.
+- A refusal exits 3: result refused, failed_step closure, no transaction, never =-Rdd= and never force. detail holds pacman's stdout lines, then one line naming the refused packages. The db stays refreshed and nothing is upgraded.
+- The transaction that follows is =sudo -n pacman -Su --noconfirm --ignore=<entry>...= against the same db, with no second =-y=.
+
+*** Everyday sequence
+1. Preconditions.
+2. News: =yay -Pwq=, before the first transaction, because yay judges news against installed build dates. It needs no informant. Failure is tolerated.
+3. Informant clear: when =${UPGRADE_GUARDED_INFORMANT-informant}= is non-empty and found, =timeout 60 sudo -n informant read --all=. It needs =--all= because a bare read prompts between items, which crashes on null stdin or blocks on a tty, and the timeout because informant 0.6.0's feed fetch has none of its own. Failure, the timeout included, is tolerated and logged.
+ - A failed or timed-out clear leaves informant's =00-informant= hook live for this run's transaction. The transaction can then wait on the same unbounded fetch (on a lever run, ended at the runner's 3600 s timeout by a SIGINT to the run's process group (Phase 2, Levers), which the trap records as interrupted; a terminal run, APPLY's =--complete= included, is bounded only by Ctrl+C, which the trap records the same way) or abort on unread news before any package changes (failed_step pacman). This is the named residual.
+4. One =sudo -n pacman -Sy=. On failure: failed_step refresh, exit 1.
+5. The sets, closure stage A and, when live, stage B, then the held set. A refusal skips steps 6 to 8 and exits 3 after steps 9 and 10.
+6. =sudo -n pacman -Su --noconfirm --ignore=<entry>...=, with no =-y=. On failure: failed_step pacman, and steps 7 and 8 are skipped.
+7. =yay -Sua --noconfirm=, always. On failure: failed_step aur, and step 8 still runs.
+ - yay still resolves repo dependencies normally. An AUR upgrade that needs a newer held GPU/compositor package while Hyprland is live makes yay's own pacman call trip the guard, so the step fails.
+ - One that needs a newer kernel-set or DKMS-set package installs it ungated. So before yay the run records =pacman -Q= of the kernel-set and DKMS-set members, the kvers with an =installed= line in =${KMC_DKMS-dkms} status=, and T7 = now, and after yay, whatever its exit, it recomputes the kernel set and compares. If a member's version changed or a new kernel-set member appeared, it opens gate as =--complete= step 3 does: {pkgbases: the previous gate's pkgbases ∪ the pkgbases of kernels whose package or headers changed ∪ (on a DKMS-set change) the pkgbase of every kver that had an =installed= line before yay, since: the previous since or T7, failed: 'kernel-side package moved by the AUR step'}. The step then counts as failed: failed_step aur, detail '<names> moved by yay -Sua — do not reboot; run upgrade-guarded --complete', exit 1. When this opens the entry (the previous gate was null) on a ZFS root, it also runs the hold steps of The snapshot hold on the oldest =<rootds>@pre-pacman_*= with creation ≥ T7 − 60 s, which predates yay's transaction; a failure only prints 'snapshot hold failed: <reason>'. Before step 8 the run removes any flag with =sudo -n rm -f=, printing 'disarmed: gate open', then writes the record at once, as =--complete='s pre-write does, with packages carried forward and that gate, result failed, failed_step aur and that detail. So an interrupt, a kill or a power cut during step 8 leaves the entry on disk with its remedy, and no flag beside it. Step 8 still runs.
+8. The sweep, unless =--no-topgrade=: =/usr/bin/topgrade --no-ask-retry --disable system git_repos containers -y= (seam =UPGRADE_GUARDED_TOPGRADE=), each its own argv element. On failure: failed_step topgrade.
+ - It is called by absolute path, so the stamping =~/.local/bin/topgrade= wrapper is never in the chain.
+ - topgrade 17.12.2's clap parser rejects the comma-joined list with exit 2, and =-y= stays last because it takes optional step values.
+ - =--no-ask-retry= keeps a failed step from waiting on stdin whatever =topgrade.toml= says, because the panel's runner has no tty. containers is off because its step fails on locally built images.
+9. While the flag exists and step 4 succeeded, the first matching case applies:
+ - gate is non-null (step 7 opened it): =sudo -n rm -f= the flag and print 'disarmed: gate open';
+ - the unit isn't enabled (=systemctl is-enabled --quiet archsetup-boot-upgrade.service= fails; seam =UPGRADE_GUARDED_UNIT_ENABLED=): =sudo -n rm -f= the flag and print 'disarmed: boot unit not enabled'. It sets no failed_step and leaves the exit unaffected;
+ - the closure refused: remove the flag and print 'disarmed: closure refused' ahead of the refusal's own lines, which the finish prints last, so the disarm line stays out of detail. The run exits 3;
+ - no GPU-kind entries remain: remove the flag;
+ - otherwise: run the arm procedure on the current GPU-kind entries, so the flag's versions follow the db this run synced. On failure: remove the flag, failed_step download, detail 'disarmed: <reason>', exit 1.
+10. Finish. Exit 3 on a refusal; otherwise 0 iff every step that ran exited 0. A deferral alone never makes the exit non-zero.
+
+*** =--complete= sequence
+1. Everyday steps 1 to 3.
+2. Refresh, then clear the flag:
+ - =sudo -n pacman -Sy=. On failure: failed_step refresh, exit 1, and the flag is kept;
+ - then =sudo -n rm -f= the flag. The arm in step 6 is the only later writer;
+ - if the removal fails: result failed, failed_step precondition, detail 'cannot remove arm flag: <reason>', exit 1, before the pre-write and stage 1, so no gate entry opens while a flag exists;
+ - compute the sets and the GPU closure. A refusal exits 3;
+ - if nothing is pending and gate is null: print 'nothing to complete', write packages empty, exit 0, no stamp.
+3. Pre-stage-1 state:
+ - record =pacman -Q= of the kernel-set and DKMS-set members, and T0 = now in epoch seconds;
+ - when a kernel-set or DKMS-set member is pending, or gate is non-null, pre-write the record with packages carried forward:
+ - gate = {pkgbases: the previous gate's pkgbases ∪ the pkgbases of kernels whose package or =<pkgbase>-headers= is pending ∪ (when a DKMS-set member is pending) the pkgbase of every kver with an =installed= line in =${KMC_DKMS-dkms} status=, since: the previous since or T0, failed: 'kernel transaction not finished'};
+ - result interrupted, failed_step pacman;
+ - detail '--complete interrupted during the kernel transaction — do not reboot; run upgrade-guarded --complete'.
+ - after that pre-write, on a ZFS root and only when the previous gate was null (this run opens the entry): =sudo -n zfs snapshot <rootds>@pre-pacman_<YYYY-MM-DD_HH-MM-SS>= (=zfs-pre-snapshot='s name form, so its prune reclaims the snapshot once released), then the hold steps of The snapshot hold on it, then write the record again. A snapshot or hold failure prints 'snapshot hold failed: <reason>' and stage 1 still runs. When the gate was already open, held_snapshot is kept.
+4. Stage 1: =sudo -n pacman -Su --noconfirm --ignore=<GPU closure entry>...=. It lands the kernel set, the DKMS set and every other pending package outside the GPU closure. Old modules are removed PreTransaction and rebuilt PostTransaction, so a failed DKMS build aborts nothing; the gate is what catches it. When the previous gate was non-null, stage 1 also names as targets, after the =--ignore= list and without =--needed=, each recorded pkgbase that =pacman -Q= finds, its =<pkgbase>-headers= where installed, and every DKMS-set member. Their same-version reinstall fires =90-mkinitcpio-install= (which restores the preset and =/boot/vmlinuz-<pkgbase>=) and the DKMS install hook again, so a stage 1 cut off after the kernel committed, which skipped every PostTransaction hook, can still pass a later gate.
+5. Gate, whatever stage 1's exit, when gate is non-null:
+ - drop each recorded pkgbase for which =LC_ALL=C pacman -Q <pkgbase>= fails with 'was not found'. If none remain, gate = null and the gate is not invoked;
+ - otherwise invoke =kernel-modules-check= once over the remaining pkgbases: in the structural form (no =--since=) only when this run opened the entry (the previous gate was null) and stage 1 changed no kernel-set or DKMS-set version against step 3's =pacman -Q=; otherwise with =--since <gate.since>=;
+ - pass: gate = null;
+ - fail: gate.failed and detail = its last stderr line, result failed, failed_step gate, exit 4; the finish prints detail last, after the snapshot hold;
+ - after a =--since=-form gate on a ZFS root, pass or fail: the snapshot hold;
+ - if stage 1 failed and the gate didn't fail (it passed, every pkgbase dropped out, or gate was null): failed_step pacman, exit 1.
+6. GPU step, only when stage 1 succeeded and gate is null:
+ - nothing GPU-side pending: nothing;
+ - not live: stage 2, =sudo -n pacman -Su --noconfirm= on the same db with no ignore list, which lands exactly the remaining closure. On failure: failed_step pacman, exit 1;
+ - live and the unit enabled (the test and seam of everyday step 9): the arm procedure. On failure: failed_step download, exit 1;
+ - live and the unit not enabled: print 'boot unit not enabled — apply from a console with Hyprland stopped: upgrade-guarded --complete'. The GPU-kind entries stay in the record. This covers the time before rollout step (c), and a rollback that removes the unit.
+7. Finish, which writes the record. Then, only when the exit is 0, stdin is a tty (the APPLY terminal is one), and the run armed or its gate passed in the =--since= form, prompt 'Reboot now? [y/N]' before exiting. The default is no; yes runs =sudo -n systemctl reboot=. INT, TERM, HUP or EOF at the prompt count as no: the record isn't rewritten and the exit stays 0.
+
+*** =--apply-armed= sequence
+1. Preconditions (there is no hook check), then read =${UPGRADE_GUARDED_ARMED_LIST:-/run/archsetup-boot-upgrade/armed.list}=, the copy the unit's =ExecStartPre= moved off the flag path. The boot form never touches the flag path.
+ - A missing, empty or unparseable list, or a live Hyprland: exit 3, result refused, failed_step precondition, with the remedy.
+2. Pre-write the record: result interrupted, failed_step boot-transaction, detail 'boot upgrade interrupted — finish from a console with Hyprland stopped: upgrade-guarded --complete', everything else carried forward. It is written before the transaction, so SIGKILL and power loss leave it too.
+3. The first tty1 line is 'archsetup: applying N deferred GPU/compositor upgrades — do not power off', N being the list's entry count. All output is mirrored to the journal with =systemd-cat -t archsetup-boot-upgrade=, through a reader that ignores SIGINT (Exit codes and interrupts), for example =exec > >(tee >(systemd-cat -t archsetup-boot-upgrade)) 2>&1=.
+4. Informant clear, as everyday step 3. Offline, the clear and the hook both fail fast and see an empty feed, so neither blocks. A network that comes up between the clear and the transaction can let the hook abort it: failed_step boot-transaction, with the arm already consumed.
+5. Drop the entries installed at or above their recorded version (=vercmp=), so the boot form never downgrades; an entry installed below it, or not installed, stays. If none are left, the run is a no-op and goes to step 8.
+6. =LC_ALL=C pacman -Sp --print-format '%n' '<n>=<v>'...=, offline and read-only. A failure, or a kernel-set or DKMS-set member in its output, exits 3: result refused, failed_step boot-transaction. A version the db no longer carries (a refresh since arming that didn't re-arm) fails here as target not found. =pacman -S= resolves dependencies from the sync db, so this check is what keeps a kernel from riding in ungated.
+7. =sudo -n pacman -S --noconfirm --needed '<n>=<v>'...= from the cache. A missing cache file fails on download, before any package changes. On failure: result failed, failed_step boot-transaction, exit 1.
+8. Finish, exiting with the transaction's status (0 on a no-op).
+
+There is no refresh, news capture, kernel step, gate, yay or sweep at boot, and no network dependency.
+
+*** Finish
+Every mode but =--dry-run= ends here:
+1. Compute the record. result follows the exit: ok on 0, failed on 1 or 4, refused on 3. On exit 0, failed_step is null and detail is empty. On a non-zero exit, detail is the failing step's own reason, never the pre-written text. Where the step itself specifies a detail (Preconditions, Dependency closure, everyday steps 7 and 9, =--complete= steps 2 and 5), detail is that text. For any other failed pacman call, detail is its last =error:= line, or its last stderr line when it printed none, as when =sudo -n= refuses for want of the NOPASSWD rule and prints only its own message, which starts 'sudo:'. Any other failure gets one line naming the reason, such as the kernel-side member =--apply-armed= step 6 refused. These replace whatever the =--complete= or =--apply-armed= pre-write left, detail included. Finish never writes interrupted and never keeps the pre-written detail; only the interrupt trap does.
+2. If the stamp predicate holds, stamp. A missing maint or a failed stamp prints 'stamp failed: <reason>', sets result failed, failed_step stamp and detail 'stamp failed: <reason>' in the computed record, leaves packages, gate, news and held_snapshot unchanged, and makes the exit 1.
+3. Write the record once. On a non-zero exit, print detail to stderr last, so its last line is the run's last stderr line. From this write on, the interrupt trap writes nothing. Then exit, except that =--complete= first runs its step 7 prompt.
+
+*** Invariants
+- No arm flag exists while gate is non-null.
+- No reboot is offered while gate is non-null: the panel hides REBOOT once it has probed, the reboot remedy refuses at fire time (Phase 2, Freshness and REBOOT), and the prompt needs exit 0.
+- The gate entry opens before =--complete='s stage 1, or after an everyday AUR step that moved a kernel-side package (everyday step 7), and closes only when a gate passes or every recorded pkgbase drops out.
+- Only =--complete= and everyday step 7 write gate or move held_snapshot, and only =--complete= closes gate. Otherwise every mode carries gate and held_snapshot forward unchanged.
+
+*** The record
+- =${MAINT_STATE_DIR:-$HOME/.local/state/maint}/upgrade_deferred.json= (maint cache key =upgrade_deferred=), in maint cache.put's ={written_at: <epoch float>, data: {...}}= envelope, written atomically (=upgrade_deferred.json.tmp.<pid>=, then rename).
+- =data= fields:
+ - packages: =[{name, old, new, kind: gpu|kernel}]=, the deferred set;
+ - result: ok, failed, refused or interrupted;
+ - failed_step: null, precondition, refresh, closure, pacman, aur, topgrade, gate, download, boot-transaction or stamp;
+ - detail: empty on exit 0; otherwise a string whose last line names the reason and is the run's last stderr line. A closure refusal puts pacman's stdout lines first, then one line naming the refused packages;
+ - gate: null, or ={pkgbases: [...], since: <epoch>, failed: <item>}=. 'Gate open' means gate is non-null;
+ - news: up to 10 verbatim 'YYYY-MM-DD <title>' lines from =yay -Pwq= (which prints them oldest first), merged with the previous record's, deduplicated on the whole line, sorted, the latest 10 kept. Empty output with exit 0 means no new news;
+ - held_snapshot: null, or the name of the snapshot the hold protects.
+- There are no mode, gate.ok, gate.at, armed or boot fields. Armed means the flag exists, and a boot run's outcome goes in result.
+- failed_step precondition covers a missing or Target-less hook, db.lck, =--apply-armed='s list and Hyprland refusals, =--complete='s failed arm-flag removal, and an interruption during news capture or the informant clear.
+- Written by every mode's finish except =--dry-run= (refusals, failures and interruptions included), the =--complete= pre-write, the =--apply-armed= pre-write, everyday step 7's gate write and the interrupt trap. Never written by a usage error, EUID 0 or a held lock.
+- Writer: =upgrade-guarded= only. Readers: maint's =upgrade_deferred= probe, =topgrade_freshness= (its deferred count) and =upgrade-guarded= itself.
+- Fixtures: the canonical copy is archsetup =tests/upgrade-guarded/fixtures/upgrade_deferred.json=, equal to a fixed fake everyday run's record except written_at. A byte-identical copy sits at dotfiles =tests/maint/fixtures/upgrade_deferred.json=, and its test module's docstring names the archsetup path as its source. A field change updates both in one rollout.
+
+*** Stamp predicate
+- The stamp is =topgrade_run=, written through =${UPGRADE_GUARDED_MAINT:-$HOME/.local/bin/maint} stamp topgrade=, by absolute path because a system unit's PATH is =/usr/local/bin:/usr/bin=. It is never wrapped in =command -v ... || true=.
+- No form stamps while gate is non-null at finish.
+- everyday: =--no-topgrade= wasn't given; the refresh, =-Su=, yay and the sweep all exited 0; and packages is empty. UPDATE therefore never stamps.
+- =--complete=: it got past the nothing-to-complete check, started with non-empty packages or a non-null gate, every step it ran succeeded, and it finishes with packages empty and gate null. An arming run and the nothing-to-complete exit never stamp.
+- =--apply-armed=: the transaction exited 0 or was a no-op, and packages ends empty.
+- =--dry-run=: never.
+- On the driven path =upgrade-guarded= is the only writer. The =~/.local/bin/topgrade= PATH wrapper stamps bare-shell topgrade runs, and Phase 2 deletes doctor.py's lever stamp.
+
+*** The gate
+- Usage: =kernel-modules-check [--since <epoch>] <pkgbase>...=. At least one pkgbase is required; none, or an unknown option, exits 2.
+- It is a stateless reader: it writes nothing and makes no pacman call. Of the scripts, only =--complete= calls it, never with an empty list. Phase 4 also runs it by hand (rollout step (a) and CRIT-row diagnosis); a by-hand run writes nothing and never clears gate. The only supported re-check, and the only thing that clears gate, is =upgrade-guarded --complete=.
+- Per pkgbase it requires:
+ - at least one kver whose =${KMC_MODULES_DIR:-/usr/lib/modules}/<kver>/pkgbase= names it and whose =vmlinuz= exists, else '<pkgbase>: no module tree';
+ - =${KMC_BOOT_DIR:-/boot}/vmlinuz-<pkgbase>= exists;
+ - =initramfs-<pkgbase>.img= exists there and is newer than =vmlinuz-<pkgbase>= and, with =--since=, than since. It is never compared with the module-tree =vmlinuz=, whose mtime is the build date;
+ - for each such kver, =${KMC_DKMS-dkms} status -k <kver>= exits 0 and every line reads =installed=. When =KMC_DKMS= is empty or dkms isn't found (a machine with no DKMS module), the item passes;
+ - on a ZFS root (=findmnt -no FSTYPE /= is =zfs=): =sudo -n lsinitcpio <image>= exits 0, else '<pkgbase>: cannot read <image>', never reported as a missing module; and its listing has =zfs.ko= with any compression suffix. mkinitcpio writes an image without it and only warns, and that image is the velox failure.
+- With =--since= on a ZFS root, once per invocation: a =<rootds>@pre-pacman_*= snapshot (rootds is =findmnt -no SOURCE /=) with creation ≥ since − 60 s, read from =zfs list -H -p -t snapshot -o name,creation=. The 60 s matches the snapshot hook's skip window. On failure the item names the dataset.
+- Without =--since=, the newer-than-since and snapshot items are skipped.
+- Exit codes: 0 pass; 1 fail, with the last stderr line '<pkgbase>: <item>' or 'snapshot: <item>'; 2 usage.
+- A kver whose dkms status shows a module only 'added' (ratio's =linux-lts-strix=) never enters gate.pkgbases through the DKMS clause, and a foreign kernel is never pending, so the gate never checks one.
+
+*** The snapshot hold
+- =--complete= step 3 holds its own pre-stage-1 snapshot when it opens the entry, and everyday step 7 holds its pre-yay one when it opens the entry. After a =--since=-form gate on a ZFS root, pass or fail, and only when held_snapshot is null, no longer exists, or was created before gate.since − 60 s, the target is the oldest =<rootds>@pre-pacman_*= with creation ≥ gate.since − 60 s.
+- If the target exists and differs from held_snapshot:
+ 1. =sudo -n zfs hold upgrade-guarded <target>=;
+ 2. =sudo -n zfs release upgrade-guarded <held_snapshot>=, tolerating a missing snapshot;
+ 3. held_snapshot = target.
+- A snapshot, hold or release failure prints 'snapshot hold failed: <reason>' to stderr and changes neither result, exit nor detail; the finish's reason line still comes after it (Finish).
+- =scripts/zfs-pre-snapshot= lists =-o name,userrefs= and prunes only rows with userrefs 0, so a held snapshot is never destroyed and doesn't count toward KEEP.
+- I hold the snapshot because about ten later transactions would otherwise prune the only one with the old kernel, initramfs and modules before the gated kernel first boots. I take and hold the snapshot before stage 1, so a kill or a power cut mid-transaction already leaves the right snapshot pinned and named. The hold moves only when a later run holds a newer snapshot by the rules above (=--complete= step 3 or everyday step 7 opening an entry, or a =--since= gate that finds no valid hold), and each move releases the previous one, so exactly one extra snapshot per machine stays pinned. The structural form places no hold: it has no since to pick a snapshot by, and runs only when no kernel-side version changed.
+
+*** The arm flag
+- =/var/lib/archsetup/apply-upgrade-on-boot= (seam =UPGRADE_GUARDED_ARM_FLAG=), root:root 0644, on a persistent path so it survives the reboot the guard's =/run= sentinel cannot. The directory exists today, and Phase 3's installer step also creates it.
+- One =<name>=<version>= line per GPU-kind upgrade target, the version as pacman prints it, epoch included (=mesa=1:26.2.4-1=). Lines starting with =#= are ignored. Only upgrade targets are listed; new dependencies come from the cache at boot, so they keep their dependency install reason.
+- Arm procedure:
+ - (a) =LC_ALL=C pacman -Sp --print-format '%n' <names>= succeeds and names no kernel-set or DKMS-set member;
+ - (b) =sudo -n pacman -Sw --noconfirm <names>= while the network is up, which downloads the dependencies too. A download-only run returns before PreTransaction hooks, so neither the guard nor informant's hook fires under a live Hyprland;
+ - (c) a user temp file, then =sudo -n install -m 0644 -o root -g root <tmp> <flag>.new=, then =sudo -n mv -f <flag>.new <flag>=.
+- Writers:
+ - =--complete= removes it once, right after its refresh, and only its GPU step re-arms;
+ - everyday step 9 rewrites or removes it;
+ - the unit's =ExecStartPre= consumes it.
+- There is no =--disarm=; re-arming means running =--complete= again. The panel reads the flag's presence and entries only.
+
+*** Exit codes and interrupts
+- Exit codes: 0 ok; 1 a step failed; 2 usage; 3 refused before any transaction; 4 gate failed; 130 interrupted. On any non-zero exit, the last stderr line names the reason.
+- Every mode traps INT, TERM and HUP until Finish writes the record; a signal after that write never rewrites it (=--complete= step 7 covers the reboot prompt). The trap waits for the running child (systemd SIGKILLs whatever is left after =TimeoutStopSec=), writes result interrupted with the step (precondition during news capture or the informant clear) and the remedy (the pre-written detail where one exists), keeps gate as written, and exits 130. An everyday run interrupted after yay started and before step 7's record write first runs step 7's comparison. On a kernel-side move it also runs step 7's snapshot hold (when this opens the entry) and its =sudo -n rm -f= of any flag. The record the trap writes therefore holds the entry, names the held pre-yay snapshot, and has no flag beside it. Under =--dry-run= it writes nothing.
+- =db.lck= is never deleted.
+- No process that carries pacman's output (a journal mirror, a tee, a capture of stderr) may die of SIGINT. A SIGINT sent to the whole group (Ctrl+C in a terminal, the lever runner's timeout, the boot unit's KillMode=control-group) would otherwise leave pacman writing to a closed pipe, and pacman dies of SIGPIPE mid-package. Such a reader starts asynchronously with SIGINT ignored (in bash, a process substitution, which starts that way), never as a foreground pipeline element. The trap waits on the transacting child's PID, never with a bare =wait=: in bash a bare =wait= also waits for the last process substitution whenever its PID is still =$!=, which is true during any foreground step after the mirror starts (the informant clear, for one). The mirror can't exit while the script holds its write end, so the trap would never return.
+
+*** Other Phase 1 changes
+- Rewrite =[updates] guard_patterns= in =configs/maintenance-thresholds.toml= to exactly the hook heredoc's 15 Target patterns; the hook is the owner and stays unchanged. That drops =hyprland-*=, =hyprlang=, =hyprcursor=, =*wayland*=, =wlroots*=, =lib32-mesa*=, =lib32-vulkan-radeon= and =lib32-vulkan-intel=, and adds =wayland=, =libdrm=, =libglvnd=, =vulkan-mesa-layers=, =nvidia-utils=, =lib32-nvidia-utils= and =xorg-xwayland=. The comment's press-again text becomes 'display mirror of the hook's Target list, pinned by a test'.
+- =scripts/post-rebuild-check= gains check 9: where the guard is installed (=PRC_GUARD_BIN= overrides =/usr/local/bin/hypr-live-update-guard=, and set but empty means not installed), =10-hypr-live-update-guard.hook= exists in the hooks directory (=PRC_PACMAN_HOOKS_DIR= overrides =/etc/pacman.d/hooks=) and the unprefixed =hypr-live-update-guard.hook= doesn't; elsewhere the check is skipped. The header, the usage text and the 'check N/8' counters become 9.
+- =scripts/zfs-pre-snapshot='s prune becomes userrefs-aware, per The snapshot hold.
+
+*** The system-health-check edit
+A doc commit after Phase 1's code, to Phase 3 of =docs/workflows/system-health-check.org=:
+- Step 6 runs =upgrade-guarded= instead of plain topgrade on a machine where rollout step (a) is done.
+- A pending kernel or DKMS package lands through =upgrade-guarded --complete= as the dedicated session.
+- Until rollout step (c), a pending GPU/compositor set finishes from a console with Hyprland stopped.
+- Where step (a) isn't done and a kernel or DKMS package is pending, don't run plain topgrade.
+- Step 5's ratio strix addendum keeps its triggers and runs before whichever =upgrade-guarded= invocation lands each one: before =--complete= for linux, linux-lts or a held mesa, and before step 6's everyday run for linux-firmware, which is never held.
+- The "maint stamp topgrade by hand" fallback becomes 'the script stamps; never hand-stamp while a deferred set is outstanding'.
+- The 2026-09-12 containers KIL entry gets a resolution note appended rather than a rewrite.
+
+*** Seams and fakes
+- =upgrade-guarded= seams: =UPGRADE_GUARDED_HOOK=, =UPGRADE_GUARDED_HYPR_RUNNING=, =UPGRADE_GUARDED_ARM_FLAG=, =UPGRADE_GUARDED_ARMED_LIST=, =UPGRADE_GUARDED_TOPGRADE=, =UPGRADE_GUARDED_MAINT=, =UPGRADE_GUARDED_UNIT_ENABLED=, =UPGRADE_GUARDED_SRC_DIR=, =UPGRADE_GUARDED_DB_LCK= and =UPGRADE_GUARDED_INFORMANT= (empty means absent); maint's =MAINT_STATE_DIR=; and the gate's =KMC_MODULES_DIR= and =KMC_DKMS=, shared so one fixture tree drives the sets, the pkgbase resolution and the gate.
+- =kernel-modules-check= seams: =KMC_MODULES_DIR=, =KMC_BOOT_DIR= and =KMC_DKMS= (empty means absent).
+- =post-rebuild-check= seams: =PRC_GUARD_BIN= and =PRC_PACMAN_HOOKS_DIR=. Every test env, run_check's defaults included, sets =PRC_GUARD_BIN= empty, so the suite never reads the real =/etc=.
+- Fakes on PATH: pacman, sudo (honouring =-n=), yay (date-prefixed news lines, oldest first), informant, systemctl, systemd-cat, vercmp, checkupdates, dkms, zfs (=tests/zfs-pre-snapshot/fake-zfs=, which gains hold, release and userrefs), findmnt and lsinitcpio.
+- The tests drive both scripts through =subprocess=.
+
+*** Phase 1 tests
+In =tests/upgrade-guarded/= and =tests/kernel-modules-check/= unless a group names another directory.
+- P1-sets:
+ - the =--ignore= list is exactly the fixture hook's patterns plus the kernel and DKMS sets plus the closure names, each its own element;
+ - with =UPGRADE_GUARDED_HYPR_RUNNING= forced each way, the everyday list holds the kernel and DKMS sets on both branches and the GPU patterns only on the live one, and =--complete='s stage 1 holds the GPU closure on both;
+ - a kernel-set fixture under =KMC_MODULES_DIR= has three vmlinuz owners (one foreign and headerless), =linux-firmware*= and =linux-api-headers= installed, an unowned vmlinuz path, and zfs built for two of the three kernels. Firmware and api-headers are never held, the unowned path is left out, the kernel set is held whole, and the foreign kernel never enters the pending set or gate.pkgbases;
+ - a pending upstream =zfs-dkms=/=zfs-utils= bump is held as a pair; a =zfs-utils= pkgrel-only bump with =zfs-dkms= unchanged is not held;
+ - with no dkms.conf under =UPGRADE_GUARDED_SRC_DIR=, the DKMS set is empty and the run proceeds;
+ - a fake =-Qu= exiting 1 with empty stdout and a warning line reads as empty; one that prints an =error:= line and exits 1 fails the step (failed_step refresh), keeps packages and doesn't stamp; under =--dry-run=, a fake checkupdates that exits 1 with empty stdout and no =error:= line ends the preview with exit 1, prints no pending set and writes nothing;
+ - the deferred set comes from a fake post-transaction =-Qu= intersected with the held set, and excludes an =[ignored]= row;
+ - a missing or Target-less hook fails closed: exit 3, result refused, failed_step precondition, the path named, no transaction.
+- P1-closure:
+ - the fake pacman replays pacman 7.1's failing =-Sup= transcript (the header on stderr, the =::= lines on stdout, exit 1). A failing probe carrying only well-formed lines adds names and doesn't refuse, and lines parse alike with and without =::=;
+ - forward: =hyprutils= (soname 12 → 13) is held, and =hyprlang=, which requires the new soname, is added as kind gpu;
+ - forward through a new dependency: the fake emits the newlib line and the ptool line, with newlib neither pending nor installed. ptool is held with its stage's kind, the run exits 0, and =-Su= runs;
+ - reverse: an unguarded soname bump that a held =hyprland= depends on is added;
+ - orphan: a reverse break whose requirer is unheld and not pending refuses (exit 3, result refused, failed_step closure), no =-Su= runs, and detail holds pacman's stdout lines, then one line naming the refused packages;
+ - a header with another reason, a stray stderr line, an unparseable stdout line, an iteration that adds nothing, and more than |pending|+1 iterations each refuse;
+ - the run calls =sudo -n pacman -Sy= once, and the =-Su= call carries no =-y=.
+- P1-steps:
+ - EUID 0 refuses with exit 3 and leaves the record untouched (run under =unshare --map-root-user=); combining modes, an unknown option, or =--no-topgrade= outside the everyday mode exits 2 and writes no record;
+ - while the test holds a real flock on the lock file, the run exits 3 and the record is unchanged; with the lock free, the run gets past the precondition;
+ - a file at =UPGRADE_GUARDED_DB_LCK= makes the everyday run, =--complete= and =--apply-armed= each exit 3 before any transaction, with result refused and failed_step precondition, naming the file and the remedy; the file is still present afterwards;
+ - a fake sudo that refuses =-n= on the refresh, as a missing NOPASSWD rule does (exit 1, one 'sudo: a password is required' line, no =error:= line), gives exit 1, result failed and failed_step refresh, with that line as detail's last line and as the run's last stderr line, and neither yay nor the sweep is invoked;
+ - =yay -Pwq= runs before the first pacman transaction in the everyday run and =--complete=, and =--apply-armed= never calls it. With the fake yay's date-prefixed lines, the merge with the previous record's lines, the whole-line dedup and the 10-latest cap are asserted;
+ - =timeout 60 sudo -n informant read --all= runs before the first transaction in the everyday, =--complete= and =--apply-armed= forms. A failing informant, and =UPGRADE_GUARDED_INFORMANT= set empty, are tolerated;
+ - a fake =informant read --all= that exits 124, followed by a fake pacman that fails as the hook's abort would, gives failed_step pacman (everyday) or boot-transaction (=--apply-armed=), no stamp and no re-arm;
+ - the sweep argv is asserted element by element. One real-binary check, skipped when =/usr/bin/topgrade= is absent, runs =/usr/bin/topgrade --no-ask-retry --disable system git_repos containers -y --version= and expects exit 0. A failing fake sweep gives failed_step topgrade, exit 1 and no stamp;
+ - =yay -Sua= runs on every everyday run. A failing fake yay still lets the sweep run, records failed_step aur, doesn't stamp, and exits 1;
+ - a fake yay that bumps a kernel-set member's version opens gate (failed 'kernel-side package moved by the AUR step', since the time recorded before yay), removes an existing flag and writes the entry to the record before the fake sweep starts, exits 1 with failed_step aur and doesn't stamp, and on a ZFS root holds the oldest pre-pacman snapshot created at or after that time − 60 s and names it in held_snapshot; an INT during that sweep, or during a fake yay that has already bumped the member, leaves the record with gate open and result interrupted and no flag present, and on a ZFS root with the pre-yay snapshot held and named in held_snapshot; a following =--complete= runs =kernel-modules-check= in the =--since= form; a fake yay that bumps =zfs-dkms= and leaves the fake dkms status showing zfs only 'added' afterwards opens gate with the pkgbase of each kver that showed =installed= before yay, so gate.pkgbases is never empty;
+ - =--dry-run= writes no record, stamp or flag, calls no sudo and takes no lock. Every probe's =--dbpath= equals the =CHECKUPDATES_DB= the fake checkupdates received, and that path is not the shared default. With the fake checkupdates and the fake =pacman -Qu= given different rows, the printed pending set is the checkupdates rows, and the fake checkupdates received =--nocolor=;
+ - an INT while a fake step runs leaves result interrupted with that step and the remedy, and exits 130. An INT during news capture or the informant clear records failed_step precondition.
+- P1-record-stamp:
+ - with a stamping fake =topgrade= first on PATH and =UPGRADE_GUARDED_TOPGRADE= pointing at a non-stamping fake, a run that defers a non-empty set leaves =topgrade_run.json= untouched and never invokes the PATH fake;
+ - a TOPGRADE-shaped everyday run whose refresh, =-Su=, yay and sweep fakes exit 0, and whose post-transaction =-Qu= leaves nothing held, writes =topgrade_run=;
+ - that same run with =UPGRADE_GUARDED_MAINT= pointing at a missing file exits 1, and the record reads result failed, failed_step stamp, detail 'stamp failed: <reason>', with packages unchanged;
+ - that same run started from a record with gate non-null carries gate and held_snapshot forward unchanged and doesn't stamp;
+ - an UPDATE-shaped run (=--no-topgrade=) with nothing deferred doesn't stamp;
+ - a refresh failure (exit 1), a closure refusal (exit 3) or a pacman failure (exit 1) skips the AUR and sweep steps, still writes the record, and doesn't stamp. The refresh failure and the refusal keep the previous packages;
+ - after a successful =--complete= on the stage-2 path that started with a kernel-set member pending (so its pre-write ran), or a successful =--apply-armed=, packages is empty, the record reads result ok with failed_step null and detail empty, and the stamp is fresh;
+ - from a non-empty record whose packages are all installed, with nothing pending and gate null, =--complete= prints 'nothing to complete', writes an empty record, exits 0 and doesn't stamp; an arming =--complete= doesn't stamp;
+ - a fixed fake everyday run's record equals =tests/upgrade-guarded/fixtures/upgrade_deferred.json= in every field except written_at.
+- P1-gate (=tests/kernel-modules-check/=):
+ - on fake dkms status output, image timestamps, lsinitcpio listings and snapshot listings, a good fixture passes and each item fails on its own: a module not =installed= for a kver (last stderr line '<pkgbase>: <item>'), a missing =vmlinuz-<pkgbase>= in the boot dir, an initramfs older than =vmlinuz-<pkgbase>=, with =--since= an initramfs newer than the module-tree vmlinuz but older than since, and on a ZFS root an image without =zfs.ko=;
+ - a pkgbase that no kver names fails as '<pkgbase>: no module tree';
+ - the fake lsinitcpio fails unless invoked through the fake sudo, and the gate passes reading through sudo. lsinitcpio exiting 1 with no output fails as '<pkgbase>: cannot read <image>';
+ - with =--since=, a listing whose newest root-dataset pre-pacman snapshot was created before since − 60 s fails, naming the dataset. One created at exactly since − 60 s, one in [since − 60 s, since) (the snapshot hook's 60 s skip window) and one after since each pass;
+ - without =--since=, an image older than any since passes and no snapshot is required;
+ - =KMC_DKMS= set empty passes the module item;
+ - no pkgbase, or an unknown option, exits 2.
+- P1-complete:
+ - during a fake stage 1, the record's gate is non-null (failed 'kernel transaction not finished', result interrupted, failed_step pacman) and no flag exists;
+ - INT or HUP during stage 1 exits 130, with the entry kept and any earlier flag gone;
+ - a single stage 1 that moves linux and zfs-dkms together: the old linux kver is never checked, the new linux kver and the untouched linux-lts kver are, and with good fakes the gate passes;
+ - a DKMS-set-only change puts in gate.pkgbases the pkgbase of every kver with an =installed= dkms status line, and never a strix-shaped kver whose zfs is only 'added';
+ - a stage 1 that changes no version but leaves =vmlinuz-<pkgbase>= missing exits 4;
+ - a stage 1 that fails before committing, with =/boot= intact, runs the structural form, clears the gate and exits 1 with result failed, failed_step pacman, gate null and the kernel-side entries still in packages as kind kernel;
+ - a stage 1 that exits non-zero after the kernel landed, with a passing gate, exits 1 with result failed, failed_step pacman, gate null, the kernel gone from packages, and a detail and last stderr line that name the stage 1 failure, not the pre-written interrupted text;
+ - a failed gate sets result failed, failed_step gate and gate.failed to the gate's last stderr line, exits 4 with that line last on stderr, and reaches neither the arm step nor the prompt; the gate field survives a following everyday run;
+ - a follow-up =--complete= after an interrupt or a failed gate runs the =--since= form against the recorded since, and neither arms nor prompts until it passes, which sets gate null;
+ - after an interrupted stage 1 whose kernel landed (=vmlinuz-<pkgbase>= missing from the fake boot dir, nothing kernel-side pending), the follow-up's stage 1 argv names the recorded kernel package, its headers and the DKMS-set members as targets without =--needed=; with a fake pacman that restores the image on that reinstall, the gate passes and sets gate null;
+ - a recorded pkgbase whose package was removed drops out. With none left, =--complete= sets gate null without invoking the gate and, with packages otherwise empty, stamps. A recorded pkgbase still installed but with no module tree fails the gate with exit 4;
+ - a =--complete= with nothing kernel-side pending and no open gate never invokes =kernel-modules-check=;
+ - with a flag present, a =--complete= closure refusal, a failed stage 1 and a failed gate each leave no flag; a refresh failure leaves it in place;
+ - a GPU closure that reaches a kernel-set member refuses (exit 3, failed_step closure) before any transaction and leaves packages unchanged;
+ - a fake zfs logs snapshot, hold and release. On a ZFS root, a =--complete= that opens the entry snapshots and holds =<rootds>@pre-pacman_<ts>= after the pre-write and before stage 1, releases the previous held_snapshot, and the record read during the fake stage 1 already names the new snapshot in held_snapshot; a follow-up with the gate already open takes no snapshot before stage 1;
+ - from a record whose open gate has since S and whose held_snapshot predates S − 60 s, with a listing of four root-dataset pre-pacman snapshots (created at S − 61 s, at exactly S − 60 s, inside the window, and after a later everyday run), a passing and a failing =--since= gate each hold the S − 60 s snapshot, release the previous one and leave held_snapshot naming it; a held_snapshot created at or after S − 60 s is kept and nothing is re-held; a structural-form gate adds no hold after the gate, so in that run the fake zfs logs only step 3's snapshot and hold;
+ - a fake zfs whose snapshot or hold exits 1 prints 'snapshot hold failed' and changes neither result nor exit, stage 1 still runs, and after a failing gate the run still exits 4 with the gate item as detail and as its last stderr line.
+- P1-arm-boot:
+ - with the unit reported enabled and Hyprland live, =--complete='s arm runs the resolve check, =sudo -n pacman -Sw= on the GPU-kind names, =sudo -n install= and =sudo -n mv=, and writes their =<name>=<version>= lines to the flag;
+ - with the unit reported not enabled, =--complete= never writes the flag, prints the 'boot unit not enabled' line and never prompts;
+ - =--complete= prompts for a reboot only when the exit is 0 and stdin is a tty, and an INT or HUP at the prompt runs no reboot, leaves the record byte-identical to the one Finish wrote, and exits 0;
+ - an everyday run with the flag present rewrites it to the current GPU-kind entries and removes it when none remain. On a closure refusal it removes the flag and prints 'disarmed: closure refused', with failed_step closure and exit 3. On a resolve-check or =-Sw= failure it removes the flag, with failed_step download, detail 'disarmed: <reason>' and exit 1;
+ - with the flag present and the unit reported not enabled, an everyday run removes the flag, prints 'disarmed: boot unit not enabled', and never runs =-Sp= or =-Sw=;
+ - =--apply-armed= targets exactly the recorded list, never passes =-y= or refreshes (the fake pacman asserts the argv), and drops an entry already at or above its recorded version without downgrading;
+ - =--apply-armed= never includes a kernel-set or DKMS-set package, and refuses (exit 3, failed_step boot-transaction) when =pacman -Sp= would pull one in as a dependency or reports a stale version as target not found;
+ - =--apply-armed= pre-writes the interrupted record before the transaction, prints the banner as its first line, and sends its output through the fake =systemd-cat=;
+ - run under =setsid=, a SIGINT to the whole process group during a fake =--apply-armed= transaction (a fake pacman that traps INT, writes to stderr after the signal, removes a fake lock file and exits 130) leaves the fake pacman not killed by SIGPIPE, the fake lock gone, and the record interrupted;
+ - a missing, empty or unparseable list, or a live Hyprland, makes =--apply-armed= exit 3 with result refused and failed_step precondition; a held lock exits 3 with the record unchanged.
+- P1-installer:
+ - =tests/installer-steps/=: a source-inspection test that the step installing =hypr-live-update-guard= also installs both scripts to =/usr/local/bin=, and the TOML pin test, which compares the sorted seed-TOML patterns with the Target lines parsed from the heredoc the archsetup script writes to =/etc/pacman.d/hooks/10-hypr-live-update-guard.hook=, located by that exact path (the =test_pacman_hook_order.py= idiom), and reads neither =/etc= nor =~/.config=;
+ - =tests/post-rebuild-check/=: check 9 with the guard present and absent, and with the legacy and =10-= hook names, through the =PRC_= seams; the '/8' assertions become '/9';
+ - =tests/zfs-pre-snapshot/=: a held snapshot (userrefs above 0) is never destroyed and doesn't count toward KEEP.
+- M-split-console (manual, in the =make test-keep FS_PROFILE=zfs= VM, with pending packages created per Phase 3's VM procedure): run =upgrade-guarded --no-topgrade= from a console with Hyprland not running. Expected: exit 0; the pending GPU-pattern packages land; the record and =pacman -Qu= list only kernel-kind entries (plus =[ignored]= rows in =-Qu=).
+
+** Phase 2 — Wire maint to it (dotfiles)
+The Phase 2 commits are pulled on a machine only after Phase 4's step (a) there: the levers and the alias call =upgrade-guarded= and the force path is gone, so pulling them first breaks UPDATE and TOPGRADE on that machine.
+
+*** Levers
+- UPDATE's argv is =['upgrade-guarded', '--no-topgrade']=; TOPGRADE's is =['upgrade-guarded']=. Both keep kind user, timeout 3600 and always True, stay on the existing lever runner, and stay strip keys (gui.py:496).
+- The runner's timeout handling changes for user-kind remedies marked long, today only these two: =_execute_step= starts them with =start_new_session=True= and keeps reading their output. When the timeout passes, it sends SIGINT to the process group, waits up to 120 s, then SIGKILLs the group and reports 'timed out — interrupted'. It never sends SIGTERM, which pacman doesn't handle. pacman stops at a package boundary, and =upgrade-guarded='s trap records the run as interrupted (exit 130).
+- When =upgrade-guarded= isn't on PATH, the lever reads 'upgrade-guarded not installed (archsetup one-time install)'. There is no fallback to the old argv.
+- Delete doctor.py:169-170, the branch that stamps =topgrade_run= for the topgrade remedy, so on the driven path the script is the only writer. The PATH wrapper is otherwise unchanged: it keeps stamping bare-shell runs and is never in the script's chain.
+- Reword the stale text:
+ - the =topgrade_freshness= docstring and its absent-stamp error in =maint/src/maint/probes/updates.py:115-127=, so neither says the lever stamps. The error reads 'no topgrade run recorded — upgrade-guarded stamps it once nothing is left deferred; a bare-shell topgrade stamps through the PATH wrapper';
+ - the doctor.py module docstring (L28-30) and guard.py's docstring (L5-7), so neither describes a refusal or =--force=;
+ - the =maint fix= usage line at =maint/README.md:23=, which drops =[--force]=;
+ - the header comment of =hyprland/.local/bin/topgrade= and the docstring of =tests/maint/test_topgrade_wrapper.py=, so they name bare-shell runs as the wrapper's job and no longer claim the lever or the health-check workflow.
+
+*** Guard UX
+- Drop =guard: "live_update"= from the update and topgrade remedies, so =iter_fix= no longer refuses them. The removal rests on the levers no longer arming a live swap: the script holds the GPU patterns whenever Hyprland is live, so on the driven path the guard fires only when an AUR upgrade reaches a held GPU/compositor package while Hyprland is live.
+- Deleted with the tag: =iter_fix='s refusal and its 'guard' event; the =--force= sentinel wrap for these two remedies; the GUI's =_update_force=; =maint fix --force=; =_rearm_after_guard=; the guard branch of =_on_fired=; the 'guard' handling at cli.py:114 and panel.py:368; and the doctor review suffix (doctor.py:415-421).
+- =panel.guarded(rid)= becomes =rid in {'update', 'topgrade'}=, with no remedy tag. I keep it rather than delete it because =_press_lever= lives in gui.py, which needs GTK, so this is the only GTK-free seam for the arm line.
+- =guard.trips=, which reads the TOML patterns Decision 3 calls the display-side mirror, survives only as the arm-line annotation. On a tripped read, the arm line for either key reads '<label> armed — will defer at least <guard matches> (kernels and DKMS modules are always held) — press again to run $ <argv>', where <argv> is =panel.arm_argv='s string ('upgrade-guarded --no-topgrade' for UPDATE, 'upgrade-guarded' for TOPGRADE), as on the untripped line. Nothing gates execution on it, and nothing says live apply or REBOOT required. maint computes no kernel set of its own; the post-run row gives the exact set.
+
+*** The deferred row
+- Probe =upgrade_deferred=, category updates (PACKAGES band). It reads the record through cache.get, and the arm flag's presence and entries, never a raw line count. It never writes the flag.
+- N is the record's packages minus the entries installed at or above =new= (=pacman -Q= plus =vercmp=), through a helper shared with =topgrade_freshness=, so a by-hand or console completion clears the row without rerunning the script. The helper never raises: for a missing record, or one whose data lacks the documented fields (packages a list of ={name, old, new, kind}=), it returns None, and =topgrade_freshness= then grades unchanged with evidence ={deferred: 0}=, so the =topgrade_age= card shows no APPLY and REVIEW & FIX keeps TOPGRADE.
+- No record (cache.get returns None): OK, value 0, '0 deferred', with no APPLY and no DISMISS.
+- A record whose data lacks the documented fields: UNPROBED 'deferred-set record malformed — rerun upgrade-guarded' (the =_cached= idiom), with no APPLY. It never crashes the envelope.
+- Severity, first match wins:
+ - CRIT while gate is non-null: 'do not reboot — <pkgbases> not verified: <gate.failed>; run upgrade-guarded --complete after any fix';
+ - WARN when result is failed, refused or interrupted (the text adds detail's last line, cut to 160 characters), or when K > 0 fixable advisories name deferred packages ('N deferred · K fixable advisories');
+ - OK otherwise: 'N deferred', or while the flag exists 'armed — A apply at next boot', where A is the flag entries (non-=#= lines) not installed at or above their version, with ' · <N−A> more deferred' appended when N > A.
+- The row never colours the waybar glyph. A kernel sits in the deferred set most days, so a plain deferral stays OK rather than moving the stale-metric symptom onto another row; =topgrade_age= already carries the cadence nag. Age-based escalation is the vNext follow-up in Scope tiers.
+- Evidence: name, old, new and kind rows; detail in full; held_snapshot when set; and the news lines not in =upgrade_news_dismissed=. The evidence says plainly that a plain reboot applies nothing until =--complete= has armed.
+
+*** APPLY
+- The REMEDIES entry =apply_deferred=: tier confirm, a new kind =terminal=, argv =['upgrade-guarded', '--complete']=, metric_ids =['upgrade_deferred', 'topgrade_age']=, always True, no timeout, not a strip key, and in no macro.
+- =panel.LEVER_KEYS= gets an =apply_deferred= items builder (the =_timer_items= idiom): =[]= on =upgrade_deferred= while N > 0 or gate is non-null, =[]= on =topgrade_age= while its evidence deferred > 0, and None otherwise.
+- =topgrade_freshness= adds evidence ={deferred: N}=, computed by the shared helper.
+- =doctor.review= offers =apply_deferred= only where the panel would show its key, and drops topgrade for a =topgrade_age= whose evidence deferred > 0. TOPGRADE stays a strip key, so the sweep stays one press away.
+- Every GUI press of a terminal-kind remedy arms on the first press. On the second, =_press_lever= makes one call, =panel.fire_press(rid, items, fire, fixture)=, where =fire= is a callable that runs gui.py's =_fire= for this press. For a terminal-kind rid, fire_press runs the panel.py launcher, =Popen(['foot', '--hold', '-e', *argv], start_new_session=True)=, or on a fixture board launches nothing and returns 'fixture board, not applied' as MERGE does; it never calls =fire=. For any other rid it calls =fire=. So a terminal-kind press never reaches =_fire= or =doctor.iter_fix=, and the choice lives in GTK-free code. That covers the row key, the =topgrade_age= card key and REVIEW & FIX's FIX. The detach means closing the panel can't kill or orphan the kernel transaction, and =--hold= keeps the gate verdict readable after the script exits.
+- The script does every step in that terminal: stage 1, the gate, the arm flag and the reboot prompt. maint never installs packages or writes the flag; the panel reads the outcome from the record and the flag on its next probe.
+- =maint fix apply_deferred= is the CLI form, and it is the usage line REVIEW & FIX prints. It and =iter_fix= run the argv with inherited stdin, stdout and stderr and no timeout, never through =cmd.run=, and return the script's exit code.
+
+*** DISMISS
+- A row key of a new kind, =dismiss=, which gui.py's =_digest_key= dispatches explicitly, with no fall-through to MERGE.
+- It is shown while any record news line is absent from =upgrade_news_dismissed=.
+- It is arm-then-fire, the MARK KNOWN idiom (gui.py:1687), because hiding a manual-intervention notice silences a signal.
+- On fire, a GTK-free panel.py helper overwrites the maint cache key =upgrade_news_dismissed= (cache.put, beside the record) with the record's whole news list. Overwriting with the whole list keeps earlier dismissals hidden when a new line arrives, and the record's 10-line cap keeps the key bounded.
+- The row hides news lines by whole-line equality.
+
+*** Freshness and REBOOT
+- =topgrade_age= grading is unchanged: per Decision 1 it ages from the last full-current stamp while anything is deferred.
+- After a TOPGRADE that left N > 0, the wall note is 'N deferred — freshness clears once they land (APPLY)' rather than a bare re-probed =topgrade_age= line.
+- REBOOT is hidden while gate is non-null, overriding the flag, =reboot_required= and =offer_reboot=. Otherwise it is shown while the flag exists, which covers ratio, where =reboot_required= stays false, and is otherwise unchanged. The panel never infers arm or gate state from an exit code.
+- The reboot remedy re-reads the record (=cache.get('upgrade_deferred')=) when it fires: while gate is non-null, =doctor.iter_fix= runs nothing and yields a fail event 'gate open — do not reboot: <gate.failed>; run upgrade-guarded --complete'. The check lives in iter_fix, so it covers the panel key before the next probe hides it, REVIEW & FIX and =maint fix reboot=. =doctor.review= also omits =reboot= while gate is non-null, as it omits =apply_deferred= where the panel shows no key, because =reboot_required= goes WARN whenever the running kernel's package has moved (its =/usr/lib/modules/$(uname -r)= is gone), which is the usual gate-open state.
+
+*** CVE, QUEUE and the strip
+- Advisories on deferred packages count against the deferred row, not UPDATE: =cve_queued= (the badge and the strip), the CVE card's 'fixable via UPDATE' caption, UPDATE's =cve_advisories= binding and REVIEW & FIX all exclude deferred names.
+- QUEUE (the results wall and =maint queue=) tags deferred names [HELD]. [HELD] is a post-run mark; the pre-run mark is the arm-line annotation built from the TOML patterns (Guard UX).
+- The strip's pending cell reads 'P pending · N held', where P is the existing pending count (held included) and N is the row's value.
+
+*** Alias and README
+- The =sysupgrade= alias in =common/.zshrc.d/aliases.sh= and =common/.bashrc.d/aliases.sh= becomes =if command -v upgrade-guarded >/dev/null 2>&1; then alias sysupgrade=upgrade-guarded; else alias sysupgrade=topgrade; fi=. common/ is also stowed on dwm installs, which never get the scripts. minimal/'s two =aliases.sh= files are symlinks into common/ (=tests/tier-dedup= pins that), so a none install gets the same conditional, which falls back to topgrade there; they stay symlinks.
+- Rewrite the 'The live-update guard' section of =maint/README.md=: drop press-again and =--force=; say UPDATE runs =upgrade-guarded --no-topgrade= and TOPGRADE runs =upgrade-guarded= (whose sweep is =/usr/bin/topgrade --no-ask-retry --disable system git_repos containers -y=); describe the deferred row, APPLY, DISMISS and the armed state. The guard banner and the wrapper paragraph stay as they are.
+
+*** Phase 2 tests
+In dotfiles =tests/maint/= under the maint fake harness. No test imports gui.py or GTK.
+- P2-levers: the two lever argvs, with kind user, timeout 3600, always True and strip keys; the missing-binary message; a zero-exit TOPGRADE lever whose record lists deferred packages leaves =topgrade_run= unchanged; and a fake long user-kind remedy whose argv traps INT and sleeps past a 1 s timeout receives SIGINT, its trap writes its marker, the step reports 'timed out — interrupted', and no process in its group survives.
+- P2-guard-ux:
+ - =panel.guarded('update')= and =panel.guarded('topgrade')= stay True, with no guard key on either remedy;
+ - =viewmodel.arm_line= with a tripped read gives the arm-line string with each label, ending '$ upgrade-guarded --no-topgrade' for UPDATE and '$ upgrade-guarded' for TOPGRADE;
+ - =review()= output carries no suffix, =iter_fix= emits no guard event, and =maint fix= has no =--force=;
+ - test_panel_levers.py:397-406 and test_panel_phase10.py:417-422 are rewritten to match, along with the tests that pinned the tag, the refusal and the wrap.
+- P2-row, from the fixture copy and variants of it:
+ - N drops an entry installed at or above its =new=, and dismissed news lines stay off the row;
+ - a gate-open fixture gives the CRIT text, REBOOT hidden and APPLY shown, and =doctor.iter_fix('reboot', ...)= on it yields a fail event naming gate.failed and never calls the priv reboot, and =doctor.review= on it, with =reboot_required= at WARN, lists no =reboot= entry;
+ - with the flag present, the armed text, and REBOOT shown whatever =reboot_required= says;
+ - an armed fixture plus a kind-kernel entry renders the appended count, and A drops below the flag's line count once an entry is installed;
+ - a record with failed_step aur grades WARN with detail's last line;
+ - a fixture with kernel-kind entries kept after a failed stage 1 and gate null shows N > 0 at WARN and offers APPLY;
+ - with no record file, the row is OK 0 with no APPLY, the =topgrade_age= card has no APPLY, and REVIEW & FIX keeps TOPGRADE for a stale =topgrade_age=;
+ - a record without packages reads UNPROBED, =topgrade_age= still grades from its stamp with evidence deferred 0 and no APPLY, and =build_status= still returns every other metric;
+ - a non-empty record leaves the waybar glyph colour unchanged;
+ - a fixture with held_snapshot set lists that name in the row's evidence, and one with held_snapshot null lists none.
+- P2-apply:
+ - the =topgrade_age= card shows APPLY at N > 0 and not at N = 0;
+ - REVIEW & FIX offers APPLY rather than TOPGRADE for a stale =topgrade_age= while N > 0, and TOPGRADE at N = 0;
+ - on a gate-open fixture, the row key and the roster's FIX both resolve to =apply_deferred= with kind terminal, and =panel.fire_press= given each one's rid and items calls the patched =subprocess.Popen= once with =['foot', '--hold', '-e', 'upgrade-guarded', '--complete']= and =start_new_session=True=, and never calls the =fire= stub or a patched =doctor.iter_fix=. =panel.fire_press('update', [], fire, None)= calls =fire= and never Popen. With a fixture name, the =apply_deferred= presses call neither Popen nor =fire= and return 'fixture board, not applied', while =panel.fire_press('update', [], fire, '<fixture>')= still calls =fire=, because =_fire= dry-runs there;
+ - =iter_fix= and =maint fix apply_deferred= never call =cmd.run=, and run the argv with inherited stdio.
+- P2-dismiss, with =cache.put= patched:
+ - the first press arms and writes nothing;
+ - the second writes exactly the record's news list, replacing any prior value;
+ - the re-probed row shows no lines and no DISMISS key;
+ - a line first recorded by a later run shows, and the earlier dismissed lines don't.
+- P2-cve-queue: given a fixable advisory on a deferred package, =cve_queued= is 0, the CVE caption omits it, neither UPDATE's =cve_advisories= binding nor REVIEW & FIX offers UPDATE for it, and the row carries it at WARN as 'N deferred · K fixable advisories'. A fixture record's names are tagged [HELD] in QUEUE's rows, and the strip reads 'P pending · N held'.
+- P2-text:
+ - after a zero-exit TOPGRADE that left N > 0, the wall note reads 'N deferred — freshness clears once they land (APPLY)';
+ - a source check that both common alias files carry the conditional and that =minimal/.zshrc.d/aliases.sh= and =minimal/.bashrc.d/aliases.sh= are still symlinks resolving to them;
+ - the =topgrade_age= absent-stamp error equals the new string, and the README's =maint fix= usage line carries no =--force=.
+
+** Phase 3 — The boot-time unit (archsetup)
+
+*** Installer step
+- An installer step writes the system unit =archsetup-boot-upgrade.service= from a heredoc, with =ARCHSETUP_USERNAME= replaced by sed (the BRIO rule precedent), runs =install -d -m 0755 /var/lib/archsetup=, and enables the unit with =systemctl enable=, without =--now=. The step documents itself in comments.
+
+*** The unit
+- [Unit]:
+ - =ConditionPathExists=/var/lib/archsetup/apply-upgrade-on-boot=, so a boot with no flag skips the unit at negligible cost;
+ - =Before=getty@tty1.service=. This holds the tty1 login (autologin where enabled, a password prompt otherwise) until the unit ends, and so holds off =~/.profile.d/99-hyprland-autostart.sh=. The ordering is load-bearing: a parallel run would let Hyprland start mid-swap and reintroduce the crash the guard prevents;
+ - default dependencies stay on (After=basic.target), so local and ZFS mounts are up, =/home= and =/var/cache/pacman= included;
+ - no Requires=, BindsTo= or RequiredBy=, and no network-online ordering.
+- [Service]:
+ - =Type=oneshot=;
+ - =User=ARCHSETUP_USERNAME=, substituted with the installing user, so =HOME= points at that user's maint state;
+ - =RuntimeDirectory=archsetup-boot-upgrade=, created when the unit starts and removed when it stops;
+ - =ExecStartPre=+/usr/bin/mv -f /var/lib/archsetup/apply-upgrade-on-boot /run/archsetup-boot-upgrade/armed.list=. The =+= runs it as root regardless of User=;
+ - =ExecStart=/usr/local/bin/upgrade-guarded --apply-armed=, with nothing after it;
+ - =TimeoutStartSec=20min=;
+ - =KillSignal=SIGINT=, with KillMode left at control-group;
+ - =TimeoutStopSec= left at the 90 s default, as the SIGKILL backstop;
+ - =StandardInput=null=, =StandardOutput=tty= and =TTYPath=/dev/tty1=, with stderr inheriting. Not journal+console, because ratio's =/dev/console= is ttyS0; the script mirrors its own output to the journal;
+ - no Restart=.
+- [Install]: =WantedBy=multi-user.target=. The unit is wanted, never required, so its failure can't fail the target.
+
+*** Disarm and failure
+- =ExecStartPre= consumes the flag before =ExecStart= runs, so success, failure, a timeout, SIGKILL and power loss all disarm, and a failed attempt never wedges later boots.
+- =ExecStart='s exit is the unit's result, so a failed or timed-out run leaves =archsetup-boot-upgrade.service= failed, and maint's existing =failed_units= row names it with its journalctl hint.
+- A timeout sends SIGINT to the cgroup. pacman, which has no SIGTERM handler, stops at a package boundary and releases =db.lck=, and the script records interrupted. The output mirror ignores SIGINT (Phase 1, Exit codes and interrupts), so pacman's output never loses its reader. The unit never deletes =db.lck=.
+- The boot form never refreshes and has no network dependency (Phase 1, =--apply-armed= sequence), which is why the unit has no network-online ordering.
+
+*** Phase 3 tests
+- P3-unit (=tests/installer-steps/=): a source-inspection test in the =test_pacman_hook_order.py= idiom. It asserts:
+ - =ConditionPathExists=/var/lib/archsetup/apply-upgrade-on-boot=;
+ - =Before=getty@tty1.service=;
+ - =Type=oneshot=;
+ - =User=ARCHSETUP_USERNAME= and its sed substitution;
+ - =RuntimeDirectory=archsetup-boot-upgrade=;
+ - the =ExecStartPre=+= mv line;
+ - =ExecStart=/usr/local/bin/upgrade-guarded --apply-armed=, with nothing after it;
+ - =TimeoutStartSec=20min=;
+ - =KillSignal=SIGINT=;
+ - =StandardInput=null=, =StandardOutput=tty= and =TTYPath=/dev/tty1=;
+ - =WantedBy=multi-user.target=;
+ - the absence of Requires=, BindsTo=, RequiredBy=, network-online.target and Restart=;
+ - the =install -d= and the enable call.
+- Manual boot tests. On a daily driver, run them when =upgrade-guarded --dry-run= lists a GPU-kind package; in the VM, follow the VM procedure below.
+ - M-boot-armed (armed boot, networking down): arm, confirm the armed state, disable networking, reboot. Expected: arming fired neither the guard nor informant's hook; the banner 'archsetup: applying N deferred GPU/compositor upgrades — do not power off' shows on tty1; the set installs from the cache before the tty1 login; the flag is gone; the record's packages are empty; and =topgrade_run= is freshly stamped when nothing else is deferred. In the VM, the arming =--complete= also has a kernel-set package pending, so it passes the real gate (=sudo -n lsinitcpio= included) on the ZFS root before it arms. On ratio and velox the test arms from APPLY (a detached =foot= terminal opens and the panel's wall streams nothing), confirms the row reads 'armed — A apply at next boot' with REBOOT shown before the reboot, and confirms =maint status= reads a fresh =topgrade_age= afterwards.
+ - M-boot-news (velox): an armed boot with two or more unread news items. Expected: the run completes with nothing waiting on stdin.
+ - M-boot-timeout: add a temporary drop-in with a short =TimeoutStartSec= and an =Environment=PATH= that puts the Phase 1 fake sudo and fake pacman, the pacman set to sleep, ahead of =/usr/bin=; then reboot armed. Expected: the tty1 login appears once the timeout fires, the flag is gone, the next boot skips the unit, the record says interrupted with the remedy and still lists the set, and =systemctl is-failed archsetup-boot-upgrade.service= reports failed (on a daily driver, the deferred row lists the set at WARN and maint's failed-units row names the unit). Remove the drop-in afterwards.
+ - M-boot-midtx: a real armed set with a =TimeoutStartSec= short enough to land inside the transaction. Expected: no =/var/lib/pacman/db.lck= remains, the record says interrupted, and =upgrade-guarded --complete= from a console then finishes the set.
+ - M-boot-fail: the M-boot-timeout drop-in with the fake pacman answering =-Q= and =-Sp= normally and exiting 1 on =-S=. Expected: the login appears, the flag is gone, the record names the failure (failed_step boot-transaction) and still lists the set, and the unit is failed. Remove the drop-in afterwards.
+ - M-boot-ratio: an armed boot on ratio, whose =/dev/console= is ttyS0. Expected: the banner and pacman's progress stay visible on the monitor through the transaction, and REBOOT was offered while armed even though =reboot_required= stays false.
+- VM procedure:
+ - =make test-keep FS_PROFILE=zfs= runs QEMU headless with no compositor, so Hyprland is never live there;
+ - create pending packages from a console by installing an older version of a GPU-pattern package (and, for M-boot-armed and M-split-console, of a kernel-set package) from the cache or the Arch Linux Archive;
+ - arm over SSH with =UPGRADE_GUARDED_HYPR_RUNNING=1 upgrade-guarded --complete= instead of APPLY, and confirm the armed state from the flag and the record;
+ - observe tty1 with the QEMU monitor's screendump on =vm-images/qemu-monitor-zfs.sock=; a guest reboot keeps the QEMU process and SSH;
+ - confirm the banner and the before-login ordering from =journalctl -u archsetup-boot-upgrade= and getty@tty1's start timestamp, and a failed run with =systemctl is-failed archsetup-boot-upgrade.service=;
+ - never use =debug-vm.sh= here: it restores the clean-install snapshot and would erase the install under test.
+- VM gate: =make test-keep FS_PROFILE=zfs= passes M-boot-armed, M-boot-timeout, M-boot-midtx and M-boot-fail, plus M-split-console, before the unit reaches velox. M-boot-news and M-boot-ratio run on their machines after step (c).
+
+** Phase 4 — Docs, rollout, and both daily drivers
+
+*** README flow and recovery
+One dotfiles commit to the 'The live-update guard' section of =maint/README.md=:
+- The flow: UPDATE defers → APPLY / =--complete=: kernel and DKMS sets live → gate → pre-download and arm → reboot → tty1 console upgrade → login.
+- For manual diagnosis of a CRIT row, =kernel-modules-check <pkgbase>= with the pkgbase the row shows. Only =upgrade-guarded --complete= clears the gate.
+- The recovery steps below, which are the only copy.
+
+*** Recovery
+Boot the held snapshot from ZFSBootMenu: the record's held_snapshot, which the deferred row's evidence shows. When the root won't boot, find it from the ZFSBootMenu recovery shell as the =zroot/ROOT/default@pre-pacman_*= row with userrefs above 0 in =zfs list -H -t snapshot -o name,userrefs zroot/ROOT/default=, and confirm the =upgrade-guarded= tag with =zfs holds <snap>=. Pick by the hold, never by age: a pre-pacman snapshot newer than the held one can already hold the new kernel. Then, before the next =upgrade-guarded= run:
+- The pre-pacman snapshot covers =zroot/ROOT/default= only. The pacman db (=zroot/var/lib/pacman=), the cache (=zroot/var/cache=) and the log (=zroot/var/log=) are separate datasets and stay current.
+- If a power cut left =/var/lib/pacman/db.lck=, remove it by hand; nothing is running after a reboot.
+- Reconcile the pacman db to the booted root from =/var/log/pacman.log=. Take every =[ALPM]= upgraded, downgraded, installed and removed line timestamped after the snapshot's creation, and undo them newest first:
+ - upgraded or downgraded: =sudo pacman -U --dbonly= the old version from =/var/cache/pacman/pkg= (yay's cache for an AUR package);
+ - installed: =sudo pacman -Rdd --dbonly=;
+ - removed: =sudo pacman -U --dbonly= the removed version.
+- A power cut mid-package leaves that package with no =[ALPM]= line, and can leave a local db entry that =pacman -Dk= reports as 'description file is missing'. pacman removes the old entry before it extracts the new one, so the db no longer records the old version. Delete that entry's directory under =/var/lib/pacman/local=, then =sudo pacman -U --dbonly= from the cache the version the booted root holds: the one named by the last =[ALPM]= line for that package timestamped before the snapshot's creation.
+- Then =pacman -Dk= is clean, and =pacman -Qkk= over those names and over any package the previous step re-registered reports no mismatch.
+- Never roll back =zroot/var/lib/pacman=, which the snapshot hook never snapshots.
+
+*** Rollout
+A rebuilt Hyprland machine gets every piece from the installer. Existing machines don't: the guard install runs inside =window_manager=, which is already marked complete in =/var/lib/archsetup/state/=, so re-running archsetup installs nothing. On each existing machine, ratio and velox, by hand, in this order:
+- (a) After Phase 1's last commit:
+ - pull archsetup;
+ - =sudo install -m 755 scripts/upgrade-guarded scripts/kernel-modules-check /usr/local/bin/=, and on a ZFS root also =sudo install -m 755 scripts/zfs-pre-snapshot /usr/local/bin/=;
+ - write =/etc/pacman.d/hooks/10-hypr-live-update-guard.hook= from the installer heredoc and confirm its Targets match the heredoc's;
+ - remove the unprefixed =hypr-live-update-guard.hook= and confirm with =ls /etc/pacman.d/hooks/=. Ratio still has the legacy name, and velox's was hand-placed under it; the script refuses without the =10-= name;
+ - re-copy =configs/maintenance-thresholds.toml= to =~/.config/archsetup/= after diffing for local edits;
+ - confirm =pacman -Qmq= lists no DKMS-set package, because a DKMS package from the AUR would sit outside the hold (=yay -Sua= upgrades it ungated);
+ - confirm =upgrade-guarded --dry-run= exits 0;
+ - on a ZFS root, confirm =kernel-modules-check "$(cat /usr/lib/modules/$(uname -r)/pkgbase)"= exits 0. This structural run proves the =sudo -n lsinitcpio= read against the real image;
+ - run M-split-live.
+- M-split-live (manual): from a terminal in the live session, right after =--dry-run=, run =upgrade-guarded --no-topgrade=. Expected: exit 0, no BLOCKED banner, the record's packages equal dry-run's predicted deferred set, and =pacman -Qu= then lists only those names plus =[ignored]= rows. When dry-run lists a GPU-kind entry, this run is the hook-silence check; if none is pending, the hook-silence part runs on the first day one is, before AC1 is ticked.
+- (b) Only after (a) on that machine: pull the Phase 2 commits. No Phase 2 commit reaches a machine's dotfiles before (a) is done there, velox included.
+- (c) After Phase 3, and on velox only after the VM gate passes:
+ - write =archsetup-boot-upgrade.service= from the installer heredoc, with the username substituted;
+ - =sudo install -d -m 0755 /var/lib/archsetup=;
+ - =sudo systemctl daemon-reload=;
+ - =sudo systemctl enable archsetup-boot-upgrade.service=.
+- Until step (c), =--complete= reports the unit not enabled and arms nothing, so the GPU/compositor set lands from a console instead.
+- Confirm the ratio path: tty1 shows a password prompt rather than autologin, and the =Before=getty@tty1.service= ordering holds that prompt; the banner shows on its monitor rather than on ttyS0 (M-boot-ratio); and of its three kernels, =linux-lts-strix= is never moved or gated.
+
+*** Recovery drill (D-zbm)
+A non-gating drill in =todo.org=, run in the =make test-keep FS_PROFILE=zfs= VM; no criterion waits on it.
+- With a kernel update and one non-kernel package pending, make the =zfs-dkms= build fail for the new kernel.
+- Run =upgrade-guarded --complete=, and confirm it exits 4 naming the failure, arms nothing, and records the held snapshot in held_snapshot.
+- Boot that snapshot from ZFSBootMenu, then run the Recovery reconcile.
+- Expected: =pacman -Q= shows the old kernel set and the non-kernel package's old version, and =pacman -Qkk= over the reconciled names reports no mismatch.
+- Variant, on a VM whose record has gate null (a fresh =make test-keep FS_PROFILE=zfs= run, not the VM the drill above left with its gate open), so that =--complete= step 3 takes and holds its own snapshot: with a kernel update pending, run =upgrade-guarded --complete= over SSH. While stage 1 prints its '(n/m) upgrading' lines, hard-reset the guest with the QEMU monitor's =system_reset=. That reset is the power-cut equivalent and keeps the QEMU process. Never use =system_powerdown=, a graceful ACPI shutdown that runs the trap instead, and never use =quit=: both end QEMU, and =make test-keep= then restores the clean-install snapshot. Boot held_snapshot from ZFSBootMenu and run Recovery. Expected: =zfs holds= shows the =upgrade-guarded= tag on the snapshot step 3 took before stage 1, and held_snapshot names it; =pacman -Q= shows the old kernel set; =pacman -Dk= is clean; and =pacman -Qkk= over the reconciled names, including the package cut off mid-extraction, reports no mismatch.
+
+*** Rollback
+Roll back in this order (dotfiles and docs first, so nothing calls the scripts once they are removed; steps 1 and 2 are independent):
+1. Revert the dotfiles Phase 4 README commit, then the Phase 2 commits newest first, then the archsetup =system-health-check.org= doc commit. The README goes first because Phase 2 and Phase 4 edit the same section. Reverting Phase 2 restores the old argv, the force path and the alias, and removes the deferred row and APPLY.
+2. =sudo systemctl disable archsetup-boot-upgrade.service=, remove the unit, =sudo systemctl daemon-reload=, and remove the flag.
+3. Release held_snapshot if set (=sudo zfs release upgrade-guarded <name>=), then remove both scripts from =/usr/local/bin=.
+
+Removing the scripts before step 1 leaves UPDATE, TOPGRADE, the =sysupgrade= alias and the health-check workflow's update step pointing at a missing binary. Removing only the unit leaves =--complete= unable to arm, and the next everyday run removes any flag (Phase 1, everyday step 9), so nothing is left dangling. The rewritten =guard_patterns=, the =10-= hook name, the userrefs-aware prune, post-rebuild-check's check 9 and the record file can stay. The guard hook's behavior is untouched throughout.
+
+* Acceptance criteria
+Each criterion is an observable outcome. Implementation phases hold the exact behavior behind it, and Testing names the tests that check it. "Gate open" means the record's =gate= is non-null, and a "gate check" is a =kernel-modules-check= run by =--complete=.
+- [ ] AC1: With a GPU/compositor package pending and Hyprland live, UPDATE (=upgrade-guarded --no-topgrade=) and TOPGRADE (=upgrade-guarded=) each:
+ - apply everything outside the held set and, when every step succeeds, exit 0, because a deferral alone never makes the exit non-zero;
+ - leave the =hypr-live-update-guard= hook silent during the pacman step;
+ - leave =pacman -Qu= listing only the deferred set a =--dry-run= run just before predicted, plus =[ignored]= rows, with the panel's =upgrade_deferred= row showing that set and QUEUE tagging its names =[HELD]=;
+ - never write =topgrade_run= while anything is deferred, whether the run starts from the panel or a shell.
+- [ ] AC2: On a soname day, when a pending package needs a held package's new version, the dependency closure holds that package too, and the run exits 0 having applied everything else.
+ - An upstream =zfs-utils= bump that breaks =zfs-dkms='s exact =zfs-utils= pin is held together with =zfs-dkms=. A pkgrel-only =zfs-utils= bump is not held.
+ - When the chain runs through a dependency the transaction would newly install, the pending package that pulls it in is held and the run doesn't refuse.
+ - When the requirer is installed but not pending (an orphan, foreign or AUR package, like ratio's =qemu-block-gluster= on 2026-08-25), the run refuses before any transaction (exit 3, the record's =failed_step= is =closure=), names the refused packages in its last stderr line, and never forces or removes a package.
+- [ ] AC3: The same UPDATE or TOPGRADE run with Hyprland not running applies everything but the kernel and DKMS sets and their closure, so only entries of kind =kernel= remain in the record and in =pacman -Qu= (besides =[ignored]= rows).
+ - "Not running" means =pgrep -x Hyprland= fails. A console reached with Ctrl+Alt+F2 while Hyprland runs on tty1 is live, so a run there holds the GPU patterns as in AC1.
+ - It writes =topgrade_run= only when it is a TOPGRADE run (no =--no-topgrade=) whose refresh, pacman, AUR and sweep steps all exited 0, nothing is left deferred and no gate is open. UPDATE never stamps.
+- [ ] AC4: A =--complete= with a kernel-set or DKMS-set member pending opens the gate from the start of its stage 1 until a gate check passes, whether the run started from the panel or a shell.
+ - While the gate is open, the deferred row is CRIT ('do not reboot — <pkgbases> not verified: <gate.failed>; run upgrade-guarded --complete after any fix'), no arm flag exists, and no =--complete= prompts to reboot. REBOOT is hidden from the panel's first probe after the gate opens, whatever the arm flag, =reboot_required= or =offer_reboot= say. A reboot fired before that probe, from the panel key, REVIEW & FIX or =maint fix reboot=, runs nothing and fails with 'gate open — do not reboot: <gate.failed>; run upgrade-guarded --complete'.
+ - The gate stays open while stage 1 runs, after an interrupted stage 1 (exit 130), and across a wall dismiss and a panel reopen. It closes only when a gate check passes or every recorded pkgbase has been uninstalled.
+ - A DKMS build that fails for the new kernel makes the run exit 4 with the failing item as its last stderr line and in the CRIT text, and the record reads result =failed= with =failed_step= =gate=. The machine keeps running the old kernel, with the new one installed but not booted.
+ - While anything is deferred or the gate is open, the row offers APPLY. APPLY, from the row or from REVIEW & FIX's FIX, opens =upgrade-guarded --complete= in a detached =foot= terminal and never runs it inside the panel.
+- [ ] AC5: With a GPU/compositor package pending, Hyprland live and the boot unit enabled, APPLY arms the boot upgrade on ratio and velox. The deferred row then reads 'armed — A apply at next boot', where A counts the armed entries not yet installed, with ' · <N−A> more deferred' appended while other entries stay deferred, and REBOOT shows.
+ - A reboot with networking down applies the armed set from the pacman cache on tty1 before the tty1 login, with the banner 'archsetup: applying N deferred GPU/compositor upgrades — do not power off' visible.
+ - Afterwards the flag is gone, the deferred row drops the entries that landed, and =maint status= reads a fresh =topgrade_age= when nothing else is deferred.
+- [ ] AC6: A boot-upgrade failure (a failed step, a timeout or an aborted transaction) never blocks the session:
+ - the tty1 login appears and the flag is gone, even when the timeout killed =ExecStart=;
+ - no =/var/lib/pacman/db.lck= remains after a timeout that SIGINT ends (the SIGKILL backstop and power loss are the residual Risks names);
+ - the record's result and =failed_step= name what went wrong, its detail names the reason (and, for an interruption, the remedy), and the deferred row still lists what did not land, at WARN;
+ - =archsetup-boot-upgrade.service= is left failed and shows in maint's failed-units row.
+- [ ] AC7: With two or more unread Arch news items and no stdin, unread news wedges no mode once the informant clear succeeds. A failed clear ends in a named =failed_step=, the lever runner's or the unit's timeout, or, in a terminal run (APPLY's =--complete= or a shell), a wait the person can end with Ctrl+C (exit 130, recorded as interrupted); none of these swaps anything.
+ - Where informant is installed, =timeout 60 sudo -n informant read --all= runs before the first transaction in all three transacting modes (everyday, =--complete= and =--apply-armed=).
+ - The everyday and =--complete= runs record the unread items (=yay -Pwq=) before the clear, and the deferred row lists them with a DISMISS key until they are dismissed.
+- [ ] AC8: A =--complete= from a console with Hyprland stopped lands the deferred GPU/compositor set and stamps freshness. It writes =topgrade_run= exactly when it got past the nothing-to-complete check, started with something deferred or the gate open, every step it ran succeeded, and it finishes with nothing deferred and no gate open.
+ - An arming run never stamps, and neither does a run that prints 'nothing to complete', even when the record it started from listed packages that have since been installed.
+ - A failed stamp makes the exit 1 and leaves the record at result =failed= with =failed_step= =stamp=.
+- [ ] AC9: While anything is deferred or the gate is open, the deferred row offers APPLY, and the deferral never changes the waybar glyph colour.
+ - After a successful TOPGRADE that left N > 0 entries deferred, =topgrade_age= keeps aging, its card also offers APPLY, REVIEW & FIX offers APPLY rather than TOPGRADE for it when stale, and the wall note reads 'N deferred — freshness clears once they land (APPLY)'.
+ - Once nothing is deferred, the =topgrade_age= card drops APPLY and REVIEW & FIX offers TOPGRADE for it again.
+ - With no record file at all, the row reads OK '0 deferred' with no APPLY or DISMISS, and REVIEW & FIX keeps TOPGRADE for a stale =topgrade_age=.
+- [ ] AC10: An AUR upgrade that needs a newer held GPU/compositor package under a live Hyprland fails the AUR step visibly without blocking the topgrade sweep. The guard aborts yay's own pacman call, the record's =failed_step= is =aur=, the row is WARN, the run exits 1, and nothing stamps.
+- [ ] AC11: A fixable advisory on a deferred package shows on the deferred row at WARN ('N deferred · K fixable advisories'), not as UPDATE-fixable: =cve_queued=, the CVE card's 'fixable via UPDATE' caption, UPDATE's =cve_advisories= binding and REVIEW & FIX all exclude it.
+- [ ] AC12: The =hypr-live-update-guard= hook is unchanged and still blocks a live GPU/compositor swap.
+ - Every mode but =--apply-armed= refuses before any transaction (exit 3, the path named; outside =--dry-run= the record's =failed_step= is =precondition=) when =/etc/pacman.d/hooks/10-hypr-live-update-guard.hook= is unreadable or has no =Target= lines.
+ - The installer writes the hook as =10-hypr-live-update-guard.hook=, and the seed TOML's =guard_patterns= equal its =Target= list.
+- [ ] AC13: On a ZFS root, a =--complete= that opens the gate entry snapshots the root dataset and puts an =upgrade-guarded= hold on that snapshot before its stage 1, and the record's =held_snapshot= names it. An everyday run whose AUR step opens the entry holds, the same way, the oldest =pre-pacman_*= snapshot taken no earlier than a minute before that step. When no such hold exists (=held_snapshot= is null, gone, or taken more than a minute before the entry opened), a gate check in the =--since= form, pass or fail, holds the oldest =pre-pacman_*= snapshot taken no earlier than a minute before the entry opened, and =held_snapshot= names that one. =zfs-pre-snapshot='s prune never destroys the held snapshot or counts it toward its keep limit, and it stays held until a later =--complete=, or an everyday run whose AUR step opens the entry, holds a newer one and releases it.
+
+I treat booting the held snapshot from ZFSBootMenu as the fallback for a kernel that won't boot, not as a gating criterion. AC13 keeps that snapshot from being pruned, Phase 4 holds the recovery steps and the non-gating D-zbm drill, and Risks weighs the cost.
+
+* Readiness dimensions
+Answer each, or write "N/A because…".
+- Data model & ownership: five pieces of state, none of it user-authored, plus a lock file. Each has one writer on the driven path (=topgrade_run= also has the PATH wrapper for bare-shell runs), and its exact shape lives in the phase subsection cited.
+ - The record, =${MAINT_STATE_DIR:-$HOME/.local/state/maint}/upgrade_deferred.json= (maint cache key =upgrade_deferred=). =upgrade-guarded= is its only writer; maint's =upgrade_deferred= probe, =topgrade_freshness= and the script itself read it. It carries no mode, armed or boot state: armed means the flag exists, and a gate is open while =gate= is non-null. A fixture in each repo pins its shape, and a field change updates both in one rollout. Exact fields, writes and fixtures: Phase 1, The record.
+ - The arm flag, =/var/lib/archsetup/apply-upgrade-on-boot=, root-owned. =upgrade-guarded= writes and removes it through sudo, the boot unit's =ExecStartPre= consumes it, and the panel reads only its presence and entries. The =/var/lib/archsetup/= directory is installer-owned. Exact rules: Phase 1, The arm flag.
+ - =topgrade_run= (maint cache key), written only through =maint stamp topgrade=: by =upgrade-guarded= under its stamp predicate, and by the =~/.local/bin/topgrade= PATH wrapper for bare-shell runs. doctor.py's lever stamp is deleted. Exact predicate: Phase 1, Stamp predicate.
+ - =upgrade_news_dismissed= (maint cache key, beside the record), written only by the deferred row's DISMISS key, which overwrites it with the record's whole news list, so it stays bounded by the record's cap of 10 lines. Exact behavior: Phase 2, DISMISS.
+ - On a ZFS root, one =upgrade-guarded= user hold on a pre-pacman snapshot, named by the record's =held_snapshot=. Only =--complete=, and an everyday run whose AUR step opens the gate entry, place or move it, the =zfs-pre-snapshot= prune never destroys a held snapshot, and rollback releases it. Exact behavior: Phase 1, The snapshot hold.
+ - =upgrade-guarded.lock=, beside the record: a flock file that holds no data, held for the life of every invocation except =--dry-run=. Exact rule: Phase 1, Preconditions.
+- Errors, empty states & failure: every failure is named and non-silent, and leaves a state the next run can recover from.
+ - On any non-zero exit the last stderr line names the reason, and the record's =result=, =failed_step= and =detail= carry it to the panel. A deferral alone never makes the exit non-zero. Exit codes and the interrupt trap: Phase 1, Exit codes and interrupts.
+ - Refusals exit 3 before any transaction and leave nothing upgraded. The script never deletes =db.lck= and never proceeds on an empty hook pattern list. Exact refusal rules: Phase 1, Preconditions and Dependency closure.
+ - News capture and the informant clear never stop a run; Risks (Arch news) names the residual when the clear fails. A failed AUR step still runs the sweep, and nothing stamps. A stamp that can't be written exits 1 with =failed_step= stamp and leaves the rest of the record as computed (Phase 1, Finish).
+ - A failed gate leaves the new kernel installed, nothing armed and no reboot offered. The terminal run names the failing item, the record keeps the gate open, and the panel shows a CRIT row with REBOOT hidden until a later =--complete= closes the gate. Exact rules: Phase 1, Invariants and The gate; Phase 2, The deferred row.
+ - Arming that can't resolve or download arms nothing. While armed, an everyday run whose refresh succeeds rewrites or removes the flag, and removes it outright when the boot unit isn't enabled, so a flag never outlives its unit (Phase 1, Everyday sequence, step 9).
+ - Empty states: an empty pending set is a normal run that writes an empty record. A =--complete= with nothing pending and no open gate prints 'nothing to complete' and exits 0. A boot run whose armed entries are all installed already is a no-op. With no record the deferred row reads OK '0 deferred', and a malformed record reads UNPROBED (Phase 2, The deferred row).
+ - The boot unit is bounded and self-disarming. =ExecStartPre= moves the flag off its persistent path before the attempt, so success, failure, timeout, SIGKILL and power loss all disarm. The 20-minute start timeout sends SIGINT, which stops pacman at a package boundary so it releases =db.lck= itself. A failure, timeout or SIGKILL ends at the tty1 login in the same boot, with the record naming the failure and the unit failed. After power loss the next boot skips the unit on its condition and no failed state survives, so the pre-written interrupted record (the deferred row, or =maint status= from a console) and, if a =db.lck= was left, the refusal that names it are what remain. Retrying means running =upgrade-guarded --complete=. Exact unit: Phase 3, The unit.
+- Security & privacy: =upgrade-guarded= runs as the invoking user and refuses EUID 0, so the record and the stamp live in that user's =$HOME= (yay refuses root anyway). Every root step the script itself runs is =sudo -n= on the existing =%cjennings NOPASSWD: ALL= rule, so a missing rule fails with a reason instead of prompting (at the refresh in the everyday run and =--complete=, before yay or the sweep runs; at the transaction in the boot form), and the unit's =ExecStartPre=+= move is the only root step outside sudo. The gate stays unprivileged apart from one =sudo -n lsinitcpio= read, because mkinitcpio writes the initramfs images root 0600. Phase 1, Identity and privilege, is the only list of the script's own root steps; yay and the sweep escalate through their own sudo. The flag is root-owned, so arming a boot takes sudo. I add no new privilege; the NOPASSWD breadth is a pre-existing fact, not introduced here. The record holds package names and versions, news lines, failure detail and a snapshot name, and nothing leaves the machine.
+- Observability:
+ - The boot run writes to tty1, not journal+console, because ratio's =/dev/console= is ttyS0. It opens with a do-not-power-off banner naming the count, and mirrors all output to the journal under the =archsetup-boot-upgrade= tag (Phase 1, --apply-armed sequence; Phase 3, The unit).
+ - =ExecStart= is the script with nothing after it, so its exit is the unit's result. A failed or timed-out run leaves =archsetup-boot-upgrade.service= failed, and maint's existing =failed_units= row names it with its journalctl hint in the session that follows.
+ - Every mode except =--dry-run= writes the record, so after any run (panel, shell or boot unit) the deferred row shows the deferred set, the last result and detail, any open gate, the armed state read from the flag, the held snapshot's name when one is set, and the undismissed news (Phase 2, The deferred row).
+ - =--dry-run= prints the sets, the GPU closure and every argv, and writes nothing (Phase 1, Modes and options).
+- Performance & scale: one pacman transaction at boot, installed from the cache that arming pre-downloaded, so there's no network wait. It's bounded by the 20-minute start timeout, and a boot with no flag skips the unit at negligible cost. An everyday run is one refresh, read-only and download-free closure probes (at most |pending|+1 per stage), one =-Su=, then yay and the sweep; the informant clear is bounded by =timeout 60=. The =--complete= session is long by nature (a kernel transaction plus DKMS builds), so APPLY opens it in a detached =foot= terminal rather than under the lever runner's 3600 s timeout.
+- Reuse & lost opportunities: reuses the guard's own =Target= list by reading the installed hook (the single source of truth for which libraries are dangerous) and the guard's liveness test (=pgrep -x Hyprland=); =maint stamp topgrade= and maint's cache.put envelope; maint's =failed_units= row for boot failures; =informant read --all= where installed, with =yay -Pwq= capturing the news first so clearing the hook never discards it unread; =checkupdates= for =--dry-run=; topgrade's own step switches; the MERGE precedent for APPLY's detached terminal; the MARK KNOWN arm-then-fire idiom for DISMISS; the BRIO rule's sed templating for the unit's =User==; =zfs-pre-snapshot='s pre-pacman snapshots, which the gate requires and the hold pins; and =tests/zfs-pre-snapshot/fake-zfs= in the tests. maint's TOML =guard_patterns= is today a divergent list, not a copy of the hook. Phase 1 rewrites it to exactly the hook's 15 =Target= patterns and keeps it as the panel's display-side mirror, pinned by a =tests/installer-steps/= test that reads neither =/etc= nor =~/.config=.
+- Architecture fit & weak points: integration points are the guard hook file (read for its =Target= lines), the pacman sync db and package cache, sudo, getty@tty1 login ordering, the pre-pacman snapshots on a ZFS root, and the maint cache and panel. The first weak point is the =Before=getty@tty1.service= ordering, which is load-bearing for safety. It holds the tty1 login, and with it =~/.profile.d/99-hyprland-autostart.sh=, until the unit ends; a parallel run would reintroduce the live-swap crash. I made the ordering explicit and pinned it with the unit's source-inspection test (P3-unit). The second is that the panel (dotfiles) calls a binary that existing machines get by a one-time hand install from archsetup, so the per-machine order in Phase 4, Rollout, is load-bearing too.
+- Config surface: nothing to tune. The GPU patterns come from the hook, the flag path is fixed, and the boot timeout is fixed at 20 minutes with SIGINT, keeping the 90 s default stop timeout as the SIGKILL backstop (Phase 3, The unit). =MAINT_STATE_DIR= is honoured as maint honours it; the =UPGRADE_GUARDED_*= and =KMC_*= variables are test seams, not configuration. The TOML =guard_patterns= is display-only. Defaults are safe: with no flag, the unit is skipped.
+- Documentation plan:
+ - Phase 1 rewrites the =guard_patterns= comment in =configs/maintenance-thresholds.toml= to 'display mirror of the hook's Target list, pinned by a test', and lands the =docs/workflows/system-health-check.org= edit as a doc commit right after its code. The exact edit is in Phase 1.
+ - Phase 2 rewrites the 'The live-update guard' section of dotfiles =maint/README.md= for the new levers, the deferred row, APPLY and the armed state, dropping press-again and =--force=. Its stale-text bullet lists the docstrings, usage line and wrapper header it rewords.
+ - Phase 4 adds the =--complete= flow and the recovery steps to that README section. Phase 4, Recovery, is the only copy of the recovery steps.
+ - The installer steps self-document in comments.
+- Dev tooling: unittest only (Testing). Phase 1, Seams and fakes, is the only list of seams and fakes; each phase names its test groups, and Testing maps them to the criteria and states the VM gate.
+- Rollout, compatibility & rollback: not additive, because Phase 2 repoints UPDATE, TOPGRADE and the =sysupgrade= alias and removes the force path. Existing machines need the one-time steps, in order, in Phase 4, Rollout; Phase 4, Rollback, reverts dotfiles and docs first and lists what can stay. The guard hook's behavior is untouched throughout.
+- External APIs & deps:
+ - pacman (=-Sy=, =-Su=, =-S= with versioned targets, =-Sw=, the =-Sup= and =-Sp= print modes, =-Q=, =-Qu=, =-Qqo=), =vercmp= and =sudo=;
+ - =yay -Sua= and =-Pwq=;
+ - topgrade at =/usr/bin/topgrade=, whose disabled steps go as separate argv elements because topgrade 17.12.2 rejects the comma form with exit 2 (exact argv: Phase 1, Everyday sequence, step 8);
+ - =informant read --all= where installed (velox; absent on ratio), under =timeout=;
+ - =checkupdates= (pacman-contrib; =--dry-run= only);
+ - =dkms status=, plus =findmnt=, =lsinitcpio= and =zfs list=, =snapshot=, =hold= and =release= on a ZFS root;
+ - =flock=, =pgrep= and =timeout=;
+ - =systemctl is-enabled=, =systemctl reboot= and =systemd-cat=;
+ - =foot= (APPLY's terminal);
+ - =$HOME/.local/bin/maint stamp topgrade=. maint is on the user's login PATH only (=~/.local/bin=, from dotfiles), not on a system unit's PATH, so the script calls it by absolute path;
+ - no external service beyond the Arch mirrors and news feed that pacman, yay and informant already use.
+
+* Risks, Rabbit Holes, and Drawbacks
+- Boot critical path: the unit runs ahead of the tty1 login, so a hang delays boot. A fixed 20-minute start timeout ends the run with SIGINT, the default stop timeout is the SIGKILL backstop, and nothing the session needs depends on the unit. The worst case is a 20-minute delay (plus 90 s if SIGINT doesn't end the run), then the login, with the record and maint's failed-units row both showing it. The flag leaves its path before the attempt starts, so every outcome, power loss included, disarms and no later boot retries. See Phase 3, The unit.
+- Boot package source: the boot form installs the flag's exact versions from the cache that arming filled, with no refresh and no network, so the flag, the cache and the system sync db must still agree at boot. A manual cache clean, or a refresh that didn't re-arm (a bare =pacman -Sy= or =yay=), between arming and reboot breaks that; the boot run then fails before any package changes, the record names it, and =upgrade-guarded --complete= re-arms. =paccache -k3= keeps the newest downloads, an armed everyday run re-arms against its own refresh, and =--complete= removes any flag right after its refresh, so only its own arm step leaves one. See Phase 1, The arm flag.
+- Interactive prompts under no stdin: pacman and yay can still reach a question (provider choice, replace, conflict) even with =--noconfirm=, which takes the default; a default of no fails the step, benignly. The boot form's single versioned transaction shrinks this to near zero, and the sweep's =-y= and =--no-ask-retry= keep a failed topgrade step from waiting on stdin under the panel's no-tty runner.
+- Partial ecosystem state: pacman is atomic per package only, and only when allowed to stop at a package boundary. It has no SIGTERM handler, so the unit stops it with SIGINT, which ends the transaction at the next boundary and releases =db.lck=. An interrupted boot run can leave part of the GPU/compositor set upgraded; the pre-written record already names the remedy. Power loss, or the SIGKILL backstop landing mid-package, can still leave a half-extracted package and a stale =db.lck=, which nothing here deletes: every later transacting run refuses and names it with the manual remedy. The =--ignore= run is itself a partial upgrade; the closure probe is its safety net, and I accept the residual unversioned-ABI exposure in Alternative E. See Phase 1, Preconditions and Exit codes and interrupts.
+- Orphans that block resolution: a package dropped from the repo but still pinning an old version (ratio's =qemu-block-gluster= on 2026-08-25) would fail the whole transaction. The closure probe sees it first, as a "breaks dependency" line whose requirer isn't held, and the script refuses with the names before any transaction, never with =-Rdd= or force. Removing or rebuilding the orphan stays manual. See Phase 1, Dependency closure.
+- Refreshed db after a refusal: a refused closure leaves the system sync db refreshed and nothing upgraded. Until an =upgrade-guarded= run succeeds again, don't install single packages with =pacman -S=: against the newer db that is a partial upgrade with no closure behind it. The deferred row shows the refusal at WARN until then.
+- The kernel and DKMS modules on a ZFS root: DKMS removes the old modules before the transaction and rebuilds after it (=70-dkms-upgrade=, =70-dkms-install=), so a failed =zfs-dkms= build aborts nothing, and mkinitcpio still writes an initramfs without =zfs.ko=, warning only. Velox then fails its next boot, whether the transaction moved the kernel or only =zfs-dkms=. So every everyday run holds the kernel and DKMS sets (and what the closure pulls in, such as =zfs-utils=), and they land only in =--complete='s stage 1 (or through yay, which then opens the gate entry), behind the =kernel-modules-check= gate. The gate entry is written before stage 1 starts, so a crashed or interrupted stage 1 already reads "do not reboot", and it closes only when a gate passes or the kernels it names are gone. While it is open, nothing arms or offers a reboot and the row is CRIT, so the machine keeps running the old kernel. When the entry is the run's own and stage 1 changed nothing kernel-side, the gate runs in its structural form, so a transaction that failed before committing raises no false alarm. Ratio's exposure is its data pool only (btrfs root); its GRUB default, the foreign =linux-lts-strix=, has no headers and is never pending, so neither stage 1 nor the gate touches it. See Phase 1, --complete sequence, The gate and Invariants.
+- Last-resort fallback on velox: an unplanned reboot while the gate is open, or a failure the gate misses, is recovered from ZFSBootMenu. =/boot= lives in the root dataset, which the =05-zfs-snapshot= hook snapshots before each transaction, so ZFSBootMenu can boot the old kernel, initramfs and modules. The hook skips a snapshot within 60 s of the last and only warns on failure, which is why the =--since= gate requires one taken no earlier than 60 s before its since time. =zfs-pre-snapshot= keeps only the newest ten, so =--complete= holds a pre-pacman snapshot from before its stage 1 and names it in the record (Phase 1, The snapshot hold), and the prune skips it. The cost is one pinned snapshot per machine, growing until a later =--complete=, or an everyday run whose AUR step opens the entry, holds a newer one; a failed hold only warns. The pacman db and cache are separate datasets that stay current, so booting the snapshot reverts every package changed since it was taken while the db still lists the new versions. Before the next =upgrade-guarded= run, reconcile the db to the booted root from =/var/log/pacman.log= (on =zroot/var/log=, also current); never roll back =zroot/var/lib/pacman=. See Phase 1, The snapshot hold, and Phase 4 for the recovery steps and the non-gating D-zbm drill.
+- Standing kernel deferral: because the everyday run never moves the kernel or DKMS sets, the dedicated session (=upgrade-guarded --complete=) has to happen on a cadence or kernel security fixes sit unapplied. The deferred row is the reminder, and it goes to WARN when a fixable advisory names a deferred package; a stale-kernel age in maint is the vNext follow-up (see Scope tiers).
+- Entry points outside the hold: bare =topgrade=, =yay= and =pacman -Syu= still land the kernel and DKMS sets with no gate, because the guard hook is silent on kernels. The hold covers only =upgrade-guarded='s callers: the panel levers, the =sysupgrade= alias where =upgrade-guarded= is installed, and the system-health-check workflow's update step.
+- AUR paths around the hold: yay resolves repo dependencies itself, so an AUR upgrade that needs a newer kernel-set or DKMS-set package installs it ungated. The everyday run detects that after yay and opens the gate entry, so the row goes CRIT and REBOOT hides until =--complete= verifies it (Phase 1, Everyday sequence, step 7); no installed foreign package has such a dependency today. A DKMS package from the AUR would bypass the hold entirely through =yay -Sua=, so the DKMS set must come from the sync repos (Phase 4's step (a) checks it). An AUR upgrade that needs a newer held GPU/compositor package while Hyprland is live trips the guard inside yay's own pacman call: the AUR step fails (the row shows WARN), the sweep still runs, and nothing stamps. See Phase 1, Everyday sequence.
+- Arch news: the driven path clears informant's feed under a 60 s timeout before any transaction, so once the clear succeeds, informant's AbortOnFail hook no longer stops a run. A failed or timed-out clear leaves the =00-informant= hook live for that run's transaction, which can then wait on the same unbounded fetch (bounded by the lever runner's 3600 s or the unit's 20 minutes, and in a terminal run only by Ctrl+C) or abort on unread news before any package changes, with a named failed step; I accept that residual. At boot, offline, the clear and the hook both fail fast against an empty feed, but a network that comes up in between can let the hook abort the transaction after the arm is consumed; =upgrade-guarded --complete= then finishes it. The deferred row is now where news gets seen: the everyday run and =--complete= capture unread titles with =yay -Pwq= before clearing, and the row shows them until DISMISS hides them. A manual-intervention notice waits on the row instead of blocking the transaction it warns about. See Phase 1, Everyday sequence and --apply-armed sequence, and Phase 2, The deferred row.
+
+* Testing / Verification / Rollout
+Every automated test uses unittest, never pytest. archsetup's =make test-unit= globs =tests/*/test_*.py=, so the new suites run without a list edit, and the Phase 2 groups run in dotfiles =tests/maint/= without GTK.
+Each group and manual test named here is defined once, with its cases, in its phase: the P1 groups and M-split-console in Phase 1, the P2 groups in Phase 2, P3-unit, the M-boot tests and the VM procedure in Phase 3, and M-split-live and D-zbm in Phase 4. Every manual test is its own entry in =todo.org= under Manual testing and validation.
+
+Each acceptance criterion is verified by:
+- AC1 (the live everyday run): P1-sets, P1-closure, P1-steps, P1-record-stamp, P2-levers, P2-row, P2-cve-queue and M-split-live. M-split-live carries the hook-silent and exit-0 outcomes on both daily drivers.
+- AC2 (soname day and the closure refusal): P1-closure and P1-sets.
+- AC3 (the run with Hyprland not running): P1-sets, P1-record-stamp and M-split-console. P1-record-stamp carries the stamp clause, and M-split-console the held-set claim.
+- AC4 (the kernel-side session and its gate): P1-gate, P1-complete, P2-row, P2-apply and M-boot-armed. P2-apply pins the routing inside =panel.fire_press=, and M-boot-armed's APPLY press on ratio and velox carries the clause that the panel calls it and opens the detached =foot= terminal, since no automated test imports gui.py.
+- AC5 (arming and the armed boot): P1-arm-boot, P2-row, P3-unit, M-boot-armed and M-boot-ratio.
+- AC6 (a boot-upgrade failure never blocks the session): P1-arm-boot, P3-unit, M-boot-timeout, M-boot-midtx and M-boot-fail.
+- AC7 (Arch news): P1-steps, P2-levers (the lever timeout's SIGINT), P2-dismiss and M-boot-news.
+- AC8 (the =--complete= stamp): P1-record-stamp.
+- AC9 (APPLY, the wall note and the no-record row): P2-row, P2-apply and P2-text.
+- AC10 (a failed AUR step): P1-steps and P2-row.
+- AC11 (advisories on deferred packages): P2-cve-queue.
+- AC12 (the guard hook unchanged): the existing =tests/hypr-live-update-guard= suite, unchanged; P1-sets (fail closed); and P1-installer (TOML pin, hook name).
+- AC13 (the held snapshot on a ZFS root): P1-complete and P1-installer.
+P2-guard-ux gates no criterion. It pins Phase 2's guard-UX changes and runs with the rest of that suite.
+
+No unit test can drive the boot path itself: an armed reboot that applies the set on tty1 before the login, then lets boot continue however the run ends. That is why the M-boot tests are manual.
+I hold the boot unit back from velox until the =make test-keep FS_PROFILE=zfs= VM passes every M-boot test except M-boot-news (run on velox) and M-boot-ratio, plus M-split-console. The VM runs follow Phase 3's VM procedure. The VM arms over SSH rather than from the panel, so arming from APPLY, the armed row and REBOOT get checked on ratio and velox under AC5.
+Two runs show the real gate reading a root-only initramfs. The first is M-boot-armed's =--complete=, which must pass the gate on the VM's ZFS root. The second is the structural =kernel-modules-check= that Phase 4 step (a) runs on each ZFS root.
+
+Rollout, rollback, recovery and the non-gating D-zbm drill live only in Phase 4.
+
+* Review and iteration history
+** 2026-10-05 Mon @ 12:10:00 -0500 — Craig Jennings — responder + reviewer
+- What: responded to three review rounds and moved the spec to READY. Round 1 (48 findings, 13 blocking): 7 accepted, 41 accepted with reconciling detail, none rejected. The design calls accepted were the pacman-resolved dependency closure, a boot form (=--apply-armed=) that installs a pre-downloaded set from cache with no network, a SIGINT/20-minute boot timeout that disarms at the start, and the UPDATE/TOPGRADE lever mapping with the script as the only writer of =topgrade_run=. Round 2 re-reviewed the revised spec (59 findings, 4 blocking: 36 accepted, 23 modified) and consolidated the exact contract into Implementation phases, which every other section now cites. It also cut machinery with no caller: =--no-aur=, unread record fields, kernel-modules-check's standalone mode, and the three-clause gate list, replaced by one pkgbase-keyed entry written before stage 1. Round 3 (22 findings, none blocking: 12 accepted, 10 modified) closed safety gaps around interrupts, the pre-stage-1 snapshot hold and power-cut recovery. A bounded repair pass followed; its 9 remaining non-blocking residuals stay open under Review findings.
+- Why: the decisions closed on 2026-08-25 left the first draft's text in place, and several assumptions about installed tools didn't hold (topgrade's argv, informant run as the user, root-only initramfs reads, checkupdates' db). Restating the contract in several sections caused drift between the copies, so I made one section authoritative.
+- Artifacts: the Review findings section; tool versions checked: topgrade 17.12.2, pacman 7.1.0, bash 5.3.20, sudo 1.9.17p2.
+** 2026-10-05 Mon @ 06:05:00 -0500 — Craig Jennings — reviewer
+- What: spec-review, rubric Not ready. Six independent passes read the spec against the live code in archsetup and the dotfiles (factual accuracy, internal consistency, boot-path safety, pacman/yay/topgrade mechanics, phasing and tests, panel UX), each finding was checked by an adversarial verifier that tried to refute it, and a completeness pass looked for gaps. 89 raw findings merged to 45 plus 3 from the completeness pass; 48 survived verification (13 blocking, 27 should-fix, 8 optional) and 2 were refuted (velox facts unverifiable from ratio, and a battery condition for the boot run). The Phases section is present and decomposable once the blockers close.
+- Why: the decisions closed on 2026-08-25 left text from the first draft in Design, Readiness and Risks, and several tool assumptions (topgrade's argv, informant run as the user, checkupdates' db, the kernel-set glob) didn't hold against the installed versions. The blockers cluster in four places: existing stamp writers that would mark a deferred run fresh, the split run's dependency handling, the kernel gate on ratio, and an undefined boot-run form (package source, timeout, news clearing).
+- Artifacts: the Review findings section above; tool versions checked this pass: topgrade 17.12.2, pacman and yay as installed on ratio.
+** 2026-10-05 Mon @ 05:14:17 -0500 — Craig Jennings — author
+- What: named the split script =upgrade-guarded= (was "name open") and added =containers= to the topgrade =--disable= list in Design and Phase 1.
+- Why: the containers step exits non-zero whenever local-only images exist (=cj/telega-server= and friends), which alone keeps topgrade from exiting 0. Pulling newer images under running containers isn't an upgrade path I use, and an ignore list would rot as images come and go.
+- Artifacts: velox health checks of 2026-09-12 and 2026-10-04.
+** 2026-08-25 Tue @ 18:45 -0600 — Craig Jennings — author
+- What: closed all seven decisions. Reversed the kernel decision (hold on every everyday run; land only in the dedicated session, gated on DKMS built, initramfs fresh, snapshot present; withdrew "install live at apply-on-reboot"), reworded the boot-scope decision for the split design, added the =kernel-modules-check= gate to Phase 1 and the acceptance criteria, and wrote the velox failure chain and the ZFSBootMenu fallback into Risks.
+- Why: on velox a failed =zfs-dkms= rebuild after a kernel swap is unabortable and unbootable; that belongs in a session I chose, not in an update I expected to touch applications.
+- Artifacts: this session's log (velox boot layout verified live: ZBM on the ESP, =/boot= in =zroot/ROOT/default=, one kernel, =zfs-dkms 2.4.4=).
+** 2026-08-25 Tue @ 18:30 -0600 — Craig Jennings — author
+- What: made the split live run (E) the everyday path and the boot oneshot (D) the completion step; added the script-ownership, stamp-semantics, and kernel-hold decisions; rewrote the phases around the script; added the orphan-blocks-resolution risk.
+- Why: watching a 724-of-730 guarded run succeed by hand on ratio made it obvious the guard's abort should be the rare path, and that the logic belongs in an editable script the panel calls rather than in the panel.
+- Artifacts: this session's log; the ratio run (=ratio-upgrade.service=, =/var/log/ratio-upgrade.log=).
+** 2026-08-25 Tue @ 06:39:42 -0600 — Craig Jennings — author
+- What: initial draft.
+- Why: the topgrade-freshness metric reads permanently stale because the guard blocks the arch step; designing a safe completion path rather than loosening the gate.
+- Artifacts: this session's log; =hypr-live-update-guard= hook; maint =topgrade_age= probe.
diff --git a/docs/verification/manual-testing.org b/docs/verification/manual-testing.org
new file mode 100644
index 0000000..3a5ebbb
--- /dev/null
+++ b/docs/verification/manual-testing.org
@@ -0,0 +1,93 @@
+#+TITLE: Manual Testing
+#+AUTHOR: Craig Jennings
+
+* Manual testing and validation
+
+** Hyprland 0.57 Lua port survives a fresh login on velox
+What we're verifying: waybar's reservation and the at_start chain work from a cold start, not only after a reload.
+- Log out of Hyprland.
+- Start Hyprland again from tty1 if it doesn't start on its own.
+#+begin_src sh :results output
+hyprctl configerrors
+#+end_src
+Expected: waybar sits fully on screen at the top, tiled windows start below it, and the block prints nothing.
+
+** Hyprland 0.57 focus keys
+What we're verifying: focus movement still works through hl.dsp calls.
+- Open two tiled windows on one workspace.
+- Press Super+J.
+- Press Super+K.
+Expected: focus moves to the other window on each press.
+
+** Hyprland 0.57 swap into master
+What we're verifying: Super+Return's two layout messages both run.
+- Open two tiled windows on one workspace.
+- Focus the window that isn't master.
+- Press Super+Return.
+Expected: the window swaps into master and keeps focus.
+
+** Hyprland 0.57 split resize
+What we're verifying: the layout-resize script changes the split.
+- Open two tiled windows on one workspace.
+- Press Super+H.
+- Press Super+L.
+Expected: the split moves one way and then back.
+
+** Hyprland 0.57 layout toggles
+What we're verifying: Super+Shift+M and Super+Shift+T work as Lua functions.
+- Press Super+Shift+M.
+- Press Super+Shift+T.
+Expected: the first key goes to monocle and the second returns to tiled.
+
+** Hyprland 0.57 push-to-talk
+What we're verifying: push-to-talk binds its trigger through hl.bind and releases it cleanly.
+- Turn push-to-talk on from the audio panel.
+- Hold the grave key (the default trigger) and speak.
+- Release the key.
+- Turn push-to-talk off.
+- Type a grave character into a text field.
+Expected: the mic opens only while held, and the grave key types normally afterward.
+
+** Hyprland 0.57 touchpad toggle
+What we're verifying: toggle-touchpad reaches the device through hl.device on velox.
+#+begin_src sh :results output
+toggle-touchpad
+#+end_src
+- Try the touchpad.
+- Run the block again.
+- Try the touchpad again.
+Expected: the touchpad turns off after the first run and back on after the second.
+
+** Hyprland 0.57 touchpad follows a mouse
+What we're verifying: touchpad-auto reacts to a mouse on velox.
+- Plug in a mouse.
+- Try the touchpad.
+- Remove the mouse.
+- Try the touchpad again.
+Expected: the touchpad is off while the mouse is in and on after it's removed.
+
+** Hyprland 0.57 theme colors
+What we're verifying: set-theme applies borders through hl.config.
+#+begin_src sh :results output
+hyprctl eval 'hl.config({general={col={active_border="rgba(ff0000ff)"}}})'
+set-theme dupre
+#+end_src
+- Check the active window's border.
+Expected: the border shows dupre's color, not red.
+
+** Hyprland 0.57 bluetooth and network panels
+What we're verifying: both panels resize through the Lua form after their output well closes.
+- Open the net panel.
+- Run DOCTOR to expand the output well.
+- Collapse the well.
+- Open the bt panel.
+- Run DOCTOR to expand the output well.
+- Collapse the well.
+Expected: each panel shrinks back to its normal size.
+
+** Hyprland 0.57 wlogout logout
+What we're verifying: wlogout ends the session through the Lua form.
+- Save your work, since this ends the session.
+- Open wlogout.
+- Choose logout.
+Expected: Hyprland exits and the desktop closes.
diff --git a/docs/workflows/system-health-check.org b/docs/workflows/system-health-check.org
index b4f34a5..43aeff5 100644
--- a/docs/workflows/system-health-check.org
+++ b/docs/workflows/system-health-check.org
@@ -235,6 +235,16 @@ Updates are separate from issue investigation. After all issues are addressed (o
5. *Host-specific kernel watches.* On ratio: if =linux=, =linux-lts=, =linux-firmware=, or a major =mesa= bump is pending, run the addendum at [[file:strix-soak-watch.org][docs/workflows/strix-soak-watch.org]] before topgrade. Retire the addendum (delete the file + this bullet) when the strix-lts custom kernel is retired.
6. Run =topgrade= for the actual update (config at =~/.config/topgrade.toml=). On maint hosts (ratio, velox) plain =topgrade= resolves to the dotfiles PATH wrapper, which stamps the console's topgrade-freshness metric on success — no extra step. If the run happened outside the wrapper somehow, =maint stamp topgrade= records it by hand.
7. If linux-firmware, kernel, or Mesa were updated, recommend a reboot
+8. On a ZFS-root host, after a kernel bump confirm the new initramfs carries the zfs module before rebooting: =sudo lsinitcpio /boot/initramfs-linux-lts.img | grep -c 'zfs.ko'=. The =sudo= is load-bearing: the images are 0600, so an unprivileged =lsinitcpio= exits 1 with "Unable to read file" on stderr and nothing on stdout, and once piped into =grep -c= that empty stdout reads as a count of 0 and looks exactly like a missing module (velox, 2026-09-12).
+
+*** Before a firmware (BIOS) update
+
+Firmware stays a manual step (=topgrade.toml= keeps =[firmware] upgrade = false=); =fwupdmgr update= stages it and the next reboot flashes it. Before staging, capture the two things a bad reboot will make you guess at:
+
+1. =sudo efibootmgr -v= — every boot entry with its loader path and command line, pasted into the session's context file.
+2. Secure Boot state — =bootctl status 2>/dev/null | grep -i 'secure boot'=.
+
+After the flash, if the machine reports no bootable device, check Secure Boot *first*. The Framework 04.02 update on velox re-enabled it, which rejects the unsigned ZFSBootMenu loader and reads as "Default Boot Device Missing" rather than a security violation; the boot entries were untouched (see the Known Issues Log, 2026-09-12). Only when Secure Boot is off and =efibootmgr -v= from a stick shows the entry gone does the boot-entry recreate apply (velox: =docs/2026-08-15-velox-uefi-boot-entry-reference.org= in archsetup).
*** Two-Stage Reboot Pattern (MANDATORY if Phase 3 installed kernel / iproute2 / systemd / NetworkManager)
@@ -1036,3 +1046,28 @@ Each entry is scoped to one host (or =any=). When Phase 1 cross-references findi
- Functional status at 2026-06-13 check: no coredumps yet that day; Telegram scans still worked from cached chat state. Treat as an app/server-container crash, not a machine-health fault.
- Classification: KNOWN — annotate future =telega-server= coredumps on ratio as =KNOWN — dockerized telega-server musl SIGSEGV= if the signature matches =tdat_plist_value= / unexpected plist value or otherwise stays inside the Telega container. Escalate only if crashes become continuous, break Telegram workflows, or appear after moving off the Docker musl build.
- Deferred remediation options, in order of least disruption: update the Emacs =telega= package, rebuild/pull a newer =telega-server= image, pin a known-good pre-2026-06 image digest, build =telega-server= natively, or report upstream with =coredumpctl= and log evidence.
+
+** 2026-09-12: velox — Framework BIOS update re-enabled Secure Boot (reads as "no bootable device")
+:host: velox
+- Symptom: after fwupd staged system firmware 0.0.3.5 → 0.0.4.2 and the reboot flashed it, the BIOS reported "Default Boot Device Missing / no bootable drive". Indistinguishable from the NVRAM wipe that forced the 2026-08-13 reinstall.
+- Actual cause: the update set Enforce Secure Boot = Enabled. The unsigned ZFSBootMenu loader was rejected and the firmware reported it as a missing device, not a security violation. Boot0001 ZFSBootMenu survived intact with its command line; no =efibootmgr= was needed.
+- The tell: booting the Ventoy stick showed shim's MOK management screen.
+- Fix: F2 → Security → Secure Boot → Enforce Secure Boot = Disabled, F10. Boots straight into ZBM. Firmware confirmed at 04.02, pools healthy.
+- Prevention: the pre-firmware-update checklist in Phase 3 (record =efibootmgr -v= and the Secure Boot state before staging). Check Secure Boot before assuming NVRAM loss.
+- Still open: velox's ESP has no removable-media fallback (=/efi/EFI/BOOT= absent), so a real NVRAM wipe would still need the stick. Filed in archangel, which owns the ZBM install, as [#C] "Installed systems have no removable-media boot fallback on the ESP" (2026-09-12); it ships with the next ISO rebuild after it lands.
+
+** 2026-09-12: any — fwupdmgr activates passim, a public LAN listener
+:host: any
+- Symptom: running =fwupdmgr= (refresh, update) D-Bus-activates =passim.service=, fwupd's LAN metadata-sharing daemon, which listens on =0.0.0.0:27500= and trips the maint listeners check to crit.
+- The unit is static (no =[Install]= section), so =systemctl disable= is a no-op and it comes back on the next fwupdmgr run. Masking is what holds: =systemctl mask passim.service=. Ratio has been masked since 2026-07-21; velox was stopped and disabled on 2026-09-12 (the disable being the no-op) and got the mask on 2026-09-13. The installer masks it as part of installing fwupd. =P2pPolicy=nothing= under =[fwupd]= in =/etc/fwupd/fwupd.conf= also works, but that file is pacman-owned and invites pacnew churn, so the mask is the form in use.
+- Classification: KNOWN — a passim listener means a machine that predates the mask or lost it; mask it, don't allowlist it.
+
+** 2026-09-12: velox — topgrade containers step fails on locally built images
+:host: velox (ratio has the same shape with its own local images)
+- Symptom: topgrade exits 1 after a clean package run because the containers step tries to =docker pull= images that were built locally (=cj/telega-server=, =telega-server-glycin=) and gets "pull access denied". The wrapper then never writes the topgrade-freshness stamp; =maint stamp topgrade= by hand after confirming the package steps succeeded.
+- Classification: KNOWN — the step cannot succeed while local-only images exist. The fix (disable the containers step, or list the images under =ignored_containers=) is tracked on the topgrade guarded-upgrade task in archsetup's todo.
+
+** 2026-09-12: velox — mkinitcpio "Possibly missing firmware" for xhci_pci_renesas and qat_6xxx
+:host: velox
+- Stock Arch mkinitcpio noise on a kernel rebuild: =xhci_pci_renesas= wants the Renesas USB controller blob (AUR =upd72020x-fw=) and =qat_6xxx= is Intel QuickAssist firmware. Neither is hardware this machine has.
+- Classification: KNOWN — harmless; annotate and move on unless the named hardware appears.
diff --git a/packages/gnome-keyring/0001-daemon-Add-Cinnamon-to-autostart-files.patch b/packages/gnome-keyring/0001-daemon-Add-Cinnamon-to-autostart-files.patch
new file mode 100644
index 0000000..980d537
--- /dev/null
+++ b/packages/gnome-keyring/0001-daemon-Add-Cinnamon-to-autostart-files.patch
@@ -0,0 +1,51 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: "Jan Alexander Steffens (heftig)" <heftig@archlinux.org>
+Date: Sat, 21 May 2022 18:47:23 +0000
+Subject: [PATCH] daemon: Add Cinnamon to autostart files
+
+Cinnamon also wants to use gnome-keyring-daemon.
+---
+ daemon/gnome-keyring-pkcs11.desktop.in.in | 2 +-
+ daemon/gnome-keyring-secrets.desktop.in.in | 2 +-
+ daemon/gnome-keyring-ssh.desktop.in.in | 2 +-
+ 3 files changed, 3 insertions(+), 3 deletions(-)
+
+diff --git a/daemon/gnome-keyring-pkcs11.desktop.in.in b/daemon/gnome-keyring-pkcs11.desktop.in.in
+index 3e36603fac70..bb729caddfd1 100644
+--- a/daemon/gnome-keyring-pkcs11.desktop.in.in
++++ b/daemon/gnome-keyring-pkcs11.desktop.in.in
+@@ -3,7 +3,7 @@ Type=Application
+ Name=Certificate and Key Storage
+ Comment=GNOME Keyring: PKCS#11 Component
+ Exec=@bindir@/gnome-keyring-daemon --start --components=pkcs11
+-OnlyShowIn=GNOME;Unity;MATE;
++OnlyShowIn=GNOME;Unity;MATE;Cinnamon;
+ NoDisplay=true
+ X-GNOME-Autostart-Phase=PreDisplayServer
+ X-GNOME-AutoRestart=false
+diff --git a/daemon/gnome-keyring-secrets.desktop.in.in b/daemon/gnome-keyring-secrets.desktop.in.in
+index 799caeea4306..6c836a2a7ec9 100644
+--- a/daemon/gnome-keyring-secrets.desktop.in.in
++++ b/daemon/gnome-keyring-secrets.desktop.in.in
+@@ -3,7 +3,7 @@ Type=Application
+ Name=Secret Storage Service
+ Comment=GNOME Keyring: Secret Service
+ Exec=@bindir@/gnome-keyring-daemon --start --components=secrets
+-OnlyShowIn=GNOME;Unity;MATE;
++OnlyShowIn=GNOME;Unity;MATE;Cinnamon;
+ NoDisplay=true
+ X-GNOME-Autostart-Phase=PreDisplayServer
+ X-GNOME-AutoRestart=false
+diff --git a/daemon/gnome-keyring-ssh.desktop.in.in b/daemon/gnome-keyring-ssh.desktop.in.in
+index fd7657a4d0cc..8f9847033507 100644
+--- a/daemon/gnome-keyring-ssh.desktop.in.in
++++ b/daemon/gnome-keyring-ssh.desktop.in.in
+@@ -3,7 +3,7 @@ Type=Application
+ Name=SSH Key Agent
+ Comment=GNOME Keyring: SSH Agent
+ Exec=@bindir@/gnome-keyring-daemon --start --components=ssh
+-OnlyShowIn=GNOME;Unity;MATE;
++OnlyShowIn=GNOME;Unity;MATE;Cinnamon;
+ X-GNOME-Autostart-Phase=PreDisplayServer
+ X-GNOME-AutoRestart=false
+ X-GNOME-Autostart-Notify=true
diff --git a/packages/gnome-keyring/0002-secret-service-missing-client.patch b/packages/gnome-keyring/0002-secret-service-missing-client.patch
new file mode 100644
index 0000000..9ef298b
--- /dev/null
+++ b/packages/gnome-keyring/0002-secret-service-missing-client.patch
@@ -0,0 +1,275 @@
+diff -ruN a/daemon/dbus/gkd-secret-objects.c b/daemon/dbus/gkd-secret-objects.c
+--- a/daemon/dbus/gkd-secret-objects.c 2026-09-17 15:41:00.000000000 -0500
++++ b/daemon/dbus/gkd-secret-objects.c 2026-10-17 06:47:19.275009074 -0500
+@@ -430,7 +430,11 @@
+
+ /* The session we're using to access the object */
+ session = gkd_secret_service_get_pkcs11_session (self->service, sender);
+- g_return_val_if_fail (session, FALSE);
++ if (session == NULL) {
++ g_free (c_ident);
++ g_free (i_ident);
++ goto out;
++ }
+
+ if (i_ident) {
+ gck_builder_add_ulong (&builder, CKA_CLASS, CKO_SECRET_KEY);
+@@ -1055,17 +1059,23 @@
+ if (!gkd_secret_util_parse_path (path, &identifier, NULL))
+ return NULL;
+
+- if (g_str_has_prefix (path, SECRET_ALIAS_PREFIX))
+- real_identifier = gkd_secret_service_get_alias (self->service, identifier);
+- else
+- real_identifier = identifier;
+-
+- /* The session we're using to access the object */
++ /* The session we're using to access the object. Resolve it before the
++ * alias: fetching a caller's session may register the caller, which
++ * refreshes the default alias and frees the string the alias table
++ * held, so an alias looked up earlier would dangle here. */
+ if (caller == NULL)
+ session = gkd_secret_service_internal_pkcs11_session (self->service);
+ else
+ session = gkd_secret_service_get_pkcs11_session (self->service, caller);
+- g_return_val_if_fail (session, NULL);
++ if (session == NULL) {
++ g_free (identifier);
++ return NULL;
++ }
++
++ if (g_str_has_prefix (path, SECRET_ALIAS_PREFIX))
++ real_identifier = gkd_secret_service_get_alias (self->service, identifier);
++ else
++ real_identifier = identifier;
+
+ gck_builder_add_ulong (&builder, CKA_CLASS, CKO_G_COLLECTION);
+ gck_builder_add_string (&builder, CKA_ID, real_identifier);
+diff -ruN a/daemon/dbus/gkd-secret-service.c b/daemon/dbus/gkd-secret-service.c
+--- a/daemon/dbus/gkd-secret-service.c 2026-09-17 15:41:00.000000000 -0500
++++ b/daemon/dbus/gkd-secret-service.c 2026-10-17 06:47:19.274715149 -0500
+@@ -137,6 +137,9 @@
+ GHashTable *clients;
+ GkdSecretObjects *objects;
+ GHashTable *aliases;
++ /* The message filter reads aliases on the GDBus worker thread while
++ * the main thread updates them; every access goes through this lock. */
++ GMutex aliases_lock;
+ GckSession *internal_session;
+ gchar *default_path;
+ };
+@@ -190,6 +193,18 @@
+ return g_build_filename (alias_directory, "default", NULL);
+ }
+
++/* An owned copy of an alias, safe to call from any thread. */
++static gchar *
++lookup_alias_copy (GkdSecretService *self, const gchar *alias)
++{
++ gchar *copy;
++
++ g_mutex_lock (&self->aliases_lock);
++ copy = g_strdup (g_hash_table_lookup (self->aliases, alias));
++ g_mutex_unlock (&self->aliases_lock);
++ return copy;
++}
++
+ static void
+ update_default (GkdSecretService *self)
+ {
+@@ -206,21 +221,31 @@
+ /* Default to to 'login' if no default keyring */
+ if (contents == NULL)
+ contents = g_strdup ("login");
+- g_hash_table_replace (self->aliases, g_strdup ("default"), contents);
++
++ /* Replace only on a real change: callers on the main thread hold the
++ * previous value borrowed, and the common case (another client
++ * registering) must not free it out from under them. */
++ g_mutex_lock (&self->aliases_lock);
++ if (g_strcmp0 (g_hash_table_lookup (self->aliases, "default"), contents) == 0)
++ g_free (contents);
++ else
++ g_hash_table_replace (self->aliases, g_strdup ("default"), contents);
++ g_mutex_unlock (&self->aliases_lock);
+ }
+
+ static void
+ store_default (GkdSecretService *self)
+ {
+ GError *error = NULL;
+- const gchar *identifier;
++ gchar *identifier;
+
+- identifier = g_hash_table_lookup (self->aliases, "default");
++ identifier = lookup_alias_copy (self, "default");
+ if (!identifier)
+ return;
+
+ if (!g_file_set_contents (self->default_path, identifier, -1, &error))
+ g_message ("couldn't store default keyring: %s", egg_error_message (error));
++ g_free (identifier);
+ }
+
+ static gboolean
+@@ -331,7 +356,7 @@
+ GDBusMessage *message)
+ {
+ const char *path = g_dbus_message_get_path (message);
+- const char *replace;
++ char *replace;
+ char *collection = NULL, *item = NULL;
+ char *collection_path, *item_path;
+ GDBusMessage *rewritten;
+@@ -346,7 +371,9 @@
+ if (!gkd_secret_util_parse_path (path, &collection, &item))
+ return message;
+
+- replace = gkd_secret_service_get_alias (self, collection);
++ /* This runs on the GDBus worker thread: take a copy under the lock
++ * rather than borrowing from a table the main thread may be updating. */
++ replace = lookup_alias_copy (self, collection);
+ if (!replace) {
+ g_free (item);
+ g_free (collection);
+@@ -356,6 +383,9 @@
+ rewritten = g_dbus_message_copy (message, &error);
+ if (error != NULL) {
+ g_error_free (error);
++ g_free (replace);
++ g_free (item);
++ g_free (collection);
+ return message;
+ }
+
+@@ -372,6 +402,7 @@
+ }
+
+ g_free (collection_path);
++ g_free (replace);
+ g_free (item);
+ g_free (collection);
+ g_object_unref (message);
+@@ -424,18 +455,30 @@
+ GError *error = NULL;
+ const gchar *caller;
+ GVariant *input_payload;
++ gboolean opened;
+
+ caller = g_dbus_method_invocation_get_sender (invocation);
+
+ /* Now we can create a session with this information */
+ session = gkd_secret_session_new (self, caller);
+ input_payload = g_variant_get_variant (input);
+- gkd_secret_session_handle_open (session, algorithm, input_payload,
+- &output, &result,
+- &error);
++ opened = gkd_secret_session_handle_open (session, algorithm, input_payload,
++ &output, &result,
++ &error);
+ g_variant_unref (input_payload);
+
+- if (error != NULL) {
++ /* Branch on the return value, not on the error alone: a negotiate helper
++ * that bails out through g_return_val_if_fail() reports failure without
++ * ever setting one, and completing the call with a NULL body makes
++ * g_variant_new() abort the whole daemon. */
++ if (!opened || output == NULL || result == NULL) {
++ if (error == NULL)
++ g_set_error_literal (&error, G_DBUS_ERROR,
++ G_DBUS_ERROR_FAILED,
++ "Couldn't open a session for this caller.");
++ if (output != NULL)
++ g_variant_unref (g_variant_ref_sink (output));
++ g_free (result);
+ g_dbus_method_invocation_take_error (invocation, error);
+ } else {
+ gkd_secret_service_publish_dispatch (self, caller,
+@@ -986,6 +1029,7 @@
+ static void
+ gkd_secret_service_init_aliases (GkdSecretService *self)
+ {
++ g_mutex_init (&self->aliases_lock);
+ self->aliases = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, g_free);
+ g_hash_table_insert (self->aliases, g_strdup ("session"), g_strdup ("session"));
+ /* TODO: We should be using CKA_G_LOGIN_COLLECTION */
+@@ -1151,6 +1195,7 @@
+
+ g_hash_table_destroy (self->aliases);
+ self->aliases = NULL;
++ g_mutex_clear (&self->aliases_lock);
+
+ g_free (self->default_path);
+ self->default_path = NULL;
+@@ -1283,7 +1328,18 @@
+ g_return_val_if_fail (caller, NULL);
+
+ client = g_hash_table_lookup (self->clients, caller);
+- g_return_val_if_fail (client, NULL);
++ if (client == NULL) {
++ /* The record is normally created by a high priority idle queued from
++ * the message filter, but that idle is not ordered against the method
++ * call that queued it: a peer which connects and calls straight away
++ * can reach us first. Create it here instead of failing the call --
++ * it is a hash table insert, not I/O. Callers must not hold borrowed
++ * alias strings across this call: registering a client refreshes the
++ * default alias. */
++ gkd_secret_service_ensure_client (self, caller);
++ client = g_hash_table_lookup (self->clients, caller);
++ g_return_val_if_fail (client, NULL);
++ }
+
+ /* Open a new session if necessary */
+ if (!client->pkcs11_session) {
+@@ -1399,7 +1455,9 @@
+ g_return_if_fail (GKD_SECRET_IS_SERVICE (self));
+ g_return_if_fail (alias);
+
++ g_mutex_lock (&self->aliases_lock);
+ g_hash_table_replace (self->aliases, g_strdup (alias), g_strdup (identifier));
++ g_mutex_unlock (&self->aliases_lock);
+
+ if (g_str_equal (alias, "default"))
+ store_default (self);
+@@ -1418,6 +1476,10 @@
+
+ /* Take ownership of the session */
+ client = g_hash_table_lookup (self->clients, caller);
++ if (client == NULL) {
++ gkd_secret_service_ensure_client (self, caller);
++ client = g_hash_table_lookup (self->clients, caller);
++ }
+ g_return_if_fail (client);
+ path = gkd_secret_dispatch_get_object_path (object);
+ g_return_if_fail (!g_hash_table_lookup (client->dispatch, path));
+diff -ruN a/daemon/dbus/gkd-secret-session.c b/daemon/dbus/gkd-secret-session.c
+--- a/daemon/dbus/gkd-secret-session.c 2026-09-17 15:41:00.000000000 -0500
++++ b/daemon/dbus/gkd-secret-session.c 2026-10-17 06:47:19.270295281 -0500
+@@ -183,7 +183,12 @@
+ gboolean ret;
+
+ session = gkd_secret_service_get_pkcs11_session (self->service, self->caller);
+- g_return_val_if_fail (session, FALSE);
++ if (session == NULL) {
++ g_set_error_literal (error_out, G_DBUS_ERROR,
++ G_DBUS_ERROR_FAILED,
++ "Couldn't open a session for this caller.");
++ return FALSE;
++ }
+
+ if (!aes_create_dh_keys (session, "ietf-ike-grp-modp-1024", &pub, &priv)) {
+ g_set_error_literal (error_out, G_DBUS_ERROR,
+@@ -250,7 +255,12 @@
+ GckSession *session;
+
+ session = gkd_secret_service_get_pkcs11_session (self->service, self->caller);
+- g_return_val_if_fail (session, FALSE);
++ if (session == NULL) {
++ g_set_error_literal (error_out, G_DBUS_ERROR,
++ G_DBUS_ERROR_FAILED,
++ "Couldn't open a session for this caller.");
++ return FALSE;
++ }
+
+ gck_builder_add_ulong (&builder, CKA_CLASS, CKO_SECRET_KEY);
+ gck_builder_add_ulong (&builder, CKA_KEY_TYPE, CKK_G_NULL);
diff --git a/packages/gnome-keyring/PKGBUILD b/packages/gnome-keyring/PKGBUILD
new file mode 100644
index 0000000..a8e32a2
--- /dev/null
+++ b/packages/gnome-keyring/PKGBUILD
@@ -0,0 +1,82 @@
+# Local rebuild of Arch's gnome-keyring 1:51.1-1 with the Secret Service
+# missing-client fix applied. I build from GNOME's release tarball (checksum
+# published by GNOME) instead of the signed git tag so the build does not need
+# the maintainer's key in the keyring of whichever machine builds it. pkgrel
+# 1.1 sorts above the stock 1:51.1-1 and below any future 1:51.1-2, so the
+# next upstream or Arch fix replaces it on a normal upgrade.
+# Maintainer: Jan Alexander Steffens (heftig) <heftig@archlinux.org>
+# Contributor: Jan De Groot <jgc@archlinux.org>
+
+pkgname=gnome-keyring
+pkgver=51.1
+pkgrel=1.1
+epoch=1
+pkgdesc="Stores passwords and encryption keys (Secret Service missing-client fix)"
+url="https://gitlab.gnome.org/GNOME/gnome-keyring"
+arch=(x86_64)
+license=(
+ GPL-2.0-or-later
+ LGPL-2.1-or-later
+)
+depends=(
+ dconf
+ gcr
+ gcr-4
+ glib2
+ glibc
+ libcap-ng
+ libgcc
+ libgcrypt
+ pam
+ systemd-libs
+)
+makedepends=(
+ docbook-xsl
+ git
+ glib2-devel
+ meson
+ p11-kit
+ python
+)
+provides=(org.freedesktop.secrets)
+groups=(gnome)
+install=gnome-keyring.install
+source=(
+ "https://download.gnome.org/sources/gnome-keyring/${pkgver%%.*}/gnome-keyring-$pkgver.tar.xz"
+ 0001-daemon-Add-Cinnamon-to-autostart-files.patch
+ 0002-secret-service-missing-client.patch
+)
+sha256sums=('dfe7adc5876ef0670e7eba2907799bdbb8875ce58bb4208e1a52169bd16a47ca'
+ '138ad9a41dcbf9faede3a8b7fcb61dd5894ca8b66599802e58f8bb81e9607eb2'
+ '2463bf109af415412bf99e9aca98c51f215cfdcc9de728bbf7155db861bf79d7')
+
+prepare() {
+ cd gnome-keyring-$pkgver
+
+ # Autolaunch in Cinnamon (Arch's own patch, unchanged)
+ patch -Np1 -i ../0001-daemon-Add-Cinnamon-to-autostart-files.patch
+ # Register Secret Service clients at the point of use, return real errors
+ # instead of completing OpenSession with a NULL body, and resolve the
+ # collection alias only after the caller's session exists.
+ patch -Np1 -i ../0002-secret-service-missing-client.patch
+}
+
+build() {
+ local meson_options=(
+ -D selinux=disabled
+ )
+
+ arch-meson gnome-keyring-$pkgver build "${meson_options[@]}"
+ meson compile -C build
+}
+
+check() {
+ # Secure memory tests fail
+ dbus-run-session meson test -C build --print-errorlogs || :
+}
+
+package() {
+ meson install -C build --no-rebuild --destdir "$pkgdir"
+}
+
+# vim:set sw=2 sts=-1 et:
diff --git a/packages/gnome-keyring/gnome-keyring.install b/packages/gnome-keyring/gnome-keyring.install
new file mode 100644
index 0000000..6143dd1
--- /dev/null
+++ b/packages/gnome-keyring/gnome-keyring.install
@@ -0,0 +1,14 @@
+post_install() {
+ # Enable socket by default
+ systemctl --global enable gnome-keyring-daemon.socket
+}
+
+post_upgrade() {
+ if (( $(vercmp $2 1:42.0-1) < 0)); then
+ systemctl --global enable gnome-keyring-daemon.socket
+ fi
+}
+
+pre_remove() {
+ systemctl --global disable gnome-keyring-daemon.socket
+}
diff --git a/scripts/cmail-setup-finish.sh b/scripts/cmail-setup-finish.sh
index 949023f..8c27eda 100755
--- a/scripts/cmail-setup-finish.sh
+++ b/scripts/cmail-setup-finish.sh
@@ -1,32 +1,37 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-3.0-or-later
-# cmail-setup-finish.sh — finish Proton Mail Bridge + cmail-action setup after
-# Bridge first-run. Idempotent; safe to re-run after a Bridge cert rotation or
-# a claude-templates re-clone.
+# cmail-setup-finish.sh — finish Proton Mail Bridge setup after Bridge
+# first-run. Idempotent; safe to re-run after a Bridge cert rotation.
#
# Pre-reqs (the script aborts if any are missing):
# - protonmail-bridge installed (archsetup handles it)
# - You have run 'protonmail-bridge --cli', logged in, and quit at least once
# (the script looks for state at ~/.config/protonmail/bridge-v3/)
-# - claude-templates cloned at ~/projects/claude-templates
# - dotfiles stowed (~/.config/.cmailpass.gpg present)
#
+# Not a pre-req, but checked and warned about: cmail-action on PATH. rulesets'
+# `make install` links it, and session start runs that, so on a machine that
+# runs agent sessions it arrives without anyone asking. On one that doesn't,
+# it needs the command by hand. The script never invokes it either way.
+#
# What it does:
# 1. Decrypts ~/.config/.cmailpass.gpg → ~/.config/.cmailpass (mode 0600)
# 2. Copies Bridge's self-signed cert → ~/.config/protonbridge.pem
-# 3. Symlinks ~/projects/claude-templates/.ai/scripts/cmail-action.py
-# → ~/.local/bin/cmail-action
-# 4. Removes the leftover ~/.config/autostart/Proton Mail Bridge.desktop
+# 3. Removes the leftover ~/.config/autostart/Proton Mail Bridge.desktop
# stub (it double-launches Bridge alongside the systemd user service
# and throws an "orphan instance" dialog every login)
-# 5. Installs a wait-for-dns drop-in so Bridge doesn't spam
+# 4. Installs a wait-for-dns drop-in so Bridge doesn't spam
# name-resolution errors during the early-boot DNS race
-# 6. Enables + starts the protonmail-bridge user service
-# 7. Verifies Bridge is listening on 127.0.0.1:1143 / :1025
+# 5. Enables + starts the protonmail-bridge user service
+# 6. Verifies Bridge is listening on 127.0.0.1:1143 / :1025
+#
+# It no longer installs cmail-action. That moved to rulesets
+# (claude-templates/bin/), whose `make install` owns the symlink.
set -euo pipefail
err() { printf 'error: %s\n' "$*" >&2; exit 1; }
+warn() { printf 'warning: %s\n' "$*" >&2; }
info() { printf '==> %s\n' "$*"; }
ok() { printf ' %s\n' "$*"; }
@@ -47,9 +52,20 @@ bridge_state="$HOME/.config/protonmail/bridge-v3"
[ -d "$bridge_state" ] \
|| err "Bridge has no state at $bridge_state — run 'protonmail-bridge --cli' and log in first"
-cmail_action_src="$HOME/projects/claude-templates/.ai/scripts/cmail-action.py"
-[ -f "$cmail_action_src" ] \
- || err "cmail-action.py not found at $cmail_action_src — clone claude-templates first"
+# cmail-action is no longer this script's to install. It lives in rulesets at
+# claude-templates/bin/, and rulesets' `make install` links everything there
+# into ~/.local/bin. Session start runs that, so on a machine that runs agent
+# sessions the symlink arrives on its own; on one that doesn't, it needs the
+# command below.
+#
+# A warning rather than an abort, because this script never invokes the tool.
+# Its job is to leave Bridge working, and it can finish that whether or not a
+# mail client has been linked yet. Aborting here would make Bridge setup
+# depend on rulesets being cloned and installed first, an ordering neither
+# repo otherwise needs, and would strand a fresh machine with Bridge ready and
+# the script refusing to configure it.
+command -v cmail-action >/dev/null 2>&1 \
+ || warn "cmail-action not on PATH — run 'make -C ~/code/rulesets install' before sending mail"
cmailpass_enc="$HOME/.config/.cmailpass.gpg"
[ -f "$cmailpass_enc" ] \
@@ -69,13 +85,7 @@ cert_dst="$HOME/.config/protonbridge.pem"
cp "$cert_src" "$cert_dst"
ok "copied $cert_src → $cert_dst"
-# 4. Symlink cmail-action
-info "symlinking cmail-action"
-mkdir -p "$HOME/.local/bin"
-ln -sf "$cmail_action_src" "$HOME/.local/bin/cmail-action"
-ok "linked $HOME/.local/bin/cmail-action → $cmail_action_src"
-
-# 5. Remove leftover XDG autostart stub
+# 4. Remove leftover XDG autostart stub
# The systemd --user service is the canonical launcher. The autostart .desktop
# starts a second Bridge instance that can't get the lock and pops up an
# "orphan instance" dialog every login.
@@ -88,7 +98,7 @@ else
ok "no autostart stub present"
fi
-# 6. Install wait-for-dns drop-in
+# 5. Install wait-for-dns drop-in
# User-instance systemd doesn't carry network-online.target / nss-lookup.target,
# so the packaged unit's After=network.target doesn't imply DNS readiness.
# Bridge starts before the resolver is up and its first API calls all fail
@@ -107,7 +117,7 @@ ok "wrote $dropin_file"
systemctl --user daemon-reload
ok "reloaded systemd user units"
-# 7. Enable + start systemd user service
+# 6. Enable + start systemd user service
info "enabling protonmail-bridge user service"
was_active=0
systemctl --user is-active --quiet protonmail-bridge.service && was_active=1
@@ -119,7 +129,7 @@ else
ok "service active"
fi
-# 8. Verify
+# 7. Verify
info "verifying Bridge is listening"
listening="$(ss -ltn 2>/dev/null || true)"
missing=""
diff --git a/scripts/post-rebuild-check b/scripts/post-rebuild-check
new file mode 100755
index 0000000..aa7ef83
--- /dev/null
+++ b/scripts/post-rebuild-check
@@ -0,0 +1,676 @@
+#!/bin/sh
+# SPDX-License-Identifier: GPL-3.0-or-later
+# post-rebuild-check - the eight checks a rebuilt machine actually needs.
+#
+# A rebuilt machine looks finished and isn't. Five gaps surfaced on velox
+# within two days of the 2026-08-13 reinstall, and three of them LOOKED
+# fine: a stowed unit file, an enabled timer, a present git clone. Each
+# check below is cheap and turns a silent no-op into a visible line:
+#
+# 1. failed systemd units, user and system scope (calendar-sync failed
+# every 15 minutes for two days with nobody watching)
+# 2. user unit files present but not enabled (roam-sync and
+# signal-receive came back linked and inert -- a unit file being
+# present is not the same as running)
+# 3. tracked *.example files whose real sibling is missing (three
+# *.local.el were gone on velox; the .example survives in git,
+# the real file never does)
+# 4. gitignore-mode projects missing tooling paths their own .gitignore
+# names (a reinstall drops every such project's untracked working
+# state -- 374 files in .emacs.d's case -- and nothing carries it)
+# 5. signal-cli holds no registered account (velox lost its registration
+# in the rebuild; at the time agent-text relayed into velox, so that
+# silently broke paging for the WHOLE fleet. agent-text now walks
+# AGENT_TEXT_RELAYS in order and skips itself, so an unregistered
+# machine is only fatal when no relay host is registered either)
+# 6. every NTP source is named by hostname (a wrong clock fails the
+# DoT/DNSSEC validation this machine's DNS runs on, so nothing
+# resolves -- including the NTP pool that would fix the clock; velox
+# deadlocked exactly this way 2026-08-19 and needed a second device)
+# 7. hypridle installed but not running (nothing then triggers idle lock
+# or suspend, so a laptop runs until its battery is gone -- which is
+# how velox reset the RTC that caused check 6's deadlock in the first
+# place; a caffeine remembered from an earlier boot is the known cause)
+# 8. a working repo cloned from the read-only https endpoint (correct
+# for a stranger with no key on the server, wrong for this machine,
+# which finds out at the first push with a 403 -- velox's dotfiles
+# remote sat that way for four days after its rebuild)
+#
+# The .gitignore rule in check 4 is what scopes it: a tooling path is only
+# expected where the project's own .gitignore names it, so a project that
+# never had a todo.org never flags. The ignore file is the project's own
+# record of what it is supposed to hold untracked.
+#
+# EVERY PROBE FAILS CLOSED. A check that cannot run reports a finding, never
+# a pass. This matters more here than anywhere else in the script: the whole
+# point is catching silent no-ops, so a silent no-op in the checker would be
+# the worst possible defect. `systemctl --user` exits 1 with empty output
+# when there is no user bus -- over ssh, from cron, under sudo, on a TTY
+# before the graphical session starts -- and reading that as "no failed
+# units" would report a machine as healthy exactly when nothing was checked.
+#
+# Exit 0 when every check is clean, 1 when any check found something,
+# 2 on usage error.
+#
+# Test seams (env; for each, set-but-empty means "the probe ran and found
+# nothing", unset means "run the real probe"):
+# PRC_FAILED_UNITS newline list of "scope:unit" (scope user|system)
+# PRC_UNIT_STATES newline list of "unit-file state" replacing the
+# user-unit-dir enumeration + is-enabled calls
+# PRC_LOCAL_SCAN_ROOTS NEWLINE-separated roots for the *.example scan
+# (default: ~/.emacs.d ~/.dotfiles)
+# PRC_PROJECT_ROOTS NEWLINE-separated project dirs for check 4
+# (default: ~/code/* ~/projects/* ~/.emacs.d
+# ~/.dotfiles)
+# PRC_SIGNAL_ACCOUNTS signal-cli listAccounts output; "" = no account,
+# the special value MISSING = binary absent
+# PRC_NTP_SOURCES newline list of configured NTP server addresses;
+# the special value MISSING = no NTP daemon active
+# PRC_CHRONY_CONF path to chrony.conf (a fixture, under test) -- the
+# confdir it names is what decides which drop-ins count
+# PRC_IDLE_DAEMON pgrep output for hypridle; "" = installed but not
+# running, the special value MISSING = not installed
+# PRC_REPO_REMOTES newline list of "path origin-url"; an empty URL
+# means origin could not be read
+# PRC_UNITS_EXPECTED_DISABLED
+# newline list of units whose not-enabled state is
+# deliberate here, replacing the file below
+# PRC_UNITS_EXPECTED_DISABLED_FILE
+# path to that list (default:
+# $XDG_CONFIG_HOME/post-rebuild-check/units-expected-disabled).
+# One unit per line, # starts a comment. Machine-local
+# on purpose: the same unit is correctly enabled on one
+# box and not another
+# PRC_SYSTEMCTL path to the systemctl binary (a fake, under test)
+# PRC_SYSTEMCTL_TIMEOUT seconds to allow each systemctl call (default 5)
+#
+# Roots are newline-separated, not space-separated, because a POSIX
+# `for root in $var` splits on spaces and turns one real directory into
+# several imaginary missing ones.
+
+usage() {
+ cat <<'EOF'
+post-rebuild-check - verify a rebuilt machine is actually finished
+
+Runs the eight checks that caught velox's 2026-08 reinstall gaps: failed
+units, present-but-inert user units, orphaned *.example configs, missing
+per-project tooling state, the signal-cli registration, whether time sync
+can recover from a wrong clock without DNS, whether anything still
+triggers idle lock and suspend, and whether the working repos can push.
+
+Usage: post-rebuild-check [--help]
+
+Exit 0 when every check is clean, 1 when any check found something.
+Every probe fails closed: a check that cannot run is a finding, not a pass.
+EOF
+}
+
+case "${1:-}" in
+ --help|-h) usage; exit 0 ;;
+ "") ;;
+ *) echo "post-rebuild-check: unknown argument: $1" >&2; usage >&2; exit 2 ;;
+esac
+
+# Own the internal flags rather than inheriting them, so a caller's unrelated
+# variable of the same name cannot manufacture or mask a finding.
+TOTAL_FINDINGS=0
+CHECK_FINDINGS=0
+FINDING_LINES=""
+signal_missing=""
+ntp_missing=""
+idle_absent=""
+
+# Every systemctl call is bounded. A wedged user manager spins and answers
+# nothing -- seen live on velox 2026-08-17, where `is-enabled`, `cat`, and
+# `list-unit-files` all hung while `list-units` still returned. Unbounded, this
+# script would hang on the first unit and never reach the remaining checks,
+# which is a worse failure than reporting nothing: a check that hangs is its
+# own outage, and the machine most in need of checking is the one it hangs on.
+# A timeout yields empty output and a non-zero status, and both are already
+# handled as findings, so bounding the call is all that is needed to fail closed.
+CHRONY_CONF=${PRC_CHRONY_CONF:-/etc/chrony.conf}
+SCTL_TIMEOUT=${PRC_SYSTEMCTL_TIMEOUT:-5}
+SYSTEMCTL=${PRC_SYSTEMCTL:-systemctl}
+
+sctl() {
+ if command -v timeout >/dev/null 2>&1; then
+ timeout "$SCTL_TIMEOUT" "$SYSTEMCTL" "$@"
+ else
+ # Say so rather than dropping the bound silently: without timeout a
+ # wedged manager hangs this run indefinitely, and the whole point of
+ # the bound is that a check which hangs reports nothing at all.
+ [ -n "${sctl_unbounded_warned:-}" ] || {
+ echo "post-rebuild-check: timeout(1) not found — systemctl calls are UNBOUNDED and may hang" >&2
+ sctl_unbounded_warned=1
+ }
+ "$SYSTEMCTL" "$@"
+ fi
+}
+
+WORK=${TMPDIR:-/tmp}/.post-rebuild-check.$$
+if ! mkdir "$WORK" 2>/dev/null; then
+ # Every check stages its input through a file in here. Without it each
+ # loop would read nothing and every check would come back clean, which is
+ # the one failure this script must never produce.
+ echo "post-rebuild-check: cannot create a work directory under ${TMPDIR:-/tmp}" >&2
+ echo " nothing was checked; this is not a pass" >&2
+ exit 1
+fi
+trap 'rm -rf "$WORK"' EXIT HUP INT TERM
+
+STAGE="$WORK/stage"
+
+finding() {
+ CHECK_FINDINGS=$((CHECK_FINDINGS + 1))
+ TOTAL_FINDINGS=$((TOTAL_FINDINGS + 1))
+ FINDING_LINES="${FINDING_LINES} DEVIATION: $1
+"
+}
+
+# Print the check's one visible line, then its findings. The visible line
+# is the point: a silent no-op is exactly what let the gaps sit unseen.
+report() {
+ if [ "$CHECK_FINDINGS" -eq 0 ]; then
+ echo "$1 — ok"
+ else
+ echo "$1 — $CHECK_FINDINGS finding(s)"
+ printf '%s' "$FINDING_LINES"
+ fi
+ CHECK_FINDINGS=0
+ FINDING_LINES=""
+}
+
+# Stage a value into $STAGE for the read loops. A failed write is fatal for
+# the same reason a missing work directory is.
+stage() {
+ if ! printf '%s\n' "$1" > "$STAGE" 2>/dev/null; then
+ echo "post-rebuild-check: cannot write $STAGE" >&2
+ echo " nothing was checked; this is not a pass" >&2
+ exit 1
+ fi
+}
+
+# --- 1. failed units ------------------------------------------------------
+
+if [ -n "${PRC_FAILED_UNITS+set}" ]; then
+ failed=$PRC_FAILED_UNITS
+else
+ failed=""
+ if user_out=$(sctl --user list-units --state=failed --no-legend --plain 2>/dev/null); then
+ failed=$(printf '%s' "$user_out" | awk 'NF {print "user:"$1}')
+ else
+ finding "could not query user units (no user bus?) — nothing was checked in this scope"
+ fi
+ if sys_out=$(sctl list-units --state=failed --no-legend --plain 2>/dev/null); then
+ failed="$failed
+$(printf '%s' "$sys_out" | awk 'NF {print "system:"$1}')"
+ else
+ finding "could not query system units — nothing was checked in this scope"
+ fi
+fi
+stage "$failed"
+while IFS= read -r line; do
+ [ -n "$line" ] || continue
+ scope=${line%%:*}
+ unit=${line#*:}
+ finding "$scope unit failed: $unit"
+done < "$STAGE"
+report "check 1/8: failed units"
+
+# --- 2. user unit files present but not enabled ---------------------------
+#
+# Units nothing intends to enable here are read from a machine-local list.
+# "Enabled" is this check's proxy for "will actually run", and the proxy is
+# wrong for a unit nobody means to enable on this box. velox carries four, for
+# four different reasons: geoclue-agent is redundant because hyprland's
+# exec-once starts the binary directly, emacs is started on demand by
+# emacsclient, obs-record-watchdog only matters while recording, and
+# obsbot-wb-guard needs an OBSBOT the machine does not have. Left unexempted
+# they report at every run, and four permanent lines in front of every real one
+# teach you to skim the output -- the same argument check 4 makes about
+# CLAUDE.md.
+#
+# Machine-local rather than a marker in the shared unit file, because
+# obsbot-wb-guard is correctly ENABLED on ratio. One unit, a different right
+# answer per machine, so the shared file cannot hold the answer.
+#
+# An entry that turns out to be enabled after all is still a finding. Without
+# that the list rots into somewhere real findings go to die, which is worse
+# than the noise it removes.
+
+EXPECT_DISABLED_FILE="${PRC_UNITS_EXPECTED_DISABLED_FILE:-${XDG_CONFIG_HOME:-$HOME/.config}/post-rebuild-check/units-expected-disabled}"
+if [ -n "${PRC_UNITS_EXPECTED_DISABLED+set}" ]; then
+ expect_disabled=$PRC_UNITS_EXPECTED_DISABLED
+elif [ -f "$EXPECT_DISABLED_FILE" ]; then
+ expect_disabled=$(cat "$EXPECT_DISABLED_FILE" 2>/dev/null)
+else
+ expect_disabled=""
+fi
+# Strip comments and blanks once, here, so the membership test below is a
+# plain word match. The reason a unit is exempt is the most useful thing about
+# the entry, so the format has to carry one.
+expect_disabled=$(printf '%s\n' "$expect_disabled" \
+ | sed 's/#.*//' | awk 'NF {print $1}')
+
+if [ -n "${PRC_UNIT_STATES+set}" ]; then
+ states=$PRC_UNIT_STATES
+else
+ states=""
+ unit_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
+ if [ ! -d "$unit_dir" ]; then
+ finding "no user unit directory at $unit_dir — nothing was checked"
+ else
+ for f in "$unit_dir"/*.timer "$unit_dir"/*.service; do
+ # -L as well as -e: a stow symlink whose target moved in the
+ # rebuild is exactly the "looked fine" case this check is for,
+ # and -e is false for a broken link.
+ [ -e "$f" ] || [ -L "$f" ] || continue
+ name=$(basename "$f")
+ # A link with nothing behind it is its own finding, decided on the
+ # filesystem rather than from systemd. `is-enabled` calls a
+ # dangling link "not-found" -- the same answer it gives for a unit
+ # that was never installed -- so routing this through the state
+ # table below would drop it silently.
+ if [ -L "$f" ] && [ ! -e "$f" ]; then
+ finding "stowed unit file points at a missing target: $name"
+ continue
+ fi
+ # is-enabled exits non-zero AND prints a state for disabled and
+ # linked, so the exit code cannot distinguish "this unit is
+ # disabled" from "the query failed". The output can: a real answer
+ # is always a word. Empty means no answer, which is a finding
+ # rather than a silent skip -- with no user bus (ssh, cron, sudo,
+ # a TTY before the graphical session) every unit answers empty,
+ # and treating that as unknown-so-ignore would pass the machine
+ # while reading nothing at all.
+ #
+ # No separate bus probe: `is-system-running` and
+ # `show-environment` both block here, and a check that can hang is
+ # its own outage.
+ state=$(sctl --user is-enabled "$name" 2>/dev/null)
+ if [ -z "$state" ]; then
+ finding "could not read the enablement state of $name — it was not checked"
+ continue
+ fi
+ states="${states}${name} ${state}
+"
+ done
+ fi
+fi
+stage "$states"
+# A second copy for the sibling-timer lookup below, so the awk that reads it
+# is never the same open file as the loop reading it.
+cp "$STAGE" "$WORK/states" 2>/dev/null || {
+ echo "post-rebuild-check: cannot write $WORK/states" >&2
+ echo " nothing was checked; this is not a pass" >&2; exit 1; }
+while read -r name state; do
+ [ -n "$name" ] || continue
+ case "$state" in
+ disabled|linked) ;;
+ *) continue ;;
+ esac
+ # A timer-activated service is SUPPOSED to sit linked-and-not-enabled:
+ # the timer owns activation, and enabling the service as well would run
+ # it at boot on top of its schedule. So a service is suppressed only when
+ # its sibling timer can actually start it (enabled), or when the timer is
+ # itself inert and therefore the finding already -- reporting both would
+ # name one gap twice. A masked, static, or not-found timer starts
+ # nothing, so the service beneath it is as dead as one with no timer.
+ case "$name" in
+ *.service)
+ timer="${name%.service}.timer"
+ tstate=$(awk -v t="$timer" '$1 == t {print $2; exit}' "$WORK/states")
+ # enabled-runtime (enabled until reboot) and generated (something
+ # produced and installed it) are live activation paths, so the
+ # service under one is being started and is not a finding.
+ # disabled and linked suppress for a different reason: the timer
+ # is then the finding itself, reported in its own right.
+ #
+ # "indirect" deliberately does NOT suppress. It means the unit
+ # file itself is not enabled, only that some Also= relative might
+ # be, so nothing here is known to start the service. The
+ # fail-closed rule says the uncertain case flags.
+ case "$tstate" in
+ enabled|enabled-runtime|generated) continue ;;
+ disabled|linked) continue ;;
+ esac
+ ;;
+ esac
+ # Deliberately not enabled on this machine. Checked last, so it suppresses
+ # only this finding and never the dangling-link one decided above on the
+ # filesystem.
+ case "
+$expect_disabled
+" in
+ *"
+$name
+"*) continue ;;
+ esac
+ finding "unit file present but not enabled: $name ($state)"
+done < "$STAGE"
+# The exemption list, checked in the other direction. An entry whose unit is
+# enabled after all suppresses nothing, and leaving it there is how the list
+# turns into a place real findings go to die. The loop above cannot catch this:
+# it skips any state that is not disabled or linked, so an enabled unit never
+# reaches it.
+printf '%s\n' "$expect_disabled" > "$WORK/expect" 2>/dev/null || {
+ echo "post-rebuild-check: cannot write $WORK/expect" >&2
+ echo " nothing was checked; this is not a pass" >&2; exit 1; }
+while IFS= read -r name; do
+ [ -n "$name" ] || continue
+ estate=$(awk -v u="$name" '$1 == u {print $2; exit}' "$WORK/states")
+ case "$estate" in
+ enabled|enabled-runtime)
+ finding "$name is listed as expected-disabled but is $estate — drop the stale exemption" ;;
+ esac
+done < "$WORK/expect"
+report "check 2/8: unit files"
+
+# --- 3. *.example files whose real sibling is missing ---------------------
+
+if [ -n "${PRC_LOCAL_SCAN_ROOTS+set}" ]; then
+ scan_roots=$PRC_LOCAL_SCAN_ROOTS
+else
+ scan_roots="$HOME/.emacs.d
+$HOME/.dotfiles"
+fi
+printf '%s\n' "$scan_roots" > "$WORK/roots" 2>/dev/null || {
+ echo "post-rebuild-check: cannot write $WORK/roots" >&2; exit 1; }
+while IFS= read -r root; do
+ [ -n "$root" ] || continue
+ if [ ! -d "$root" ]; then
+ finding "scan root missing: $root"
+ continue
+ fi
+ # Vendored package trees ship their own .example docs; those belong to
+ # the package, not to this machine, so they are noise in front of the
+ # real findings this check exists for.
+ #
+ # -prune, not -not -path: the latter filters find's OUTPUT while still
+ # descending, so an unreadable directory inside a tree we deliberately
+ # ignore would set find's exit status and be reported as an unscanned
+ # part of the root. Pruning means those trees are never entered, so the
+ # exit status only reflects places this check actually wanted to read.
+ #
+ # That status matters: find exits non-zero when it cannot descend
+ # somewhere, having printed only what it could reach. Discarding it would
+ # hide every orphan under an unreadable directory behind a clean "ok",
+ # which is the defect this script exists to catch.
+ if ! find "$root" \
+ \( -name .git -o -name elpa -o -name straight \
+ -o -name node_modules -o -name .venv \) -prune \
+ -o -name '*.example' -print > "$WORK/examples" 2>/dev/null; then
+ finding "could not fully scan $root — part of it was not checked"
+ fi
+ while IFS= read -r ex; do
+ [ -n "$ex" ] || continue
+ # -e, so a sibling that exists only as a dangling symlink counts as
+ # missing. It is not a config the machine can read.
+ [ -e "${ex%.example}" ] || finding "example without its real file: $ex"
+ done < "$WORK/examples"
+done < "$WORK/roots"
+report "check 3/8: local files"
+
+# --- 4. gitignore-mode projects missing their tooling ---------------------
+
+if [ -n "${PRC_PROJECT_ROOTS+set}" ]; then
+ projects=$PRC_PROJECT_ROOTS
+else
+ projects=$(ls -d "$HOME"/code/*/ "$HOME"/projects/*/ 2>/dev/null; \
+ printf '%s\n%s\n' "$HOME/.emacs.d" "$HOME/.dotfiles")
+fi
+printf '%s\n' "$projects" > "$WORK/projects" 2>/dev/null || {
+ echo "post-rebuild-check: cannot write $WORK/projects" >&2; exit 1; }
+# CLAUDE.md is deliberately absent from this set. It is seed-only --
+# install-lang writes it once and the project owns it afterward -- so most
+# projects legitimately never have one, and ratio shows the identical
+# absences in the identical projects. That match is what proves it is the
+# steady state rather than reinstall drift, and flagging it would put nine
+# standing findings in front of every real one.
+#
+# .claude/ is absent for the same reason and proven the same way. The
+# bootstrap and the gitignore sweep write it into the ignore set of every
+# gitignore-mode project whether or not one ever exists there, so the entry is
+# aspirational rather than a promise -- pearl, rsyncshot and yt-sync each name
+# it and none of the three has ever had one, on velox or on ratio. Dropping it
+# loses no real signal either: a project that genuinely carries a .claude/
+# (rules and hooks from a language bundle) has it re-synced by
+# sync-language-bundle.sh at every session start, so a true absence heals
+# itself before this check would run.
+#
+# The list is fed to the inner loop straight from a heredoc rather than
+# staged through a file. It is a constant, so a file bought nothing and cost
+# a fifth unguarded write: had it failed (a full tmpfs, say) the inner loop
+# would read nothing and every project would pass silently, which is the one
+# outcome this script must never produce. The heredoc is the inner loop's own
+# stdin and leaves the outer loop's redirect alone.
+while IFS= read -r proj; do
+ [ -n "$proj" ] || continue
+ proj=${proj%/}
+ # -e not -d: in a worktree or submodule .git is a file naming the real
+ # gitdir, and a -d test would skip those projects silently.
+ [ -e "$proj/.git" ] || continue
+ [ -f "$proj/.gitignore" ] || continue
+ while read -r disk pattern; do
+ # Both the anchored (/.ai/) and unanchored (.ai/) ignore styles exist
+ # across the fleet; the sweep-gitignore audit hit exactly that split.
+ #
+ # grep exits 1 for no-match and 2 for an error, so the two are told
+ # apart rather than both read as "the ignore file does not name this".
+ # An unreadable .gitignore would otherwise pass the whole project.
+ grep -Eq "^/?${pattern}/?\$" "$proj/.gitignore" 2>/dev/null
+ case $? in
+ 0) [ -e "$proj/$disk" ] \
+ || finding "$proj: .gitignore names $disk but it is missing on disk" ;;
+ 1) ;;
+ *) finding "$proj: could not read .gitignore — the project was not checked"
+ break ;;
+ esac
+ done <<'EOF'
+.ai \.ai
+todo.org todo\.org
+inbox inbox
+EOF
+done < "$WORK/projects"
+report "check 4/8: project tooling"
+
+# --- 5. signal-cli registration -------------------------------------------
+
+if [ -n "${PRC_SIGNAL_ACCOUNTS+set}" ]; then
+ accounts=$PRC_SIGNAL_ACCOUNTS
+ if [ "$accounts" = "MISSING" ]; then
+ accounts=""
+ signal_missing=1
+ fi
+else
+ if command -v signal-cli >/dev/null 2>&1; then
+ if ! accounts=$(signal-cli listAccounts 2>/dev/null); then
+ accounts=""
+ finding "signal-cli listAccounts failed — the registration was not checked"
+ signal_missing=skip
+ fi
+ else
+ accounts=""
+ signal_missing=1
+ fi
+fi
+if [ "$signal_missing" = 1 ]; then
+ finding "signal-cli is not installed — paging relies on it fleet-wide"
+elif [ -z "$signal_missing" ] && [ -z "$accounts" ]; then
+ finding "no signal account registered — this machine can only page by relaying to one that has an account; if no host in AGENT_TEXT_RELAYS is registered either, the whole fleet loses paging"
+fi
+report "check 5/8: signal registration"
+
+# --- 6. NTP can recover a wrong clock without DNS -------------------------
+#
+# The clock/DNS bootstrap deadlock. This machine resolves through DNSOverTLS
+# with DNSSEC, and both validate against the wall clock, so a boot with a
+# wrong clock resolves nothing at all. If every configured NTP source is named
+# by hostname, the daemon that would correct the clock needs the DNS the clock
+# is breaking, and the machine cannot recover without a second device --
+# which is exactly what happened on velox 2026-08-19. One source addressed by
+# IP breaks the cycle, so that is what this check looks for.
+
+# True when the argument is an address rather than a name. An address needs no
+# resolver, which is the whole property being checked.
+is_ip_literal() {
+ case "$1" in
+ "") return 1 ;;
+ *:*) case "$1" in *[!0-9A-Fa-f:]*) return 1 ;; esac
+ return 0 ;;
+ *[!0-9.]*) return 1 ;;
+ *.*) return 0 ;;
+ esac
+ return 1
+}
+
+if [ -n "${PRC_NTP_SOURCES+set}" ]; then
+ ntp_sources=$PRC_NTP_SOURCES
+ if [ "$ntp_sources" = "MISSING" ]; then
+ ntp_sources=""
+ ntp_missing=1
+ fi
+elif sctl is-active chronyd >/dev/null 2>&1; then
+ # The main file, plus any drop-in directory chrony.conf actually names.
+ #
+ # The confdir read is the load-bearing part. A drop-in is inert unless
+ # chrony.conf points at its directory, and Arch's stock chrony.conf points
+ # at none -- so globbing /etc/chrony.d unconditionally would find the
+ # IP-addressed source, report the machine healthy, and be describing a file
+ # chrony never opens. That is a false pass on exactly the misconfiguration
+ # this check exists to catch, so the sources are read only from files
+ # chrony is actually told to read.
+ ntp_conf_files=$CHRONY_CONF
+ for ntp_dir in $(awk '$1 == "confdir" || $1 == "sourcedir" { print $2 }' \
+ "$CHRONY_CONF" 2>/dev/null); do
+ for ntp_f in "$ntp_dir"/*.conf "$ntp_dir"/*.sources; do
+ [ -f "$ntp_f" ] && ntp_conf_files="$ntp_conf_files $ntp_f"
+ done
+ done
+ # Unquoted on purpose: the accumulated list is several paths, and none of
+ # this script's own paths contain spaces.
+ ntp_sources=$(cat $ntp_conf_files 2>/dev/null \
+ | awk '$1 == "server" || $1 == "pool" { print $2 }')
+elif sctl is-active systemd-timesyncd >/dev/null 2>&1; then
+ ntp_sources=$(awk -F= '/^[[:space:]]*NTP=/ { print $2 }' \
+ /etc/systemd/timesyncd.conf 2>/dev/null | tr ' ' '\n')
+else
+ ntp_sources=""
+ ntp_missing=1
+fi
+
+if [ "$ntp_missing" = 1 ]; then
+ finding "no NTP implementation is active — nothing corrects the clock, and a wrong clock takes DNS down with it"
+elif [ -z "$ntp_sources" ]; then
+ finding "no NTP sources are configured — nothing was checked, and nothing corrects the clock"
+else
+ ntp_has_literal=""
+ stage "$ntp_sources"
+ while IFS= read -r src_addr; do
+ [ -z "$src_addr" ] && continue
+ if is_ip_literal "$src_addr"; then
+ ntp_has_literal=1
+ fi
+ done < "$STAGE"
+ if [ -z "$ntp_has_literal" ]; then
+ finding "every NTP source is named by hostname — a wrong clock breaks DNS, so nothing can resolve them and the clock stays wrong"
+ fi
+fi
+report "check 6/8: NTP bootstrap"
+
+# --- 7. the idle daemon survives session start ----------------------------
+#
+# A laptop that never sleeps has no symptom until the battery is gone, so
+# nothing surfaces this without being asked. On velox 2026-08-19 hypridle
+# started cleanly at 15:29:48 and `settings restore` killed it six seconds
+# later, replaying a caffeine stored in an earlier boot. The machine ran
+# 11h40m fully awake on battery, died when it flattened, and reset its RTC --
+# which took DNS down with it, the same deadlock check 6 exists for. The
+# desktop looked correct throughout.
+#
+# Behavioural on purpose: this asks whether the daemon is alive, not why it
+# might not be, so a stale caffeine, a crash, and a broken config all surface
+# the same way. Gated on hypridle being installed, because that is what marks
+# a machine as using it -- archsetup installs it only for Hyprland, so a
+# headless or dwm box would otherwise report a finding on every run.
+
+if [ -n "${PRC_IDLE_DAEMON+set}" ]; then
+ idle_pids=$PRC_IDLE_DAEMON
+ if [ "$idle_pids" = "MISSING" ]; then
+ idle_pids=""
+ idle_absent=1
+ fi
+elif command -v hypridle >/dev/null 2>&1; then
+ # pgrep exits non-zero with no match, which is the not-running case rather
+ # than a probe failure, so the || keeps `set -e`-style callers out of it.
+ idle_pids=$(pgrep -x hypridle 2>/dev/null) || idle_pids=""
+else
+ idle_pids=""
+ idle_absent=1
+fi
+
+if [ "$idle_absent" = 1 ]; then
+ : # hypridle is not part of this machine -- nothing to check
+elif [ -z "$idle_pids" ]; then
+ finding "hypridle is installed but not running — nothing triggers idle lock or suspend, so this machine stays awake until its battery is gone; a caffeine remembered from an earlier boot is the known cause"
+fi
+report "check 7/8: idle daemon"
+
+# --- 8. working repos cloned from the read-only endpoint ------------------
+#
+# archsetup clones the user's own archsetup and dotfiles from
+# https://git.cjennings.net/..., which serves anonymous clones and refuses
+# pushes. That default is correct for a stranger installing archsetup -- they
+# have no key on the server -- and wrong for this machine, which has to push.
+# ARCHSETUP_REPO / DOTFILES_REPO override it, but only where they are
+# configured: a curl|bash install, or a rebuild from a stock ISO, takes the
+# default straight back.
+#
+# Nothing about the tree shows it. The clone is complete and ordinary, and the
+# machine finds out at the first push, with a 403 -- which is how velox's
+# dotfiles remote was found on 2026-08-17, four days after its rebuild, by
+# which time the same rebuild's shallow clone had already answered a
+# credential-history question wrongly.
+#
+# Only the read-only endpoint is flagged. An https remote elsewhere may be
+# perfectly pushable through a credential helper, and guessing about hosts
+# this machine does not own would stand noise in front of the real findings.
+
+if [ -n "${PRC_REPO_REMOTES+set}" ]; then
+ repo_remotes=$PRC_REPO_REMOTES
+else
+ repo_remotes=""
+ for repo in "$HOME/code/archsetup" "$HOME/.dotfiles"; do
+ # -e not -d: a worktree or submodule .git is a file naming the gitdir.
+ [ -e "$repo/.git" ] || continue
+ # A repo with no origin still gets a line, with an empty URL, so the
+ # loop below reports it rather than skipping it into a silent pass.
+ repo_url=$(git -C "$repo" remote get-url origin 2>/dev/null)
+ repo_remotes="${repo_remotes}${repo} ${repo_url}
+"
+ done
+fi
+
+stage "$repo_remotes"
+while IFS= read -r repo_line; do
+ [ -n "$repo_line" ] || continue
+ repo_path=${repo_line%% *}
+ repo_url=${repo_line#"$repo_path"}
+ repo_url=${repo_url# }
+ case "$repo_url" in
+ "")
+ finding "$repo_path: origin could not be read — the remote was not checked" ;;
+ https://git.cjennings.net/*|https://cjennings.net/*)
+ finding "$repo_path: origin is the read-only endpoint ($repo_url) — git push returns 403; set the ssh form, or ARCHSETUP_REPO/DOTFILES_REPO before installing" ;;
+ esac
+done < "$STAGE"
+report "check 8/8: repo remotes"
+
+# --- summary --------------------------------------------------------------
+
+if [ "$TOTAL_FINDINGS" -eq 0 ]; then
+ echo "all checks clean"
+ exit 0
+fi
+echo "$TOTAL_FINDINGS finding(s) across 8 checks"
+exit 1
diff --git a/scripts/testing/tests/test_config_applied.py b/scripts/testing/tests/test_config_applied.py
index 00c410e..08ffc1b 100644
--- a/scripts/testing/tests/test_config_applied.py
+++ b/scripts/testing/tests/test_config_applied.py
@@ -40,7 +40,8 @@ def test_makepkg_options_trimmed(host):
@pytest.mark.attribution("archsetup")
-@pytest.mark.parametrize("rel", ["dns.conf", "wifi-privacy.conf"])
+@pytest.mark.parametrize("rel", ["dns.conf", "wifi-privacy.conf",
+ "tunnel-dns-over-tls.conf"])
def test_networkmanager_dropin(host, rel):
assert host.file("/etc/NetworkManager/conf.d/%s" % rel).exists
diff --git a/scripts/testing/tests/test_desktop.py b/scripts/testing/tests/test_desktop.py
index 1538d6a..ca2f266 100644
--- a/scripts/testing/tests/test_desktop.py
+++ b/scripts/testing/tests/test_desktop.py
@@ -11,6 +11,8 @@ installs `awww` (swww successor) and `pacman -Q swww` no longer matches — so
this checks awww. That divergence from the shell sweep is a correctness fix.
"""
+import re
+
import pytest
@@ -19,8 +21,12 @@ HYPRLAND_TOOLS = [
"awww", "grim", "slurp", "gammastep", "foot",
]
+# Note: hyprland.lua sits beside two .conf files on purpose. Only Hyprland's own
+# config format is deprecated (removed in 0.57); hypridle and hyprlock are
+# separate projects still on hyprlang, so their .conf names are current. Don't
+# "fix" this list for consistency -- that reverts the Lua port.
HYPRLAND_CONFIGS = [
- ".config/hypr/hyprland.conf",
+ ".config/hypr/hyprland.lua",
".config/hypr/hypridle.conf",
".config/hypr/hyprlock.conf",
".config/waybar/config",
@@ -139,8 +145,19 @@ def test_bt_panel_wired(host, hyprland_installed, home):
waybar = host.file("%s/.config/waybar/config" % home)
assert "custom/bluetooth" in waybar.content_string, \
"waybar config lacks the custom/bluetooth module"
- hyprconf = host.file("%s/.config/hypr/hyprland.conf" % home)
- assert "bt-panel" in hyprconf.content_string, \
- "hyprland.conf lacks the bt-panel keybind"
+ hypr = host.file("%s/.config/hypr/hyprland.lua" % home).content_string
+ # Assert the dispatcher and the chord, not the bare binary name. "bt-panel"
+ # on its own still appears inside exec_cmd() when the chord that reaches it
+ # is mangled, so the weak form passes on a config where Super+Shift+B does
+ # nothing. That is not hypothetical: converting this config to Lua produced
+ # exactly that defect elsewhere ("CTRL" .. mod .. " + S" collapsing to an
+ # unparseable CTRLSUPER + S, which Hyprland rejects at parse and leaves dead).
+ # One pattern spanning the whole bind, not two independent checks: asserting
+ # the chord and the dispatcher separately passes when they sit on different
+ # lines, and a planned reshuffle of the panel keybinding family is exactly
+ # the change that would separate them.
+ assert re.search(
+ r'hl\.bind\(\s*mod\s*\.\.\s*" \+ SHIFT \+ B",\s*hl\.dsp\.exec_cmd\("bt-panel"\)',
+ hypr), "hyprland.lua lacks the Super+Shift+B bind reaching bt-panel"
assert host.file("%s/.config/themes/dupre/panel.css" % home).exists, \
"shared panel css missing from the stowed theme"
diff --git a/scripts/zz-bluetooth-resume b/scripts/zz-bluetooth-resume
new file mode 100755
index 0000000..4b51dae
--- /dev/null
+++ b/scripts/zz-bluetooth-resume
@@ -0,0 +1,95 @@
+#!/bin/sh
+# SPDX-License-Identifier: GPL-3.0-or-later
+# zz-bluetooth-resume - put bluetooth back after a sleep cycle.
+#
+# A systemd-sleep hook. Two things break bluetooth across sleep on a TLP
+# laptop, and nothing else on the machine fixes either one.
+#
+# 1. The rfkill soft-block is not cleared. systemd-rfkill would do it, and
+# it is masked here deliberately -- it fights TLP's radio handling, so
+# configure_tlp_power masks it and TLP owns radios instead. TLP's own
+# sleep hook runs `tlp resume`, but that only puts bluetooth back where
+# it was at suspend and applies no policy: its enable setting is
+# DEVICES_TO_ENABLE_ON_STARTUP, startup not resume, and TLP has no
+# ON_RESUME at all. So a block present at suspend stays, and so does one
+# that a controller re-probed after that replay comes up with. WiFi
+# survives only because NetworkManager unblocks itself; bluetooth has no
+# equivalent.
+#
+# 2. The controller comes back wedged from a hibernate. It reports powered
+# and unblocked while scanning finds nothing whatever -- zero devices
+# where the same room gave seventeen a minute later -- and bluetoothd
+# logs "Failed to set mode" and "Failed to add device <mac>" at the
+# instant of resume. Reloading btusb clears it.
+#
+# Both observed on velox 2026-08-21, on the first suspend-then-hibernate cycle
+# after hibernate was switched back on. The second symptom is why unblocking
+# alone is not enough: rfkill was cleared by hand and scanning still returned
+# nothing until the driver was reloaded.
+#
+# The hook re-asserts TLP's own declared intent rather than inventing a policy.
+# A machine whose TLP config does not ask for bluetooth keeps it off, which is
+# what stops this from overriding a deliberate block at every wakeup.
+#
+# The zz- prefix buys no ordering: systemd-sleep runs every hook here in
+# parallel (man systemd-sleep), so `tlp resume` may still be running when
+# this starts, and its first act is to replay the rfkill state it saved at
+# suspend. On a hibernate-class wake the settle between unload and load below
+# usually lands the final unblock after that replay. A plain suspend has no
+# settle and unblocks at once, so that race is open. The tell is `rfkill list
+# bluetooth` reading "Soft blocked: yes" after a suspend cycle that was entered
+# with bluetooth already blocked (a hook that never ran reads the same way).
+#
+# Test seams: BTR_RFKILL, BTR_MODPROBE, BTR_TLP_CONF, BTR_TLP_CONF_DIR,
+# BTR_SETTLE (seconds to wait between driver unload and load).
+
+set -u
+
+RFKILL="${BTR_RFKILL:-rfkill}"
+MODPROBE="${BTR_MODPROBE:-modprobe}"
+TLP_CONF="${BTR_TLP_CONF:-/etc/tlp.conf}"
+TLP_CONF_DIR="${BTR_TLP_CONF_DIR:-/etc/tlp.d}"
+SETTLE="${BTR_SETTLE:-1}"
+
+# post only. The pre phase has nothing to do, and acting there would fight the
+# suspend it is about to run.
+[ "${1:-}" = "post" ] || exit 0
+
+# Does TLP ask for bluetooth on this machine? Comments are stripped first, so a
+# commented-out example in the stock config cannot be read as a policy. Both
+# the main file and any drop-in count, and the last assignment wins the same
+# way TLP itself resolves them.
+wants_bluetooth() {
+ cat "$TLP_CONF" "$TLP_CONF_DIR"/*.conf 2>/dev/null \
+ | sed 's/#.*//' \
+ | awk -F= '/DEVICES_TO_ENABLE_ON_STARTUP/ { v = $2 } END { print v }' \
+ | tr -d '"' \
+ | tr ' ' '\n' \
+ | grep -qx "bluetooth"
+}
+
+wants_bluetooth || exit 0
+
+# The wedge follows a hibernate, which reinitialises the controller from a
+# saved image. A plain suspend brings USB back intact, so reloading there would
+# tear down a working adapter for nothing.
+#
+# suspend-then-hibernate reports that name whether or not it reached the
+# hibernate stage, so this reloads on a cycle that only suspended. That is the
+# cheap side of the trade: a couple of seconds against an adapter that answers
+# nothing until someone notices and reloads it by hand.
+case "${2:-}" in
+ hibernate|suspend-then-hibernate)
+ "$MODPROBE" -r btusb 2>/dev/null || true
+ [ "$SETTLE" = "0" ] || sleep "$SETTLE"
+ "$MODPROBE" btusb 2>/dev/null || true
+ ;;
+esac
+
+# After the reload, not before: a freshly loaded btusb can come up soft-blocked
+# and would undo an earlier unblock.
+"$RFKILL" unblock bluetooth 2>/dev/null || true
+
+# Never fail. systemd-sleep logs a failing hook, and that noise outlives the
+# cause it describes; nothing here is worth alarming a resume over.
+exit 0
diff --git a/tests/bluetooth-resume/test_bluetooth_resume.py b/tests/bluetooth-resume/test_bluetooth_resume.py
new file mode 100644
index 0000000..62fd24f
--- /dev/null
+++ b/tests/bluetooth-resume/test_bluetooth_resume.py
@@ -0,0 +1,141 @@
+"""Tests for scripts/zz-bluetooth-resume.
+
+Two things break bluetooth across a sleep cycle on a TLP laptop, and nothing
+else on the machine fixes either.
+
+The rfkill soft-block is not cleared. systemd-rfkill would do it, but it is
+masked deliberately -- it fights TLP's radio handling, so TLP owns radios
+instead. TLP's own sleep hook runs `tlp resume`, but that only puts bluetooth
+back where it was at suspend and applies no policy: its setting is
+DEVICES_TO_ENABLE_ON_STARTUP, startup not resume, and there is no ON_RESUME in
+TLP's vocabulary. A block present at suspend stays, and so does one that a
+controller re-probed after that replay comes up with. WiFi survives only
+because NetworkManager unblocks itself; bluetooth has no equivalent.
+
+The controller also comes back wedged from a hibernate. It reports powered and
+unblocked while scanning finds nothing whatever -- zero devices where the same
+room gave seventeen a minute later. bluetoothd logs "Failed to set mode" and
+"Failed to add device <mac>" at the instant of resume. Reloading btusb clears
+it.
+
+Both observed on velox 2026-08-21, on its first suspend-then-hibernate cycle
+after hibernate was switched back on.
+
+The hook re-asserts TLP's own declared intent rather than inventing a policy,
+so a machine that deliberately blocks bluetooth keeps it blocked.
+
+Run from repo root:
+ python3 -m unittest tests.bluetooth-resume.test_bluetooth_resume
+"""
+
+import os
+import stat
+import subprocess
+import tempfile
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+HOOK = os.path.join(REPO_ROOT, "scripts", "zz-bluetooth-resume")
+
+TLP_WANTS_BT = 'DEVICES_TO_ENABLE_ON_STARTUP="bluetooth wifi"\n'
+TLP_WIFI_ONLY = 'DEVICES_TO_ENABLE_ON_STARTUP="wifi"\n'
+
+
+def run(phase="post", kind="suspend-then-hibernate", tlp_conf=TLP_WANTS_BT,
+ conf_present=True):
+ """Drive the hook with rfkill and modprobe faked, and read back the calls."""
+ with tempfile.TemporaryDirectory() as d:
+ calls = os.path.join(d, "calls.log")
+ bindir = os.path.join(d, "bin")
+ os.makedirs(bindir)
+ for tool in ("rfkill", "modprobe"):
+ p = os.path.join(bindir, tool)
+ with open(p, "w") as fh:
+ fh.write(f'#!/bin/sh\necho "{tool} $*" >> "{calls}"\nexit 0\n')
+ os.chmod(p, 0o755)
+ conf = os.path.join(d, "tlp.conf")
+ if conf_present:
+ with open(conf, "w") as fh:
+ fh.write(tlp_conf)
+ env = dict(os.environ)
+ env.update({
+ "BTR_RFKILL": os.path.join(bindir, "rfkill"),
+ "BTR_MODPROBE": os.path.join(bindir, "modprobe"),
+ "BTR_TLP_CONF": conf,
+ "BTR_TLP_CONF_DIR": os.path.join(d, "tlp.d"),
+ "BTR_SETTLE": "0",
+ })
+ r = subprocess.run(["sh", HOOK, phase, kind], env=env,
+ capture_output=True, text=True, timeout=20)
+ log = ""
+ if os.path.exists(calls):
+ with open(calls) as fh:
+ log = fh.read()
+ return r, log
+
+
+class BluetoothResume(unittest.TestCase):
+ # --- Normal ---------------------------------------------------------
+ def test_hibernate_reloads_the_driver_and_unblocks(self):
+ _, log = run(kind="suspend-then-hibernate")
+ self.assertIn("modprobe -r btusb", log)
+ self.assertIn("modprobe btusb", log)
+ self.assertIn("rfkill unblock bluetooth", log)
+
+ def test_the_unblock_comes_after_the_reload(self):
+ # A freshly loaded btusb can come up soft-blocked, so unblocking first
+ # would be undone by the reload that follows it.
+ _, log = run()
+ self.assertLess(log.index("modprobe btusb"),
+ log.index("rfkill unblock"))
+
+ def test_plain_suspend_unblocks_without_reloading(self):
+ # The wedge was seen coming out of hibernate, which reinitialises the
+ # controller from a saved image. A plain suspend restores USB intact,
+ # so reloading there would cost a working adapter for nothing.
+ _, log = run(kind="suspend")
+ self.assertIn("rfkill unblock bluetooth", log)
+ self.assertNotIn("btusb", log)
+
+ # --- Boundary -------------------------------------------------------
+ def test_the_pre_phase_does_nothing(self):
+ _, log = run(phase="pre")
+ self.assertEqual(log, "")
+
+ def test_a_tlp_policy_without_bluetooth_is_left_alone(self):
+ # The hook re-asserts TLP's stated intent. It must not invent one, or
+ # a machine that deliberately keeps bluetooth off gets it turned on at
+ # every wakeup.
+ _, log = run(tlp_conf=TLP_WIFI_ONLY)
+ self.assertEqual(log, "")
+
+ def test_a_commented_out_policy_does_not_count(self):
+ _, log = run(tlp_conf='#DEVICES_TO_ENABLE_ON_STARTUP="bluetooth"\n')
+ self.assertEqual(log, "")
+
+ def test_hibernate_proper_also_reloads(self):
+ _, log = run(kind="hibernate")
+ self.assertIn("modprobe -r btusb", log)
+
+ # --- Error ----------------------------------------------------------
+ def test_a_missing_tlp_config_is_left_alone(self):
+ # No declared policy means no intent to re-assert. Failing safe here
+ # means doing nothing, not guessing.
+ _, log = run(conf_present=False)
+ self.assertEqual(log, "")
+
+ def test_the_hook_always_exits_zero(self):
+ # systemd-sleep logs a failing hook and the noise outlives the cause.
+ # Nothing here is worth delaying or alarming a resume over.
+ for kind in ("suspend", "hibernate", "suspend-then-hibernate"):
+ with self.subTest(kind=kind):
+ r, _ = run(kind=kind)
+ self.assertEqual(r.returncode, 0, r.stderr)
+
+ def test_it_is_executable(self):
+ self.assertTrue(os.stat(HOOK).st_mode & stat.S_IXUSR,
+ "systemd-sleep only runs executables")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_clone_user_repos.py b/tests/installer-steps/test_clone_user_repos.py
new file mode 100644
index 0000000..51d8434
--- /dev/null
+++ b/tests/installer-steps/test_clone_user_repos.py
@@ -0,0 +1,155 @@
+"""Test clone_user_repos: the two user repos are cloned with full history.
+
+archsetup and dotfiles are not build directories. They are the two repos I
+actively develop in on every machine this installer builds, so a shallow clone
+is wrong for both. Velox came back from its 2026-08-13 rebuild with 7 commits
+of history in each instead of 851, and nothing about the tree said so.
+
+The quiet failure is what makes this worth a test rather than a one-line fix.
+`git log -- <path>` against a shallow clone does not error; it answers "no
+commits". So a credential-history check run on that machine reported five
+sensitive files absent from history and exited clean, when the real answer was
+that the clone could not see the history they live in. A security question came
+back falsely reassuring. Everything else it breaks — blame, bisect, any
+archaeology past the graft point — is merely annoying by comparison.
+
+The AUR build clones are a different case and stay shallow: they are throwaway
+build trees, cloned to run `make install` and then discarded, where history has
+no value and the download cost is real. So this suite asserts both halves —
+full history for the two user repos, and depth still pinned on the AUR path —
+because a fix applied with too broad a brush would regress the build clones
+without failing any test that only looked at the user repos.
+
+Method: sed-extract clone_user_repos from the real `archsetup`, fake git /
+mkdir / chown / display / error_warn / error_fatal, and read back the git
+command lines the function issued.
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_clone_user_repos
+"""
+
+import os
+import re
+import subprocess
+import tempfile
+import textwrap
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+
+
+def run(clone_fails=False, make_git_dir=True):
+ """Drive clone_user_repos with every side effect faked.
+
+ dotfiles_dir is pre-created with a .git so the function's "is this a real
+ checkout?" guard passes on the happy path; make_git_dir=False exercises the
+ guard itself.
+ """
+ with tempfile.TemporaryDirectory() as d:
+ dotfiles_dir = os.path.join(d, "dotfiles")
+ os.makedirs(dotfiles_dir)
+ if make_git_dir:
+ os.makedirs(os.path.join(dotfiles_dir, ".git"))
+ clone_rc = 1 if clone_fails else 0
+ script = textwrap.dedent(f"""\
+ logfile=/dev/null
+ action=""
+ username=testuser
+ archsetup_repo="https://example.invalid/archsetup.git"
+ dotfiles_repo="https://example.invalid/dotfiles.git"
+ dotfiles_branch=main
+ dotfiles_dir="{dotfiles_dir}"
+ display() {{ :; }}
+ mkdir() {{ echo "MKDIR: $*" >> "{d}/calls.log"; return 0; }}
+ chown() {{ echo "CHOWN: $*" >> "{d}/calls.log"; return 0; }}
+ git() {{
+ echo "GIT: $*" >> "{d}/calls.log"
+ case "$1" in
+ clone) return {clone_rc} ;;
+ *) return 0 ;;
+ esac
+ }}
+ error_warn() {{ echo "WARN: $1" >> "{d}/calls.log"; return 1; }}
+ error_fatal() {{ echo "FATAL: $1" >> "{d}/calls.log"; exit 1; }}
+ source <(sed -n '/^clone_user_repos() {{/,/^}}/p' "{ARCHSETUP}")
+ clone_user_repos
+ echo "RC=$?" >> "{d}/calls.log"
+ exit 0
+ """)
+ subprocess.run(
+ ["bash", "-c", script], capture_output=True, text=True, timeout=10,
+ )
+ with open(os.path.join(d, "calls.log")) as fh:
+ return fh.read()
+
+
+def clone_lines(log):
+ return [ln for ln in log.splitlines() if ln.startswith("GIT: clone")]
+
+
+class CloneUserRepos(unittest.TestCase):
+ # ------------------------------------------------------------ normal ----
+ def test_both_user_repos_are_cloned(self):
+ lines = clone_lines(run())
+ self.assertEqual(len(lines), 2,
+ f"expected an archsetup clone and a dotfiles clone, got: {lines}")
+ self.assertTrue(any("archsetup.git" in ln for ln in lines))
+ self.assertTrue(any("dotfiles.git" in ln for ln in lines))
+
+ def test_archsetup_clone_carries_full_history(self):
+ """A shallow archsetup clone answers history questions wrongly."""
+ line = next(ln for ln in clone_lines(run()) if "archsetup.git" in ln)
+ self.assertNotIn("--depth", line,
+ "archsetup is a working repo, not a build tree — a shallow "
+ "clone makes `git log -- <path>` answer 'no commits' instead "
+ "of failing, which is how a credential-history check came "
+ "back falsely clean on velox")
+
+ def test_dotfiles_clone_carries_full_history(self):
+ line = next(ln for ln in clone_lines(run()) if "dotfiles.git" in ln)
+ self.assertNotIn("--depth", line,
+ "dotfiles is a working repo, not a build tree")
+
+ def test_dotfiles_clone_still_pins_the_branch(self):
+ """Dropping --depth must not disturb the --branch argument beside it."""
+ line = next(ln for ln in clone_lines(run()) if "dotfiles.git" in ln)
+ self.assertIn("--branch main", line)
+
+ # ---------------------------------------------------------- boundary ----
+ def test_no_user_repo_clone_is_shallow_by_any_spelling(self):
+ """--depth, --depth=N and -depth are all shallow; catch the lot."""
+ for line in clone_lines(run()):
+ self.assertNotRegex(line, r"(^|\s)-{1,2}depth(\s|=)",
+ f"user-repo clone must be full: {line}")
+
+ def test_aur_build_clones_stay_shallow(self):
+ """The fix must not over-apply — build trees are throwaway.
+
+ Read against the real file rather than the extracted function, because
+ these clones live in a different function entirely and the risk being
+ guarded is a careless repo-wide sed.
+ """
+ with open(ARCHSETUP) as fh:
+ source = fh.read()
+ build_clones = re.findall(r"^.*git clone.*build_dir.*$", source, re.M)
+ self.assertTrue(build_clones, "expected AUR build clones to exist")
+ for line in build_clones:
+ self.assertIn("--depth 1", line,
+ f"AUR build clone should stay shallow: {line.strip()}")
+
+ # ------------------------------------------------------------- error ----
+ def test_clone_failure_is_reported_not_swallowed(self):
+ log = run(clone_fails=True)
+ self.assertIn("WARN:", log,
+ "a failed clone must surface through error_warn")
+
+ def test_dotfiles_clone_producing_no_checkout_is_fatal(self):
+ """The stow/restore steps downstream need a real checkout."""
+ log = run(make_git_dir=False)
+ self.assertIn("FATAL:", log)
+ self.assertNotIn("RC=", log, "error_fatal must halt, not fall through")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_configure_backlight_access.py b/tests/installer-steps/test_configure_backlight_access.py
new file mode 100644
index 0000000..6fd171a
--- /dev/null
+++ b/tests/installer-steps/test_configure_backlight_access.py
@@ -0,0 +1,139 @@
+"""Test configure_backlight_access — udev rule for brightness writes.
+
+Arch's brightnessctl ships no udev rules: it relies on logind, which grants
+brightness writes only to the *active seat session*. Anything outside that
+session — a script, a remote shell, a panel launched into a different
+session — gets EPERM against root-owned sysfs. Found on velox 2026-08-13:
+on a fresh install the desktop settings panel's screen and keyboard
+brightness sliders were both inert.
+
+The step drops a rule making the brightness attributes group-writable by
+video (the group create_user already adds the user to). leds are granted to
+video as well rather than input, so nobody needs input-group membership —
+and the keylogging surface that carries — just to dim a keyboard.
+
+Method: sed-extract configure_backlight_access from the real `archsetup`,
+point it at a temp rules dir, and assert on the file it writes.
+
+ python3 -m unittest tests.installer-steps.test_configure_backlight_access
+"""
+
+import os
+import re
+import stat
+import subprocess
+import tempfile
+import textwrap
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+
+
+def run(ruledir, marker=None):
+ # The step sends udevadm's own output to $logfile, so the stub records
+ # its argv to a marker file instead of stdout — writing straight to the
+ # file is unaffected by the caller's redirect.
+ marker = marker or os.devnull
+ script = textwrap.dedent(f"""\
+ logfile=/dev/null
+ action=""
+ display() {{ :; }}
+ error_warn() {{ echo "WARN: $1"; return 1; }}
+ udevadm() {{ echo "UDEVADM: $*" >> "{marker}"; }}
+ source <(sed -n '/^configure_backlight_access() {{/,/^}}/p' "{ARCHSETUP}")
+ configure_backlight_access "{ruledir}"
+ echo "RC=$?"
+ exit 0
+ """)
+ return subprocess.run(
+ ["bash", "-c", script], capture_output=True, text=True, timeout=10,
+ )
+
+
+def rc_of(r):
+ m = re.search(r"^RC=(\d+)$", r.stdout, re.M)
+ assert m, "no RC line in output: %r / %r" % (r.stdout, r.stderr)
+ return int(m.group(1))
+
+
+class ConfigureBacklightAccess(unittest.TestCase):
+ # ------------------------------------------------------------ normal ----
+ def test_writes_a_rule_covering_backlight_and_keyboard_leds(self):
+ with tempfile.TemporaryDirectory() as d:
+ r = run(d)
+ self.assertEqual(rc_of(r), 0)
+ rule = os.path.join(d, "90-backlight.rules")
+ self.assertTrue(os.path.exists(rule))
+ body = open(rule).read()
+ self.assertIn('SUBSYSTEM=="backlight"', body)
+ self.assertIn('SUBSYSTEM=="leds"', body)
+ self.assertIn('KERNEL=="*kbd_backlight"', body)
+
+ def test_grants_the_video_group_write_access(self):
+ with tempfile.TemporaryDirectory() as d:
+ run(d)
+ body = open(os.path.join(d, "90-backlight.rules")).read()
+ self.assertIn("chgrp video", body)
+ self.assertIn("chmod g+w", body)
+
+ def test_does_not_use_the_input_group(self):
+ # Granting leds to input would require input-group membership,
+ # which also confers read access to every input device.
+ with tempfile.TemporaryDirectory() as d:
+ run(d)
+ body = open(os.path.join(d, "90-backlight.rules")).read()
+ self.assertNotIn("chgrp input", body)
+
+ def test_rule_is_world_readable_not_writable(self):
+ with tempfile.TemporaryDirectory() as d:
+ run(d)
+ mode = stat.S_IMODE(os.stat(os.path.join(d, "90-backlight.rules")).st_mode)
+ self.assertEqual(mode, 0o644)
+
+ def test_reloads_udev_so_the_rule_applies_without_a_reboot(self):
+ with tempfile.TemporaryDirectory() as d:
+ marker = os.path.join(d, "udevadm.calls")
+ run(d, marker=marker)
+ calls = open(marker).read()
+ self.assertIn("UDEVADM: control --reload", calls)
+ self.assertRegex(calls, r"UDEVADM: trigger .*backlight")
+ self.assertRegex(calls, r"UDEVADM: trigger .*leds")
+
+ # ---------------------------------------------------------- boundary ----
+ def test_running_twice_leaves_one_correct_rule(self):
+ # Compare whole bodies rather than counting lines: a count encodes
+ # today's line total, so it breaks on a correct edit and passes an
+ # append regression that happens to hit the same number.
+ with tempfile.TemporaryDirectory() as d:
+ run(d)
+ first = open(os.path.join(d, "90-backlight.rules")).read()
+ r = run(d)
+ self.assertEqual(rc_of(r), 0)
+ second = open(os.path.join(d, "90-backlight.rules")).read()
+ self.assertEqual(first, second)
+
+ def test_absent_rules_directory_is_created(self):
+ with tempfile.TemporaryDirectory() as d:
+ nested = os.path.join(d, "etc", "udev", "rules.d")
+ r = run(nested)
+ self.assertEqual(rc_of(r), 0)
+ self.assertTrue(os.path.exists(os.path.join(nested, "90-backlight.rules")))
+
+ # ------------------------------------------------------------- error ----
+ @unittest.skipUnless(os.geteuid() != 0, "root ignores directory write bits")
+ def test_unwritable_directory_warns_and_does_not_crash(self):
+ with tempfile.TemporaryDirectory() as d:
+ ruledir = os.path.join(d, "ro")
+ os.mkdir(ruledir)
+ os.chmod(ruledir, 0o500)
+ try:
+ r = run(ruledir)
+ self.assertIn("WARN:", r.stdout)
+ self.assertNotEqual(rc_of(r), 0)
+ finally:
+ os.chmod(ruledir, 0o700)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_configure_framework_amd_internal_mic.py b/tests/installer-steps/test_configure_framework_amd_internal_mic.py
new file mode 100644
index 0000000..7a45fb1
--- /dev/null
+++ b/tests/installer-steps/test_configure_framework_amd_internal_mic.py
@@ -0,0 +1,277 @@
+"""Test configure_framework_amd_internal_mic and its DMI gate.
+
+The Framework Laptop 13 on AMD Ryzen AI 300 (Strix Point and Krackan Point
+alike) has a known, still-open kernel bug in the ACP PDM digital-mic path: the
+DMIC card (acp-pdm-mach, hw:acppdmmach) returns a constant full-scale rail
+(rms 0.99999) instead of audio. With UCM on, alsa-ucm-conf links that card
+into the ALC285 codec card, so the only "internal mic" PipeWire offers IS the
+broken DMIC, and the built-in microphone is dead in every app. Found on velox
+2026-10-06 after the 08-13 reinstall; Framework community threads 81866 and
+75748 document it across several HX 370 owners.
+
+The workaround is a WirePlumber drop-in that turns UCM off (the ALC285 falls
+back to its analog-stereo profile, whose Internal Microphone pin carries real
+audio) and disables the standalone PDM card so nothing can pick it. The step
+installs the shipped copy of that drop-in, and only on this hardware: the
+gate reads DMI, not the hostname, because the fix belongs to the board, not
+to velox.
+
+The gate has to be exact in both directions. The same bug does not exist on
+the Intel Framework 13 (SOF stack, not ACP), and the Framework Desktop (ratio,
+"AMD Ryzen AI Max 300") has no internal mic at all, so a vendor-only or
+"AMD"-only match would drop a UCM-off file onto machines whose audio is fine
+and silently flatten their codec profiles.
+
+Method: sed-extract both functions from the real `archsetup`, point the step
+at a temp conf.d dir and a fixture DMI dir, and fake display / error_warn.
+The shipped drop-in is read from the real configs/ tree so the test pins the
+content the installer actually delivers.
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_configure_framework_amd_internal_mic
+"""
+
+import os
+import re
+import stat
+import subprocess
+import tempfile
+import textwrap
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+DROPIN_SRC = os.path.join(REPO_ROOT, "configs", "wireplumber", "50-disable-ucm.conf")
+DROPIN_NAME = "50-disable-ucm.conf"
+# The broken DMIC card as velox names it, and the codec card that must stay.
+VELOX_PDM_CARD = "alsa_card.pci-0000_c1_00.5-platform-acp-pdm-mach"
+VELOX_CODEC_CARD = "alsa_card.pci-0000_c1_00.6"
+
+# DMI fixtures. The product strings are what the real machines report.
+VELOX = dict(sys_vendor="Framework", product_name="Laptop 13 (AMD Ryzen AI 300 Series)")
+RATIO = dict(sys_vendor="Framework", product_name="Desktop (AMD Ryzen AI Max 300 Series)")
+INTEL_13 = dict(sys_vendor="Framework", product_name="Laptop (13th Gen Intel Core)")
+AMD_7040_13 = dict(sys_vendor="Framework", product_name="Laptop 13 (AMD Ryzen 7040Series)")
+AMD_16 = dict(sys_vendor="Framework", product_name="Laptop 16 (AMD Ryzen 7040 Series)")
+OTHER_VENDOR = dict(sys_vendor="LENOVO", product_name="Laptop 13 (AMD Ryzen AI 300 Series)")
+
+
+def write_dmi(dmi_dir, fields):
+ os.makedirs(dmi_dir, exist_ok=True)
+ for name, value in fields.items():
+ with open(os.path.join(dmi_dir, name), "w") as f:
+ f.write(value + "\n")
+
+
+def gate(dmi_dir):
+ script = textwrap.dedent(f"""\
+ source <(sed -n '/^is_framework_13_ryzen_ai_300() {{/,/^}}/p' "{ARCHSETUP}")
+ if is_framework_13_ryzen_ai_300 "{dmi_dir}"; then echo "GATE=yes"; else echo "GATE=no"; fi
+ exit 0
+ """)
+ r = subprocess.run(["bash", "-c", script], capture_output=True, text=True, timeout=10)
+ m = re.search(r"^GATE=(yes|no)$", r.stdout, re.M)
+ assert m, "no GATE line in output: %r / %r" % (r.stdout, r.stderr)
+ return m.group(1) == "yes"
+
+
+def run(confd, dmi_dir, src_root=REPO_ROOT):
+ # The step sends install's output to $logfile; the fakes report on stdout
+ # so the test can see the warning and the function's own return code.
+ script = textwrap.dedent(f"""\
+ logfile=/dev/null
+ action=""
+ username=nobody
+ display() {{ :; }}
+ error_warn() {{ echo "WARN: $1"; return 1; }}
+ user_archsetup_dir="{src_root}"
+ source <(sed -n '/^is_framework_13_ryzen_ai_300() {{/,/^}}/p' "{ARCHSETUP}")
+ source <(sed -n '/^configure_framework_amd_internal_mic() {{/,/^}}/p' "{ARCHSETUP}")
+ configure_framework_amd_internal_mic "{confd}" "{dmi_dir}"
+ echo "RC=$?"
+ exit 0
+ """)
+ return subprocess.run(
+ ["bash", "-c", script], capture_output=True, text=True, timeout=10,
+ )
+
+
+def rc_of(r):
+ m = re.search(r"^RC=(\d+)$", r.stdout, re.M)
+ assert m, "no RC line in output: %r / %r" % (r.stdout, r.stderr)
+ return int(m.group(1))
+
+
+class Framework13RyzenAi300Gate(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.dmi = os.path.join(self.tmp.name, "dmi")
+
+ def tearDown(self):
+ self.tmp.cleanup()
+
+ # ------------------------------------------------------------ normal ----
+ def test_matches_the_framework_13_on_ryzen_ai_300(self):
+ write_dmi(self.dmi, VELOX)
+ self.assertTrue(gate(self.dmi))
+
+ # ---------------------------------------------------------- boundary ----
+ def test_rejects_the_framework_desktop_on_ryzen_ai_max(self):
+ """ratio: same vendor, "AMD Ryzen AI" in the name, no internal mic.
+
+ "AMD Ryzen AI Max 300" must not satisfy an "AMD Ryzen AI 300" match.
+ """
+ write_dmi(self.dmi, RATIO)
+ self.assertFalse(gate(self.dmi))
+
+ def test_rejects_the_framework_13_on_intel(self):
+ """Intel's SOF stack has no ACP PDM path; the bug does not exist there."""
+ write_dmi(self.dmi, INTEL_13)
+ self.assertFalse(gate(self.dmi))
+
+ def test_rejects_the_framework_13_on_ryzen_7040(self):
+ """Phoenix is not Ryzen AI 300; the workaround is unverified there."""
+ write_dmi(self.dmi, AMD_7040_13)
+ self.assertFalse(gate(self.dmi))
+
+ def test_rejects_the_framework_16(self):
+ write_dmi(self.dmi, AMD_16)
+ self.assertFalse(gate(self.dmi))
+
+ def test_rejects_another_vendor_with_the_same_model_string(self):
+ write_dmi(self.dmi, OTHER_VENDOR)
+ self.assertFalse(gate(self.dmi))
+
+ def test_rejects_a_missing_dmi_directory(self):
+ self.assertFalse(gate(os.path.join(self.tmp.name, "nope")))
+
+
+class ConfigureFrameworkAmdInternalMic(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.dmi = os.path.join(self.tmp.name, "dmi")
+ self.confd = os.path.join(self.tmp.name, "wireplumber.conf.d")
+ self.dropin = os.path.join(self.confd, DROPIN_NAME)
+
+ def tearDown(self):
+ self.tmp.cleanup()
+
+ # ------------------------------------------------------------ normal ----
+ def test_framework_13_ryzen_ai_300_gets_the_shipped_dropin(self):
+ write_dmi(self.dmi, VELOX)
+ r = run(self.confd, self.dmi)
+ self.assertEqual(rc_of(r), 0, r.stdout)
+ self.assertTrue(os.path.exists(self.dropin), "drop-in not installed")
+ with open(self.dropin) as f, open(DROPIN_SRC) as src:
+ self.assertEqual(f.read(), src.read(),
+ "the installed file must be the shipped file, byte for byte")
+
+ def test_dropin_mode_is_0644(self):
+ """Owner-writable, world-readable, like every file under /etc."""
+ write_dmi(self.dmi, VELOX)
+ run(self.confd, self.dmi)
+ mode = stat.S_IMODE(os.stat(self.dropin).st_mode)
+ self.assertEqual(mode, 0o644)
+
+ def test_shipped_dropin_turns_ucm_off(self):
+ """Pin the shipped content, not just the copy: use-ucm must be an
+ active setting, not a commented example."""
+ with open(DROPIN_SRC) as f:
+ body = f.read()
+ active = [ln for ln in body.splitlines()
+ if "alsa.use-ucm" in ln and not ln.lstrip().startswith("#")]
+ self.assertEqual(len(active), 1, body)
+ self.assertRegex(active[0], r"alsa\.use-ucm\s*=\s*false")
+
+ def test_shipped_dropin_disables_the_standalone_pdm_card(self):
+ """Without UCM the broken DMIC still shows up as its own card; the
+ rule has to catch it and disable it, or an app or the default
+ selection can still land on the full-scale rail.
+
+ The gate fires on every Framework 13 on Ryzen AI 300, so the rule
+ must match the card by name pattern (WirePlumber's ~regex form), not
+ by the one PCI path velox happens to enumerate: a renumbered bus on
+ the same board would otherwise get UCM turned off with the broken
+ card left selectable. WirePlumber's regex match is unanchored, so
+ re.search mirrors it.
+ """
+ with open(DROPIN_SRC) as f:
+ body = f.read()
+ m = re.search(r'device\.name\s*=\s*"~([^"]+)"', body)
+ self.assertIsNotNone(m, "the rule must match device.name with a ~regex, "
+ "not a literal PCI path")
+ assert m is not None # narrows the type; the assertion above is the test
+ pattern = m.group(1)
+ self.assertTrue(re.search(pattern, VELOX_PDM_CARD),
+ f"{pattern!r} must match velox's card {VELOX_PDM_CARD!r}")
+ self.assertTrue(re.search(pattern, "alsa_card.pci-0000_c3_00.5-platform-acp-pdm-mach"),
+ "the same card on another bus number must match too")
+ self.assertFalse(re.search(pattern, VELOX_CODEC_CARD),
+ "the ALC285 codec card carries the working mic and must not be disabled")
+ self.assertRegex(body, r"device\.disabled\s*=\s*true")
+
+ # ---------------------------------------------------------- boundary ----
+ def test_other_hardware_writes_nothing(self):
+ for fixture in (RATIO, INTEL_13, AMD_7040_13, AMD_16, OTHER_VENDOR):
+ with self.subTest(product=fixture["product_name"], vendor=fixture["sys_vendor"]):
+ write_dmi(self.dmi, fixture)
+ r = run(self.confd, self.dmi)
+ self.assertEqual(rc_of(r), 0, r.stdout)
+ self.assertFalse(os.path.exists(self.confd),
+ "no conf.d may be created on hardware the step does not apply to")
+ self.assertNotIn("WARN:", r.stdout)
+
+ def test_missing_dmi_directory_is_a_quiet_no_op(self):
+ """A VM or a stripped sysfs reports no DMI; that is not an error."""
+ r = run(self.confd, os.path.join(self.tmp.name, "nope"))
+ self.assertEqual(rc_of(r), 0, r.stdout)
+ self.assertFalse(os.path.exists(self.confd))
+ self.assertNotIn("WARN:", r.stdout)
+
+ def test_running_twice_leaves_one_identical_dropin(self):
+ write_dmi(self.dmi, VELOX)
+ run(self.confd, self.dmi)
+ with open(self.dropin) as f:
+ first = f.read()
+ r = run(self.confd, self.dmi)
+ self.assertEqual(rc_of(r), 0, r.stdout)
+ with open(self.dropin) as f:
+ second = f.read()
+ self.assertEqual(first, second)
+ self.assertEqual(os.listdir(self.confd), [DROPIN_NAME])
+
+ def test_absent_confd_is_created(self):
+ """wireplumber's package ships /etc/wireplumber/ but not conf.d/; the
+ step must create the nested path rather than fail on it."""
+ write_dmi(self.dmi, VELOX)
+ nested = os.path.join(self.tmp.name, "etc", "wireplumber", "wireplumber.conf.d")
+ r = run(nested, self.dmi)
+ self.assertEqual(rc_of(r), 0, r.stdout)
+ self.assertTrue(os.path.exists(os.path.join(nested, DROPIN_NAME)))
+
+ # ------------------------------------------------------------- error ----
+ def test_missing_shipped_file_warns_and_does_not_crash(self):
+ """A partial checkout has no configs/ tree; surface it, keep going."""
+ write_dmi(self.dmi, VELOX)
+ empty_root = os.path.join(self.tmp.name, "empty-checkout")
+ os.makedirs(empty_root)
+ r = run(self.confd, self.dmi, src_root=empty_root)
+ self.assertIn("WARN:", r.stdout)
+ self.assertNotEqual(rc_of(r), 0)
+ self.assertFalse(os.path.exists(self.dropin))
+
+ @unittest.skipUnless(os.geteuid() != 0, "root ignores directory write bits")
+ def test_unwritable_confd_warns_and_does_not_crash(self):
+ write_dmi(self.dmi, VELOX)
+ os.makedirs(self.confd)
+ os.chmod(self.confd, 0o500)
+ try:
+ r = run(self.confd, self.dmi)
+ self.assertIn("WARN:", r.stdout)
+ self.assertNotEqual(rc_of(r), 0)
+ finally:
+ os.chmod(self.confd, 0o700)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_configure_hibernate_delay.py b/tests/installer-steps/test_configure_hibernate_delay.py
new file mode 100644
index 0000000..95f2dcb
--- /dev/null
+++ b/tests/installer-steps/test_configure_hibernate_delay.py
@@ -0,0 +1,131 @@
+"""Test configure_hibernate_delay — the suspend-to-hibernate handoff.
+
+The desktop panel can upgrade its idle suspend stage to
+suspend-then-hibernate, but the delay between the two is systemd's
+HibernateDelaySec, which lives in a root-owned drop-in and so cannot be a
+panel setting. Left unset the delay depends on the hardware: a machine
+with a battery hibernates on a low-battery alarm at an hour nobody chose,
+and one without falls back to systemd's 2h default.
+
+90 minutes is the seeded value: long enough that stepping out of a meeting
+costs a screen unlock rather than a passphrase and a resume, short enough
+that a laptop in a bag is not holding its encryption keys in RAM all
+afternoon (a suspended machine is; a hibernated one is not).
+
+Method: sed-extract configure_hibernate_delay from the real `archsetup`,
+point it at a temp config dir, and assert on the file it writes.
+
+ python3 -m unittest tests.installer-steps.test_configure_hibernate_delay
+"""
+
+import os
+import re
+import stat
+import subprocess
+import tempfile
+import textwrap
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+
+
+def run(confdir):
+ script = textwrap.dedent(f"""\
+ logfile=/dev/null
+ action=""
+ display() {{ :; }}
+ error_warn() {{ echo "WARN: $1"; return 1; }}
+ source <(sed -n '/^configure_hibernate_delay() {{/,/^}}/p' "{ARCHSETUP}")
+ configure_hibernate_delay "{confdir}"
+ echo "RC=$?"
+ exit 0
+ """)
+ return subprocess.run(
+ ["bash", "-c", script], capture_output=True, text=True, timeout=10,
+ )
+
+
+def rc_of(r):
+ m = re.search(r"^RC=(\d+)$", r.stdout, re.M)
+ assert m, "no RC line in output: %r / %r" % (r.stdout, r.stderr)
+ return int(m.group(1))
+
+
+def body(confdir):
+ with open(os.path.join(confdir, "60-hibernate-delay.conf")) as f:
+ return f.read()
+
+
+class ConfigureHibernateDelay(unittest.TestCase):
+ # ------------------------------------------------------------ normal ----
+ def test_writes_a_sleep_drop_in_with_the_delay(self):
+ with tempfile.TemporaryDirectory() as d:
+ r = run(d)
+ self.assertEqual(rc_of(r), 0)
+ text = body(d)
+ self.assertIn("[Sleep]", text)
+ self.assertIn("HibernateDelaySec=90min", text)
+
+ def test_drop_in_is_world_readable_not_writable(self):
+ with tempfile.TemporaryDirectory() as d:
+ run(d)
+ mode = stat.S_IMODE(
+ os.stat(os.path.join(d, "60-hibernate-delay.conf")).st_mode)
+ self.assertEqual(mode, 0o644)
+
+ def test_explains_why_the_value_is_not_left_to_systemd(self):
+ # The number is a judgement call, so the file has to carry its
+ # reasoning: whoever changes it next needs the tradeoff, not just
+ # a number to overwrite. Assert on the tradeoff's own terms —
+ # "contains a hash" would pass on any comment at all.
+ with tempfile.TemporaryDirectory() as d:
+ run(d)
+ text = body(d).lower()
+ self.assertIn("2h default", text) # what unset would give
+ self.assertIn("encryption keys", text) # why not longer
+ self.assertIn("passphrase", text) # why not shorter
+
+ def test_drop_in_sorts_above_vendor_drop_ins(self):
+ # systemd reserves 10-40 for /usr and 60-90 for /etc. A 10- file
+ # in /etc sorts below a vendor 20- file and silently loses to it.
+ with tempfile.TemporaryDirectory() as d:
+ run(d)
+ name = os.listdir(d)[0]
+ prefix = int(name.split("-")[0])
+ self.assertGreaterEqual(prefix, 60)
+ self.assertLessEqual(prefix, 90)
+
+ # ---------------------------------------------------------- boundary ----
+ def test_running_twice_leaves_one_correct_drop_in(self):
+ with tempfile.TemporaryDirectory() as d:
+ run(d)
+ first = body(d)
+ r = run(d)
+ self.assertEqual(rc_of(r), 0)
+ self.assertEqual(first, body(d))
+
+ def test_absent_directory_is_created(self):
+ with tempfile.TemporaryDirectory() as d:
+ nested = os.path.join(d, "etc", "systemd", "sleep.conf.d")
+ r = run(nested)
+ self.assertEqual(rc_of(r), 0)
+ self.assertIn("HibernateDelaySec=90min", body(nested))
+
+ # ------------------------------------------------------------- error ----
+ @unittest.skipUnless(os.geteuid() != 0, "root ignores directory write bits")
+ def test_unwritable_directory_warns_and_does_not_crash(self):
+ with tempfile.TemporaryDirectory() as d:
+ confdir = os.path.join(d, "ro")
+ os.mkdir(confdir)
+ os.chmod(confdir, 0o500)
+ try:
+ r = run(confdir)
+ self.assertIn("WARN:", r.stdout)
+ self.assertNotEqual(rc_of(r), 0)
+ finally:
+ os.chmod(confdir, 0o700)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_configure_openai_chatgpt_repo.py b/tests/installer-steps/test_configure_openai_chatgpt_repo.py
new file mode 100644
index 0000000..cb0e84b
--- /dev/null
+++ b/tests/installer-steps/test_configure_openai_chatgpt_repo.py
@@ -0,0 +1,324 @@
+"""Test configure_openai_chatgpt_repo — OpenAI's signed pacman repo for ChatGPT.
+
+OpenAI ships the ChatGPT (Codex) desktop app for Arch as chatgpt-bin from
+its own signed pacman repository. Its install-arch.sh pins the repository
+signing-key fingerprint, locally signs the key into the pacman keyring,
+writes /etc/pacman.d/openai-chatgpt.conf and adds an Include line to
+pacman.conf; ratio and velox were set up that way on 2026-10-11. The step
+reproduces exactly that configuration so a re-run on those machines is a
+no-op and the app rides normal pacman upgrades under OpenAI's signature.
+The AUR repackage of the same binary (chatgpt-desktop) would conflict with
+chatgpt-bin on any machine already using the repo, so it is not used.
+
+A half-configured repository is worse than none: with the Include in place
+and the key untrusted, SigLevel TrustedOnly makes every later pacman call
+fail on the invalid database. So an untrusted key (fingerprint mismatch or
+failed download) must import nothing and leave pacman.conf untouched.
+
+The pin is on the primary key's fingerprint alone. pacman-key --lsign-key
+resolves a subkey fingerprint to its primary, so a bundle whose subkey or
+second primary carries the pinned fingerprint must be rejected too.
+
+Method: sed-extract configure_openai_chatgpt_repo from the real `archsetup`,
+point it at a temp pacman.conf and repo drop-in, and replace curl, gpg,
+pacman-key and pacman with recorders driven by environment switches.
+
+ python3 -m unittest tests.installer-steps.test_configure_openai_chatgpt_repo
+"""
+
+import os
+import re
+import stat
+import subprocess
+import tempfile
+import textwrap
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+
+FPR = "3BFA0E4AE8B8CC16A2D9BA684A3B4A566C4660E4"
+KEY_URL = "https://persistent.oaistatic.com/codex-app-prod/linux/repository-signing-key.gpg"
+SERVER = "https://persistent.oaistatic.com/codex-app-prod/linux/arch/$arch"
+BASE_CONF = "[core]\nInclude = /etc/pacman.d/mirrorlist\n"
+
+# Recorders. Every fake appends its argv to $MARKER; the pacman fake also
+# records whether the Include line is already in pacman.conf at call time,
+# which is how the tests see that -Sy ran after the repo was wired in.
+STUBS = r"""
+logfile=/dev/null
+action=""
+display() { :; }
+backup_system_file() { :; }
+error_warn() { echo "WARN: $1"; return 1; }
+pacman-key() {
+ echo "PACMAN_KEY: $*" >> "$MARKER"
+ case "$1" in
+ --list-keys) [ "$KEY_PRESENT" = 1 ] ;;
+ *) return 0 ;;
+ esac
+}
+curl() {
+ echo "CURL: $*" >> "$MARKER"
+ [ "$CURL_FAIL" = 1 ] && return 22
+ local out=""
+ while [ $# -gt 0 ]; do
+ case "$1" in -o) out="$2"; shift ;; esac
+ shift
+ done
+ [ -n "$out" ] && printf 'FAKE KEY\n' > "$out"
+ return 0
+}
+gpg() {
+ echo "GPG: $*" >> "$MARKER"
+ case " $* " in
+ *" --show-keys "*)
+ printf 'pub:u:4096:1:4A3B4A566C4660E4:1754362069:::u:::scSC::::::23::0:\n'
+ printf 'fpr:::::::::%s:\n' "$SERVED_FPR"
+ printf 'uid:u::::1754362069::ABC::Codex Linux Repository::::::::::0:\n'
+ if [ -n "$SERVED_SUBKEY_FPR" ]; then
+ printf 'sub:u:4096:1:0123456789ABCDEF:1754362069::::::e::::::23:\n'
+ printf 'fpr:::::::::%s:\n' "$SERVED_SUBKEY_FPR"
+ fi
+ if [ -n "$SERVED_SECOND_PUB_FPR" ]; then
+ printf 'pub:u:4096:1:FEDCBA9876543210:1754362069:::u:::scSC::::::23::0:\n'
+ printf 'fpr:::::::::%s:\n' "$SERVED_SECOND_PUB_FPR"
+ printf 'uid:u::::1754362069::DEF::Somebody Else::::::::::0:\n'
+ fi
+ ;;
+ esac
+ return 0
+}
+pacman() {
+ local n
+ n=$(grep -cxF "Include = $REPO_CONF" "$PACMAN_CONF" 2>/dev/null)
+ echo "PACMAN: $* include=$n" >> "$MARKER"
+ return 0
+}
+"""
+
+
+def paths(d):
+ return (os.path.join(d, "pacman.conf"),
+ os.path.join(d, "pacman.d", "openai-chatgpt.conf"),
+ os.path.join(d, "calls"))
+
+
+def run(d, *, key_present=False, served_fpr=FPR, served_subkey_fpr="",
+ served_second_pub_fpr="", curl_fail=False, pacman_conf_body=BASE_CONF):
+ pacman_conf, repo_conf, marker = paths(d)
+ if pacman_conf_body is not None and not os.path.exists(pacman_conf):
+ with open(pacman_conf, "w") as f:
+ f.write(pacman_conf_body)
+ script = STUBS + textwrap.dedent(f"""\
+ source <(sed -n '/^configure_openai_chatgpt_repo() {{/,/^}}/p' "{ARCHSETUP}")
+ configure_openai_chatgpt_repo "$PACMAN_CONF" "$REPO_CONF"
+ echo "RC=$?"
+ exit 0
+ """)
+ env = dict(os.environ, MARKER=marker, PACMAN_CONF=pacman_conf,
+ REPO_CONF=repo_conf, KEY_PRESENT="1" if key_present else "0",
+ SERVED_FPR=served_fpr, SERVED_SUBKEY_FPR=served_subkey_fpr,
+ SERVED_SECOND_PUB_FPR=served_second_pub_fpr,
+ CURL_FAIL="1" if curl_fail else "0")
+ return subprocess.run(["bash", "-c", script], capture_output=True,
+ text=True, timeout=10, env=env)
+
+
+def rc_of(r):
+ m = re.search(r"^RC=(\d+)$", r.stdout, re.M)
+ assert m, "no RC line in output: %r / %r" % (r.stdout, r.stderr)
+ return int(m.group(1))
+
+
+def read(path):
+ with open(path) as f:
+ return f.read()
+
+
+def calls(d):
+ marker = paths(d)[2]
+ return read(marker) if os.path.exists(marker) else ""
+
+
+class ConfigureOpenaiChatgptRepo(unittest.TestCase):
+ # ------------------------------------------------------------ normal ----
+ def test_writes_the_published_repo_definition(self):
+ with tempfile.TemporaryDirectory() as d:
+ r = run(d)
+ self.assertEqual(rc_of(r), 0)
+ _, repo_conf, _ = paths(d)
+ self.assertTrue(os.path.exists(repo_conf))
+ body = read(repo_conf)
+ self.assertIn("[openai-chatgpt]\n", body)
+ self.assertIn("SigLevel = Required DatabaseRequired TrustedOnly\n", body)
+ # $arch stays literal: pacman expands it, the shell must not.
+ self.assertIn(f"Server = {SERVER}\n", body)
+
+ def test_repo_definition_is_world_readable_not_writable(self):
+ with tempfile.TemporaryDirectory() as d:
+ run(d)
+ mode = stat.S_IMODE(os.stat(paths(d)[1]).st_mode)
+ self.assertEqual(mode, 0o644)
+
+ def test_adds_one_include_line_to_pacman_conf(self):
+ with tempfile.TemporaryDirectory() as d:
+ run(d)
+ pacman_conf, repo_conf, _ = paths(d)
+ body = read(pacman_conf)
+ self.assertTrue(body.startswith(BASE_CONF), body)
+ self.assertEqual(body.count(f"\nInclude = {repo_conf}\n"), 1)
+
+ def test_imports_and_locally_signs_the_pinned_key(self):
+ with tempfile.TemporaryDirectory() as d:
+ run(d)
+ c = calls(d)
+ self.assertIn(f" {KEY_URL}", c)
+ self.assertIn("GPG: ", c)
+ self.assertIn("--show-keys", c)
+ self.assertIn("PACMAN_KEY: --add ", c)
+ self.assertIn(f"PACMAN_KEY: --lsign-key {FPR}", c)
+
+ def test_refreshes_databases_after_the_include_is_in_place(self):
+ with tempfile.TemporaryDirectory() as d:
+ run(d)
+ self.assertIn("PACMAN: -Sy include=1", calls(d))
+
+ # ---------------------------------------------------------- boundary ----
+ def test_running_twice_leaves_one_include_and_the_same_files(self):
+ with tempfile.TemporaryDirectory() as d:
+ pacman_conf, repo_conf, _ = paths(d)
+ self.assertEqual(rc_of(run(d)), 0)
+ first_conf, first_repo = read(pacman_conf), read(repo_conf)
+ self.assertEqual(rc_of(run(d)), 0)
+ self.assertEqual(read(pacman_conf), first_conf)
+ self.assertEqual(read(repo_conf), first_repo)
+ self.assertEqual(first_conf.count("Include = " + repo_conf), 1)
+
+ def test_already_trusted_key_is_not_downloaded_or_added_again(self):
+ with tempfile.TemporaryDirectory() as d:
+ r = run(d, key_present=True)
+ self.assertEqual(rc_of(r), 0)
+ c = calls(d)
+ self.assertNotIn("CURL:", c)
+ self.assertNotIn("PACMAN_KEY: --add", c)
+ self.assertTrue(os.path.exists(paths(d)[1]))
+
+ def test_pacman_conf_without_trailing_newline_gets_its_own_line(self):
+ with tempfile.TemporaryDirectory() as d:
+ r = run(d, pacman_conf_body=BASE_CONF.rstrip("\n"))
+ self.assertEqual(rc_of(r), 0)
+ pacman_conf, repo_conf, _ = paths(d)
+ body = read(pacman_conf)
+ self.assertIn("Include = /etc/pacman.d/mirrorlist\n", body)
+ self.assertRegex(body, re.compile(rf"^Include = {re.escape(repo_conf)}$", re.M))
+
+ def test_existing_differing_repo_definition_is_kept_and_flagged(self):
+ with tempfile.TemporaryDirectory() as d:
+ pacman_conf, repo_conf, _ = paths(d)
+ os.makedirs(os.path.dirname(repo_conf))
+ custom = "[openai-chatgpt]\nSigLevel = Required\nServer = file:///srv/mirror\n"
+ with open(repo_conf, "w") as f:
+ f.write(custom)
+ r = run(d)
+ self.assertIn("WARN:", r.stdout)
+ self.assertEqual(read(repo_conf), custom)
+ # The repository is still configured, just by the user's file.
+ self.assertIn(f"\nInclude = {repo_conf}\n", read(pacman_conf))
+ self.assertEqual(rc_of(r), 0)
+
+ # ------------------------------------------------------------- error ----
+ def assert_nothing_configured(self, d, r):
+ pacman_conf, repo_conf, _ = paths(d)
+ c = calls(d)
+ self.assertIn("WARN:", r.stdout)
+ self.assertNotIn("PACMAN_KEY: --add", c)
+ self.assertNotIn("PACMAN_KEY: --lsign-key", c)
+ self.assertNotIn("PACMAN: -Sy", c)
+ self.assertFalse(os.path.exists(repo_conf))
+ self.assertEqual(read(pacman_conf), BASE_CONF)
+ self.assertNotEqual(rc_of(r), 0)
+
+ def test_fingerprint_mismatch_imports_nothing_and_touches_nothing(self):
+ with tempfile.TemporaryDirectory() as d:
+ r = run(d, served_fpr="DEADBEEF" * 5)
+ self.assert_nothing_configured(d, r)
+
+ def test_key_download_failure_imports_nothing_and_touches_nothing(self):
+ with tempfile.TemporaryDirectory() as d:
+ r = run(d, curl_fail=True)
+ self.assert_nothing_configured(d, r)
+
+ # The pin is on the PRIMARY key's fingerprint and nothing else. pacman-key
+ # --lsign-key resolves a subkey fingerprint to the primary that carries it,
+ # so a pin that a subkey or a second primary could satisfy would trust
+ # whatever key the bundle smuggled in beside the expected one.
+ def test_subkey_carrying_the_pinned_fingerprint_is_rejected(self):
+ with tempfile.TemporaryDirectory() as d:
+ r = run(d, served_fpr="DEADBEEF" * 5, served_subkey_fpr=FPR)
+ self.assert_nothing_configured(d, r)
+
+ def test_second_primary_carrying_the_pinned_fingerprint_is_rejected(self):
+ with tempfile.TemporaryDirectory() as d:
+ r = run(d, served_fpr="DEADBEEF" * 5, served_second_pub_fpr=FPR)
+ self.assert_nothing_configured(d, r)
+
+ def test_rogue_second_primary_beside_the_pinned_key_is_rejected(self):
+ with tempfile.TemporaryDirectory() as d:
+ r = run(d, served_fpr=FPR, served_second_pub_fpr="DEADBEEF" * 5)
+ self.assert_nothing_configured(d, r)
+
+ @unittest.skipUnless(os.geteuid() != 0, "root ignores file write bits")
+ def test_unwritable_pacman_conf_warns_and_reports_failure(self):
+ with tempfile.TemporaryDirectory() as d:
+ pacman_conf, _, _ = paths(d)
+ with open(pacman_conf, "w") as f:
+ f.write(BASE_CONF)
+ os.chmod(pacman_conf, 0o444)
+ try:
+ r = run(d)
+ self.assertIn("WARN:", r.stdout)
+ self.assertNotEqual(rc_of(r), 0)
+ self.assertNotIn("PACMAN: -Sy", calls(d))
+ finally:
+ os.chmod(pacman_conf, 0o644)
+
+
+class SupplementalSoftwareWiring(unittest.TestCase):
+ """chatgpt-bin is installed only once its repository is trusted."""
+
+ def run_step(self, repo_rc):
+ script = textwrap.dedent(f"""\
+ desktop_env=hyprland
+ display() {{ :; }}
+ run_task() {{ :; }}
+ mask_fwupd_passim() {{ :; }}
+ pacman_install() {{ echo "PACMAN_INSTALL: $1"; }}
+ aur_install() {{ echo "AUR_INSTALL: $1"; }}
+ configure_openai_chatgpt_repo() {{ echo "CONFIGURE_REPO"; return {repo_rc}; }}
+ source <(sed -n '/^supplemental_software() {{/,/^}}/p' "{ARCHSETUP}")
+ supplemental_software
+ """)
+ return subprocess.run(["bash", "-c", script], capture_output=True,
+ text=True, timeout=10)
+
+ def test_installs_chatgpt_bin_after_configuring_the_repo(self):
+ out = self.run_step(0).stdout
+ self.assertIn("CONFIGURE_REPO\n", out)
+ self.assertLess(out.index("CONFIGURE_REPO"),
+ out.index("PACMAN_INSTALL: chatgpt-bin"))
+
+ def test_skips_chatgpt_bin_when_the_repo_cannot_be_trusted(self):
+ out = self.run_step(1).stdout
+ self.assertNotIn("PACMAN_INSTALL: chatgpt-bin", out)
+ # The failure is local to the app; the rest of the step still runs.
+ self.assertIn("AUR_INSTALL: claude-desktop", out)
+ self.assertIn("AUR_INSTALL: freetube", out)
+
+ def test_does_not_also_pull_the_conflicting_aur_repackage(self):
+ out = self.run_step(0).stdout
+ self.assertNotIn("chatgpt-desktop", out)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_configure_service_discovery.py b/tests/installer-steps/test_configure_service_discovery.py
new file mode 100644
index 0000000..90ce041
--- /dev/null
+++ b/tests/installer-steps/test_configure_service_discovery.py
@@ -0,0 +1,85 @@
+"""Pin configure_service_discovery's source: the WS-Discovery host daemon
+is never enabled.
+
+wsdd.service advertises this machine as a Samba host to Windows clients.
+Nothing the installer sets up runs Samba, so enabled it advertised a share
+server that doesn't exist while listening on every interface, VPN and
+tailscale links included (2026-09-12). Browsing Windows shares is the other
+direction: gvfs-wsdd spawns its own wsdd in discovery mode and needs only
+the package.
+
+Method: the step writes straight to /etc (geoclue.conf, the dbus-broker
+drop-in) with no directory parameter, so unlike the other step tests this
+one can't run the function in a temp dir. It sed-extracts the source and
+asserts on the calls it contains.
+
+ python3 -m unittest tests.installer-steps.test_configure_service_discovery
+"""
+
+import os
+import re
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+
+
+def function_source(name):
+ with open(ARCHSETUP) as f:
+ text = f.read()
+ m = re.search(r"^%s\(\) \{\n.*?^\}\n" % re.escape(name), text, re.S | re.M)
+ assert m, "function %s not found in archsetup" % name
+ return m.group(0)
+
+
+ENABLE_WSDD = r"(systemctl\s+enable|enable_service)\s+(--now\s+)?wsdd\b"
+
+
+def calls(src):
+ """Non-comment lines — what the function actually runs."""
+ return [l for l in src.splitlines() if l.strip() and not l.strip().startswith("#")]
+
+
+class ConfigureServiceDiscovery(unittest.TestCase):
+ # ------------------------------------------------------------ normal ----
+ def test_does_not_enable_the_wsdd_host_daemon(self):
+ # Both spellings the script uses: a raw systemctl call and the
+ # enable_service helper (which takes the bare unit name).
+ body = "\n".join(calls(function_source("configure_service_discovery")))
+ self.assertNotRegex(body, ENABLE_WSDD)
+
+ def test_turns_off_a_previously_enabled_wsdd_on_rerun(self):
+ # Machines installed before this change still have the unit
+ # enabled; a re-run converges them. The guard keeps a fresh install
+ # (no unit yet) quiet.
+ body = "\n".join(calls(function_source("configure_service_discovery")))
+ self.assertRegex(body, r"systemctl\s+is-enabled\s+(--quiet\s+)?wsdd\.service")
+ self.assertRegex(body, r"systemctl\s+disable\s+--now\s+wsdd\.service")
+
+ def test_still_enables_the_discovery_it_does_want(self):
+ # Characterization: removing wsdd must not have taken avahi (mDNS)
+ # or geoclue with it.
+ body = "\n".join(calls(function_source("configure_service_discovery")))
+ self.assertIn("systemctl enable avahi-daemon.service", body)
+ self.assertIn("systemctl enable geoclue.service", body)
+
+ # ---------------------------------------------------------- boundary ----
+ def test_wsdd_package_still_installed_for_gvfs(self):
+ # The package stays: gvfs-wsdd depends on it and spawns its own
+ # discovery-mode instance. Only the host service is gone.
+ body = "\n".join(calls(function_source("supplemental_software")))
+ self.assertRegex(body, r"pacman_install\s+wsdd\b")
+ self.assertRegex(body, r"pacman_install\s+gvfs-wsdd\b")
+
+ # ------------------------------------------------------------- error ----
+ def test_no_other_step_enables_wsdd_either(self):
+ # A regression that re-enables it from a different step is the same
+ # bug; scan the whole script, not just the one function.
+ with open(ARCHSETUP) as f:
+ lines = [l for l in f if l.strip() and not l.strip().startswith("#")]
+ offenders = [l.rstrip() for l in lines if re.search(ENABLE_WSDD, l)]
+ self.assertEqual(offenders, [])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_configure_tlp_power.py b/tests/installer-steps/test_configure_tlp_power.py
new file mode 100644
index 0000000..dc46c93
--- /dev/null
+++ b/tests/installer-steps/test_configure_tlp_power.py
@@ -0,0 +1,239 @@
+"""Test configure_tlp_power's radio-enable line, daemon masking, and laptop gating.
+
+systemd-rfkill is masked on laptops because it fights TLP's radio handling —
+which means nothing restores radio state at boot unless TLP is told to. The
+velox 2026-04-10 setup found wifi and bluetooth soft-blocked on first boot for
+exactly this reason. The conf written here must carry
+DEVICES_TO_ENABLE_ON_STARTUP so a fresh install comes up with radios on.
+
+power-profiles-daemon is masked and stopped on laptops for the same class of
+reason. power-profiles-daemon.service declares "Conflicts=tuned.service
+tlp.service auto-cpufreq.service ..." — the line is in ppd's unit, not tlp's —
+so systemd TERMs TLP the instant ppd starts. Leaving ppd merely disabled does
+not prevent that: ppd ships D-Bus activation files, and the desktop-settings
+panel's own powerprofilesctl call activates it on demand. Velox ran that way
+from its 2026-08-13 rebuild until 2026-08-16, with TLP failing at every boot and
+none of its battery policy applied, while the machine looked correctly
+configured. Masking blocks D-Bus activation too, which both keeps TLP alive and
+makes the panel's power control read as unavailable, the behavior the
+package-install site in `archsetup` already documents as intended. The stop is
+what makes a repair re-run take effect on a booted machine, where a mask alone
+would leave a running ppd running.
+
+The bluetooth resume hook installed here must land in
+/usr/lib/systemd/system-sleep/. That is the only directory systemd-sleep scans
+(systemd 262's binary and its man page both name just the one), so a hook under
+/etc/systemd/system-sleep/ is silently dead. Velox ran about fifteen hibernate
+cycles between 2026-08-21 and 2026-10-05 with the hook installed there and it
+never fired once; bluetooth came back rfkilled every time, and TLP's own hook
+(which lives in /usr/lib and does run) carried the block across every later
+cycle.
+
+Method: sed-extract configure_tlp_power from the real `archsetup`, point it at
+a temp tlp.d dir and a temp power-supply dir, and fake pacman_install /
+run_task / display / error_warn / systemctl.
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_configure_tlp_power
+"""
+
+import os
+import stat
+import subprocess
+import tempfile
+import textwrap
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+
+
+def run(battery=True, bat_name="BAT0", unwritable_tlpd=False, systemctl_fails=False,
+ install_fails=False):
+ with tempfile.TemporaryDirectory() as d:
+ psdir = os.path.join(d, "power_supply")
+ os.makedirs(psdir)
+ if battery:
+ open(os.path.join(psdir, bat_name), "w").close()
+ tlpd = os.path.join(d, "tlp.d")
+ if unwritable_tlpd:
+ os.makedirs(tlpd)
+ os.chmod(tlpd, stat.S_IRUSR | stat.S_IXUSR)
+ # The real mask call redirects stdout into $logfile, so the fake
+ # systemctl records to a side file the test reads back instead.
+ sysrc = 1 if systemctl_fails else 0
+ # The real install_executable warns on its own; the fake only reports
+ # the return code so the test can see whether the step keeps going.
+ instrc = 1 if install_fails else 0
+ script = textwrap.dedent(f"""\
+ logfile=/dev/null
+ action=""
+ display() {{ :; }}
+ pacman_install() {{ echo "INSTALL: $1"; }}
+ run_task() {{ echo "TASK: $1"; }}
+ systemctl() {{ echo "SYSTEMCTL: $*" >> "{d}/systemctl.log"; return {sysrc}; }}
+ error_warn() {{ echo "WARN: $1"; return 1; }}
+ user_archsetup_dir=/src
+ install_executable() {{ echo "INSTALL_EXEC: $1 -> $2"; return {instrc}; }}
+ source <(sed -n '/^configure_tlp_power() {{/,/^}}/p' "{ARCHSETUP}")
+ configure_tlp_power "{tlpd}" "{psdir}"
+ echo "RC=$?"
+ echo "CONF:[$(cat "{tlpd}/01-custom.conf" 2>/dev/null)]"
+ [ -f "{d}/systemctl.log" ] && cat "{d}/systemctl.log"
+ exit 0
+ """)
+ r = subprocess.run(
+ ["bash", "-c", script], capture_output=True, text=True, timeout=10,
+ )
+ if unwritable_tlpd:
+ os.chmod(tlpd, stat.S_IRWXU)
+ return r
+
+
+class ConfigureTlpPower(unittest.TestCase):
+ # ------------------------------------------------------------ normal ----
+ def test_laptop_gets_tlp_with_radio_enable_line(self):
+ r = run(battery=True)
+ self.assertIn("INSTALL: tlp", r.stdout)
+ conf = r.stdout.split("CONF:[")[1]
+ self.assertIn('DEVICES_TO_ENABLE_ON_STARTUP="bluetooth wifi"', conf,
+ "radios must be re-enabled at boot: systemd-rfkill is "
+ "masked, so TLP is the only thing that can do it")
+ self.assertIn("CPU_ENERGY_PERF_POLICY_ON_AC", conf)
+ self.assertIn("SYSTEMCTL: mask systemd-rfkill.service systemd-rfkill.socket",
+ r.stdout)
+ self.assertIn("TASK: enabling TLP service", r.stdout)
+
+ def test_laptop_masks_power_profiles_daemon(self):
+ r = run(battery=True)
+ self.assertIn("SYSTEMCTL: mask power-profiles-daemon.service", r.stdout,
+ "ppd's unit declares Conflicts=...tlp.service..., so ppd "
+ "must be masked or it TERMs TLP whenever it is activated")
+
+ def test_laptop_stops_running_power_profiles_daemon(self):
+ """Masking alone leaves an already-running ppd running.
+
+ The installer runs on a booted system, so a repair re-run would
+ otherwise mask ppd, leave it live, and let it keep TLP dead until the
+ next reboot with nothing reporting it.
+ """
+ r = run(battery=True)
+ self.assertIn("SYSTEMCTL: stop power-profiles-daemon.service", r.stdout)
+
+ def test_ppd_is_masked_before_it_is_stopped(self):
+ """Order matters: stopping first leaves a window to re-activate in."""
+ calls = [line for line in run(battery=True).stdout.splitlines()
+ if line.startswith("SYSTEMCTL:") and "power-profiles-daemon" in line]
+ verbs = [line.split()[1] for line in calls]
+ self.assertEqual(verbs, ["mask", "stop"])
+
+ def test_power_profiles_daemon_is_masked_not_merely_disabled(self):
+ """Disabling ppd is not enough — D-Bus activation ignores it.
+
+ This is the whole point of the mask, so assert the verb directly. A
+ `disable` here would pass a naive "ppd is handled" check while leaving
+ the panel's powerprofilesctl call free to start ppd and kill TLP.
+ """
+ r = run(battery=True)
+ ppd_calls = [line for line in r.stdout.splitlines()
+ if line.startswith("SYSTEMCTL:") and "power-profiles-daemon" in line]
+ self.assertTrue(ppd_calls, "configure_tlp_power must act on ppd at all")
+ for line in ppd_calls:
+ self.assertNotIn(" disable ", line,
+ "disable leaves D-Bus activation live; only mask blocks it")
+
+ def test_radio_line_is_active_not_commented(self):
+ r = run(battery=True)
+ conf = r.stdout.split("CONF:[")[1].split("]")[0]
+ for line in conf.splitlines():
+ if "DEVICES_TO_ENABLE_ON_STARTUP" in line:
+ self.assertFalse(line.lstrip().startswith("#"),
+ "the radio-enable line must not be commented out")
+ break
+ else:
+ self.fail("DEVICES_TO_ENABLE_ON_STARTUP line missing from conf")
+
+ def test_laptop_installs_the_resume_hook_where_systemd_sleep_scans(self):
+ """The hook goes to /usr/lib/systemd/system-sleep/, the only scanned dir.
+
+ Assert the full install list, not a substring: a second copy under
+ /etc/ would pass an assertIn and still be the dead hook this test
+ exists to catch.
+ """
+ r = run(battery=True)
+ installs = [line for line in r.stdout.splitlines()
+ if line.startswith("INSTALL_EXEC:")]
+ self.assertEqual(
+ installs,
+ ["INSTALL_EXEC: /src/scripts/zz-bluetooth-resume -> "
+ "/usr/lib/systemd/system-sleep/zz-bluetooth-resume"],
+ "systemd-sleep never scans /etc/systemd/system-sleep/; a hook "
+ "there installs cleanly and never runs")
+
+ # ---------------------------------------------------------- boundary ----
+ def test_desktop_installs_no_resume_hook(self):
+ """No TLP on a desktop means no masked rfkill and no gap to fill."""
+ r = run(battery=False)
+ self.assertNotIn("INSTALL_EXEC:", r.stdout)
+
+ def test_desktop_without_battery_is_a_no_op(self):
+ r = run(battery=False)
+ self.assertNotIn("INSTALL:", r.stdout)
+ self.assertNotIn("SYSTEMCTL:", r.stdout)
+ self.assertIn("CONF:[]", r.stdout, "no conf may be written on a desktop")
+
+ def test_second_battery_index_still_counts_as_laptop(self):
+ r = run(battery=True, bat_name="BAT1")
+ self.assertIn("INSTALL: tlp", r.stdout)
+ self.assertIn('DEVICES_TO_ENABLE_ON_STARTUP', r.stdout)
+
+ def test_desktop_keeps_power_profiles_daemon(self):
+ """A batteryless machine must NOT get ppd masked.
+
+ There is no TLP on a desktop to conflict with it, and the package-install
+ site enables ppd precisely so the settings panel's three-way power
+ control works there. Masking it here would break that control for no gain.
+ """
+ r = run(battery=False)
+ self.assertNotIn("power-profiles-daemon", r.stdout)
+
+ # ------------------------------------------------------------- error ----
+ def test_failed_ppd_mask_warns_and_does_not_crash(self):
+ """A masking failure must surface, not pass silently.
+
+ Silence is the exact failure mode being fixed: velox looked configured
+ while TLP was dead. If the mask cannot be applied, say so.
+
+ Assert on the harness's own RC= line, not on r.returncode. The harness
+ script ends in a literal `exit 0`, so r.returncode is 0 no matter what
+ configure_tlp_power does — asserting it can never fail, which would make
+ this test the same silent no-op it exists to catch.
+ """
+ r = run(battery=True, systemctl_fails=True)
+ self.assertIn("WARN: masking power-profiles-daemon for TLP", r.stdout)
+ self.assertIn("WARN: stopping power-profiles-daemon for TLP", r.stdout)
+ self.assertIn("RC=", r.stdout,
+ "the function must return so the install continues, "
+ "not exit and take the script down with it")
+
+ def test_failed_hook_install_does_not_stop_the_ppd_mask(self):
+ """install_executable warns for itself, so the step must carry on.
+
+ The mask that follows is what keeps TLP alive; a hook copy that fails
+ (source missing on a partial checkout) must not take it with it.
+ """
+ r = run(battery=True, install_fails=True)
+ self.assertIn("INSTALL_EXEC:", r.stdout)
+ self.assertIn("SYSTEMCTL: mask power-profiles-daemon.service", r.stdout)
+ self.assertIn("RC=", r.stdout)
+
+ @unittest.skipUnless(os.geteuid() != 0, "root ignores directory write bits")
+ def test_unwritable_tlpd_warns_and_does_not_crash(self):
+ r = run(battery=True, unwritable_tlpd=True)
+ self.assertIn("WARN:", r.stdout,
+ "a failed conf write must surface through error_warn")
+ self.assertEqual(r.returncode, 0)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_configure_tunnel_dns_over_tls.py b/tests/installer-steps/test_configure_tunnel_dns_over_tls.py
new file mode 100644
index 0000000..89677d8
--- /dev/null
+++ b/tests/installer-steps/test_configure_tunnel_dns_over_tls.py
@@ -0,0 +1,147 @@
+"""Test configure_tunnel_dns_over_tls — per-link DoT off for VPN tunnels.
+
+The resolved drop-in pins DNSOverTLS=yes globally. Proton VPN (proton0) and
+the static Proton WireGuard profiles (wgpvpn) push an in-tunnel resolver,
+10.2.0.1, that answers plain port 53 and never completes TLS on 853, so
+every lookup through the tunnel hangs. The Proton client recreates its NM
+profile on each connect, so a per-profile setting can't stick; a
+NetworkManager [connection-*] default matched on the interface names is
+what NM pushes to resolved on every activation. Diagnosed 2026-09-09/10,
+verified live on ratio and velox.
+
+Method: sed-extract configure_tunnel_dns_over_tls from the real
+`archsetup`, point it at a temp conf.d, and assert on the file it writes.
+
+ python3 -m unittest tests.installer-steps.test_configure_tunnel_dns_over_tls
+"""
+
+import os
+import re
+import stat
+import subprocess
+import tempfile
+import textwrap
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+FILENAME = "tunnel-dns-over-tls.conf"
+
+
+def run(confdir):
+ script = textwrap.dedent(f"""\
+ logfile=/dev/null
+ action=""
+ display() {{ :; }}
+ error_warn() {{ echo "WARN: $1"; return 1; }}
+ source <(sed -n '/^configure_tunnel_dns_over_tls() {{/,/^}}/p' "{ARCHSETUP}")
+ configure_tunnel_dns_over_tls "{confdir}"
+ echo "RC=$?"
+ exit 0
+ """)
+ return subprocess.run(
+ ["bash", "-c", script], capture_output=True, text=True, timeout=10,
+ )
+
+
+def rc_of(r):
+ m = re.search(r"^RC=(\d+)$", r.stdout, re.M)
+ assert m, "no RC line in output: %r / %r" % (r.stdout, r.stderr)
+ return int(m.group(1))
+
+
+def body(confdir):
+ with open(os.path.join(confdir, FILENAME)) as f:
+ return f.read()
+
+
+def directives(text):
+ """The non-comment, non-blank lines — what NM actually parses."""
+ return [l.strip() for l in text.splitlines()
+ if l.strip() and not l.lstrip().startswith("#")]
+
+
+class ConfigureTunnelDnsOverTls(unittest.TestCase):
+ # ------------------------------------------------------------ normal ----
+ def test_writes_a_connection_default_that_turns_dot_off(self):
+ with tempfile.TemporaryDirectory() as d:
+ r = run(d)
+ self.assertEqual(rc_of(r), 0)
+ lines = directives(body(d))
+ self.assertIn("[connection-tunnel-dot]", lines)
+ self.assertIn("connection.dns-over-tls=0", lines)
+
+ def test_matches_both_proton_interfaces_and_nothing_else(self):
+ # proton0 is the Proton client; wgpvpn is the static WireGuard
+ # profiles. A match-device line without both leaves one tunnel
+ # broken; one with a wildcard would turn DoT off for wifi too.
+ with tempfile.TemporaryDirectory() as d:
+ run(d)
+ match = [l for l in directives(body(d)) if l.startswith("match-device=")]
+ self.assertEqual(len(match), 1)
+ devices = match[0].split("=", 1)[1].split(",")
+ self.assertEqual(sorted(devices),
+ ["interface-name:proton0", "interface-name:wgpvpn"])
+
+ def test_explains_why_the_global_setting_is_overridden_per_link(self):
+ # The file contradicts the resolved drop-in next to it, so it has
+ # to carry the reason: the in-tunnel resolver that never completes
+ # TLS, and why the Proton client can't hold a per-profile setting.
+ with tempfile.TemporaryDirectory() as d:
+ run(d)
+ text = body(d).lower()
+ self.assertIn("10.2.0.1", text)
+ self.assertIn("853", text)
+ self.assertIn("recreates", text)
+
+ def test_drop_in_is_world_readable_not_writable(self):
+ with tempfile.TemporaryDirectory() as d:
+ run(d)
+ mode = stat.S_IMODE(os.stat(os.path.join(d, FILENAME)).st_mode)
+ self.assertEqual(mode, 0o644)
+
+ # ---------------------------------------------------------- boundary ----
+ def test_running_twice_leaves_one_identical_file(self):
+ with tempfile.TemporaryDirectory() as d:
+ run(d)
+ first = body(d)
+ r = run(d)
+ self.assertEqual(rc_of(r), 0)
+ self.assertEqual(first, body(d))
+ self.assertEqual(os.listdir(d), [FILENAME])
+
+ def test_absent_directory_is_created(self):
+ with tempfile.TemporaryDirectory() as d:
+ nested = os.path.join(d, "etc", "NetworkManager", "conf.d")
+ r = run(nested)
+ self.assertEqual(rc_of(r), 0)
+ self.assertIn("connection.dns-over-tls=0", directives(body(nested)))
+
+ def test_leaves_sibling_drop_ins_alone(self):
+ # dns.conf, wifi-privacy.conf and wifi-powersave-off.conf share the
+ # directory; this step must add a file, never rewrite the dir.
+ with tempfile.TemporaryDirectory() as d:
+ sibling = os.path.join(d, "dns.conf")
+ with open(sibling, "w") as f:
+ f.write("[main]\ndns=systemd-resolved\n")
+ run(d)
+ with open(sibling) as f:
+ self.assertEqual(f.read(), "[main]\ndns=systemd-resolved\n")
+
+ # ------------------------------------------------------------- error ----
+ @unittest.skipUnless(os.geteuid() != 0, "root ignores directory write bits")
+ def test_unwritable_directory_warns_and_does_not_crash(self):
+ with tempfile.TemporaryDirectory() as d:
+ confdir = os.path.join(d, "ro")
+ os.mkdir(confdir)
+ os.chmod(confdir, 0o500)
+ try:
+ r = run(confdir)
+ self.assertIn("WARN:", r.stdout)
+ self.assertNotEqual(rc_of(r), 0)
+ finally:
+ os.chmod(confdir, 0o700)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_dotfiles_dependency_packages.py b/tests/installer-steps/test_dotfiles_dependency_packages.py
new file mode 100644
index 0000000..58b7275
--- /dev/null
+++ b/tests/installer-steps/test_dotfiles_dependency_packages.py
@@ -0,0 +1,119 @@
+"""Pin the packages the dotfiles assume are present.
+
+Three packages are not optional extras: something the dotfiles ship depends
+on each one, and when the package is missing the dependent silently does the
+wrong thing rather than failing.
+
+- libreoffice-fresh :: common/.config/mimeapps.list maps presentations,
+ documents and spreadsheets to libreoffice-impress/-writer/-calc. With the
+ package absent those .desktop files don't exist, so xdg-mime falls through
+ to the next application claiming the type — on a machine with the winvm
+ dotfiles that is powerpoint.desktop, which boots a Windows VM to open a
+ deck (2026-09-18).
+- imv :: gui-open --image execs imv, and without it every agent-side image
+ render fails with "required application is unavailable" (2026-09-18).
+- git-lfs :: a repo tracking globs in LFS fails every checkout and merge
+ with "smudge filter lfs failed" (2026-09-20).
+
+All three were installed before the 2026-08-13 rebuild and absent after it,
+which is the regression this pins: they are dependencies of shipped defaults,
+so the installer has to declare them rather than leave them to whatever a
+machine happens to carry.
+
+Method mirrors test_required_software: sed-extract supplemental_software from
+the real `archsetup`, stub pacman_install as a recorder, run it, and assert
+against what it actually invoked. Running the function beats matching its
+source text, because the property under test is "every machine installs this"
+and only a run resolves the conditionals that could make that false. The
+function is run once per desktop environment for the same reason.
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_dotfiles_dependency_packages
+"""
+
+import os
+import subprocess
+import textwrap
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+
+DEPENDENCY_PACKAGES = ("libreoffice-fresh", "imv", "git-lfs")
+
+# Every desktop environment the installer branches on inside this function.
+DESKTOP_ENVS = ("dwm", "hyprland")
+
+
+def declared_packages(desktop_env):
+ """Return (exit_code, [pacman package, ...]) for one desktop environment.
+
+ Everything the function calls besides pacman_install is stubbed to a no-op,
+ so the run records package declarations and nothing else. aur_install is
+ deliberately separate: these three are pacman packages, and folding the two
+ recorders together would let an AUR declaration satisfy the pin.
+ """
+ script = textwrap.dedent(f"""\
+ desktop_env={desktop_env}
+ display() {{ :; }}
+ aur_install() {{ :; }}
+ mask_fwupd_passim() {{ :; }}
+ run_task() {{ :; }}
+ error_warn() {{ :; }}
+ pacman_install() {{ echo "$1"; }}
+ source <(sed -n '/^supplemental_software() {{/,/^}}/p' "{ARCHSETUP}")
+ supplemental_software
+ """)
+ result = subprocess.run(
+ ["bash", "-c", script], capture_output=True, text=True, timeout=30,
+ )
+ return result.returncode, result.stdout.split()
+
+
+class DotfilesDependencyPackages(unittest.TestCase):
+ # ------------------------------------------------------------ normal ----
+ def test_each_dependency_package_is_installed(self):
+ rc, pkgs = declared_packages("hyprland")
+ self.assertEqual(rc, 0)
+ for package in DEPENDENCY_PACKAGES:
+ with self.subTest(package=package):
+ self.assertIn(
+ package, pkgs,
+ f"{package} is a dependency of a shipped dotfiles default "
+ "and must be declared",
+ )
+
+ # ---------------------------------------------------------- boundary ----
+ def test_each_is_declared_exactly_once(self):
+ # A second declaration is dead weight and drifts out of sync with the
+ # first when one of them is edited.
+ rc, pkgs = declared_packages("hyprland")
+ self.assertEqual(rc, 0)
+ for package in DEPENDENCY_PACKAGES:
+ with self.subTest(package=package):
+ self.assertEqual(pkgs.count(package), 1)
+
+ # ------------------------------------------------------------- error ----
+ def test_none_is_gated_behind_a_desktop_environment(self):
+ # The dotfiles defaults that need these apply on every DE, so a
+ # declaration reachable under only one of them would leave the same
+ # hole on the other.
+ for env in DESKTOP_ENVS:
+ rc, pkgs = declared_packages(env)
+ self.assertEqual(rc, 0)
+ for package in DEPENDENCY_PACKAGES:
+ with self.subTest(desktop_env=env, package=package):
+ self.assertIn(package, pkgs)
+
+ def test_harness_observes_desktop_environment_gating(self):
+ # The control for the test above: ranger IS gated to dwm on purpose, so
+ # if this run can't see that, the DE-independence assertion is vacuous
+ # and would pass against a gated package too.
+ _, dwm = declared_packages("dwm")
+ _, hyprland = declared_packages("hyprland")
+ self.assertIn("ranger", dwm)
+ self.assertNotIn("ranger", hyprland)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_idempotency_cluster.py b/tests/installer-steps/test_idempotency_cluster.py
index ecb279d..0cca750 100644
--- a/tests/installer-steps/test_idempotency_cluster.py
+++ b/tests/installer-steps/test_idempotency_cluster.py
@@ -114,6 +114,33 @@ class EnableUserService(unittest.TestCase):
out = run("enable_user_service", one).stdout
self.assertIn("RC=0", out)
+ def test_socket_unit_lands_in_sockets_target_wants(self):
+ # A socket unit's [Install] is WantedBy=sockets.target, so enabling it
+ # via default.target.wants would never socket-activate. The optional
+ # fifth arg names the wants target.
+ with tempfile.TemporaryDirectory() as home:
+ body = (
+ f'enable_user_service {ME!r} podman.socket '
+ f'/usr/lib/systemd/user/podman.socket {home!r} sockets.target\n'
+ f'link="{home}/.config/systemd/user/sockets.target.wants/podman.socket"\n'
+ f'[ -L "$link" ] && echo "LINK=yes" || echo "LINK=no"\n'
+ f'echo "TARGET=$(readlink "$link")"'
+ )
+ out = run("enable_user_service", body).stdout
+ self.assertIn("LINK=yes", out)
+ self.assertIn("TARGET=/usr/lib/systemd/user/podman.socket", out)
+
+ def test_omitted_target_still_defaults_to_default_target(self):
+ with tempfile.TemporaryDirectory() as home:
+ body = (
+ f'enable_user_service {ME!r} gamemoded.service '
+ f'/usr/lib/systemd/user/gamemoded.service {home!r}\n'
+ f'[ -L "{home}/.config/systemd/user/default.target.wants/gamemoded.service" ] '
+ f'&& echo "DEFAULT=yes" || echo "DEFAULT=no"'
+ )
+ out = run("enable_user_service", body).stdout
+ self.assertIn("DEFAULT=yes", out)
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/installer-steps/test_install_camera_passthrough_rules.py b/tests/installer-steps/test_install_camera_passthrough_rules.py
new file mode 100644
index 0000000..ab28575
--- /dev/null
+++ b/tests/installer-steps/test_install_camera_passthrough_rules.py
@@ -0,0 +1,118 @@
+"""Test install_camera_passthrough_rules — the VM camera udev grant.
+
+usbredirect must open a camera's raw USB node read-write to claim it for the
+Windows VM; the node defaults to root-owned with no group write, so the
+attach fails with a bare "Failed to open device!". The rule grants
+GROUP="video", MODE="0660" (the verified fix) and keeps TAG+="uaccess".
+
+The file NUMBER is load-bearing (winvm correction, 2026-08-08): the uaccess
+ACL is applied by 73-seat-late.rules, so a 99- file adds the tag after that
+already ran. The shipped filename must sort below 73.
+
+Method: sed-extract install_camera_passthrough_rules from the real
+`archsetup`, point it at a temp rules path, fake display / error_warn.
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_install_camera_passthrough_rules
+"""
+
+import os
+import re
+import stat
+import subprocess
+import tempfile
+import textwrap
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+
+
+def run(rules_path, pre=""):
+ script = textwrap.dedent(f"""\
+ logfile=/dev/null
+ action=""
+ display() {{ :; }}
+ error_warn() {{ echo "WARN: $1"; return 1; }}
+ source <(sed -n '/^install_camera_passthrough_rules() {{/,/^}}/p' "{ARCHSETUP}")
+ {pre}
+ install_camera_passthrough_rules "{rules_path}"
+ echo "RC=$?"
+ echo "RULES:[$(cat "{rules_path}" 2>/dev/null)]"
+ exit 0
+ """)
+ return subprocess.run(
+ ["bash", "-c", script], capture_output=True, text=True, timeout=10,
+ )
+
+
+def rules_body(r):
+ return r.stdout.split("RULES:[")[1].split("]")[0]
+
+
+class InstallCameraPassthroughRules(unittest.TestCase):
+ # ------------------------------------------------------------ normal ----
+ def test_writes_both_camera_rules_with_group_mode_and_tag(self):
+ with tempfile.TemporaryDirectory() as d:
+ r = run(os.path.join(d, "72-test.rules"))
+ body = rules_body(r)
+ for vendor, product in (("3564", "ff02"), ("046d", "085e")):
+ line = next((ln for ln in body.splitlines()
+ if f'ATTR{{idVendor}}=="{vendor}"' in ln), None)
+ assert line is not None, f"no rule line for {vendor}:{product}"
+ self.assertIn(f'ATTR{{idProduct}}=="{product}"', line)
+ self.assertIn('GROUP="video"', line)
+ self.assertIn('MODE="0660"', line)
+ self.assertIn('TAG+="uaccess"', line)
+ self.assertIn("RC=0", r.stdout)
+
+ def test_default_filename_sorts_below_seat_late(self):
+ # The rule file's default install path must sort before
+ # 73-seat-late.rules or the uaccess tag lands too late to be ACLed.
+ with open(ARCHSETUP) as f:
+ src = f.read()
+ func = re.search(
+ r'^install_camera_passthrough_rules\(\)\s*{.*?^}', src, re.S | re.M)
+ assert func is not None, "function not found in archsetup"
+ m = re.search(r'\$\{1:-(/etc/udev/rules\.d/[^}]+)\}', func.group(0))
+ assert m is not None, "default rules path not found in the function"
+ basename = os.path.basename(m.group(1))
+ self.assertLess(basename, "73-seat-late.rules",
+ "the rules file must sort below 73-seat-late.rules")
+
+ # ---------------------------------------------------------- boundary ----
+ def test_rerun_is_idempotent(self):
+ with tempfile.TemporaryDirectory() as d:
+ path = os.path.join(d, "72-test.rules")
+ first = run(path)
+ body_one = rules_body(first)
+ second = run(path)
+ self.assertEqual(body_one, rules_body(second))
+ self.assertIn("RC=0", second.stdout)
+
+ def test_overwrites_a_stale_existing_file(self):
+ with tempfile.TemporaryDirectory() as d:
+ path = os.path.join(d, "72-test.rules")
+ with open(path, "w") as f:
+ f.write("# stale content that must not survive\n")
+ r = run(path)
+ self.assertNotIn("stale content", rules_body(r))
+ self.assertIn('GROUP="video"', rules_body(r))
+
+ # ------------------------------------------------------------- error ----
+ @unittest.skipUnless(os.geteuid() != 0, "root ignores directory write bits")
+ def test_unwritable_dir_warns_and_does_not_crash(self):
+ with tempfile.TemporaryDirectory() as d:
+ locked = os.path.join(d, "locked")
+ os.makedirs(locked)
+ os.chmod(locked, stat.S_IRUSR | stat.S_IXUSR)
+ r = run(os.path.join(locked, "72-test.rules"))
+ os.chmod(locked, stat.S_IRWXU)
+ self.assertIn("WARN:", r.stdout)
+ self.assertIn("RC=", r.stdout,
+ "the harness must reach its RC line — the function "
+ "returned rather than aborting the script")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_install_cpu_microcode.py b/tests/installer-steps/test_install_cpu_microcode.py
new file mode 100644
index 0000000..20f1820
--- /dev/null
+++ b/tests/installer-steps/test_install_cpu_microcode.py
@@ -0,0 +1,110 @@
+"""Test install_cpu_microcode — vendor-detected microcode package install.
+
+archsetup never installed intel-ucode/amd-ucode (found on velox 2026-04-10:
+CPU running old microcode, hand-fixed). The step reads vendor_id from
+/proc/cpuinfo and installs the matching package. It must run before
+configure_grub in boot_ux: grub-mkconfig detects /boot/<vendor>-ucode.img for
+its initrd lines, and mkinitcpio's microcode hook embeds it, so the package
+has to exist before either generates.
+
+Method: sed-extract install_cpu_microcode from the real `archsetup`, point it
+at a fixture cpuinfo, fake pacman_install / display / error_warn.
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_install_cpu_microcode
+"""
+
+import os
+import re
+import subprocess
+import tempfile
+import textwrap
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+
+INTEL = "vendor_id\t: GenuineIntel\n"
+AMD = "vendor_id\t: AuthenticAMD\n"
+
+
+def run(cpuinfo_body, missing=False):
+ with tempfile.TemporaryDirectory() as d:
+ cpuinfo = os.path.join(d, "cpuinfo")
+ if not missing:
+ with open(cpuinfo, "w") as f:
+ f.write(cpuinfo_body)
+ script = textwrap.dedent(f"""\
+ logfile=/dev/null
+ action=""
+ display() {{ :; }}
+ pacman_install() {{ echo "INSTALL: $1"; }}
+ error_warn() {{ echo "WARN: $1"; return 1; }}
+ source <(sed -n '/^install_cpu_microcode() {{/,/^}}/p' "{ARCHSETUP}")
+ install_cpu_microcode "{cpuinfo}"
+ echo "RC=$?"
+ exit 0
+ """)
+ return subprocess.run(
+ ["bash", "-c", script], capture_output=True, text=True, timeout=10,
+ )
+
+
+def rc_of(r):
+ m = re.search(r"^RC=(\d+)$", r.stdout, re.M)
+ assert m, "no RC line in output: %r / %r" % (r.stdout, r.stderr)
+ return int(m.group(1))
+
+
+class InstallCpuMicrocode(unittest.TestCase):
+ # ------------------------------------------------------------ normal ----
+ def test_intel_vendor_installs_intel_ucode(self):
+ r = run(f"processor\t: 0\n{INTEL}model name\t: whatever\n")
+ self.assertIn("INSTALL: intel-ucode", r.stdout)
+ self.assertNotIn("amd-ucode", r.stdout)
+ self.assertEqual(rc_of(r), 0)
+
+ def test_amd_vendor_installs_amd_ucode(self):
+ r = run(f"processor\t: 0\n{AMD}")
+ self.assertIn("INSTALL: amd-ucode", r.stdout)
+ self.assertNotIn("intel-ucode", r.stdout)
+ self.assertEqual(rc_of(r), 0)
+
+ # ---------------------------------------------------------- boundary ----
+ def test_multicore_cpuinfo_installs_exactly_once(self):
+ # /proc/cpuinfo repeats vendor_id per logical CPU.
+ body = "".join(f"processor\t: {i}\n{AMD}\n" for i in range(16))
+ r = run(body)
+ self.assertEqual(r.stdout.count("INSTALL:"), 1,
+ "one package install, not one per core")
+
+ def test_space_separated_vendor_line_parses(self):
+ # Some kernels/arches pad with spaces rather than a tab.
+ r = run("vendor_id : GenuineIntel\n")
+ self.assertIn("INSTALL: intel-ucode", r.stdout)
+
+ def test_vendor_id_substring_elsewhere_does_not_confuse(self):
+ # A flags line or model name mentioning a vendor string must not win
+ # over the real vendor_id line.
+ body = "model name\t: AuthenticAMD emulator\n" + INTEL
+ r = run(body)
+ self.assertIn("INSTALL: intel-ucode", r.stdout)
+ self.assertNotIn("amd-ucode", r.stdout)
+
+ # ------------------------------------------------------------- error ----
+ def test_unknown_vendor_warns_and_installs_nothing(self):
+ r = run("vendor_id\t: CentaurHauls\n")
+ self.assertNotIn("INSTALL:", r.stdout)
+ self.assertIn("WARN:", r.stdout)
+ self.assertEqual(rc_of(r), 1)
+
+ def test_missing_cpuinfo_warns_and_installs_nothing(self):
+ r = run("", missing=True)
+ self.assertNotIn("INSTALL:", r.stdout)
+ self.assertIn("WARN:", r.stdout)
+ self.assertEqual(rc_of(r), 1)
+ self.assertEqual(r.returncode, 0)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_install_gnome_keyring_fix.py b/tests/installer-steps/test_install_gnome_keyring_fix.py
new file mode 100644
index 0000000..1a4115a
--- /dev/null
+++ b/tests/installer-steps/test_install_gnome_keyring_fix.py
@@ -0,0 +1,209 @@
+"""Tests for install_gnome_keyring_fix in the archsetup installer.
+
+gnome-keyring 51.1 (and 48 through 50) aborts when a Secret Service client's
+first OpenSession is dispatched before the daemon has created its per-client
+record. Every abort loses the unlocked state, so the next request pops the
+keyring password dialog; on ratio this fired three times a day once the
+ChatGPT desktop app's codex app-server started bursting keyring reads. The
+fix is upstream merge request !112 plus an alias-lifetime correction, carried
+as a patched rebuild of Arch's own package under packages/gnome-keyring in
+this repo (pkgrel 1.1). install_gnome_keyring_fix builds and installs it with
+makepkg as the user when the stock 1:51.1-1 is what is installed.
+
+Versioning is the whole contract. The rebuild sorts above stock 1:51.1-1 and
+below any future 1:51.1-2 or 51.2, so a normal pacman upgrade replaces it the
+moment Arch or upstream ships a fix, and the step must never pin or hold the
+package. When something newer than the rebuild is already installed, the fix
+is assumed upstream and the step skips with a warning so the assumption is
+visible rather than silent. When the rebuild itself is installed, the step is
+a no-op.
+
+These tests exercise the REAL function body, extracted from the `archsetup`
+script at run time. pacman, vercmp, makepkg and sudo are replaced by
+recorders driven by environment switches; the package directory is a temp
+copy of the real packages/gnome-keyring tree.
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_install_gnome_keyring_fix
+"""
+
+import os
+import re
+import shutil
+import subprocess
+import tempfile
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+PACKAGE_DIR = os.path.join(REPO_ROOT, "packages", "gnome-keyring")
+
+STUBS = r"""
+logfile=/dev/null
+action=""
+username=tester
+display() { echo "DISPLAY: $*"; }
+pacman_install() { echo "PACMAN_INSTALL: $1" >> "$MARKER"; }
+error_warn() { echo "WARN:$1"; return 1; }
+# pacman -Q prints the installed version, or fails when INSTALLED is empty.
+pacman() {
+ echo "PACMAN: $*" >> "$MARKER"
+ case "$1" in
+ -Q) [ -n "$INSTALLED" ] && echo "gnome-keyring $INSTALLED" ;;
+ *) return 0 ;;
+ esac
+}
+# The real vercmp is used so the ordering under test is pacman's own.
+makepkg() {
+ echo "MAKEPKG: $* (pwd=$PWD)" >> "$MARKER"
+ [ "$MAKEPKG_FAILS" = 1 ] && return 2
+ return 0
+}
+# sudo -u user cmd... runs cmd in place; the recorder notes the user.
+sudo() {
+ echo "SUDO: $*" >> "$MARKER"
+ shift 2
+ "$@"
+}
+safe_rm_rf() { rm -rf "$1"; }
+# chown needs root; the tests only care that the copy was staged.
+chown() { echo "CHOWN: $*" >> "$MARKER"; }
+"""
+
+
+def run(pkg_dir, installed, makepkg_fails=False, source_dir=None):
+ marker = tempfile.NamedTemporaryFile(prefix="keyring-fix-", delete=False)
+ marker.close()
+ source_dir = source_dir or tempfile.mkdtemp(prefix="keyring-src-")
+ script = (
+ STUBS
+ + "source <(sed -n '/^install_gnome_keyring_fix() {/,/^}/p' \"%s\")\n"
+ + 'user_archsetup_dir="%s"\n'
+ + 'source_dir="%s"\n'
+ + "install_gnome_keyring_fix\n"
+ + 'echo "RC=$?"\n'
+ ) % (ARCHSETUP, pkg_dir, source_dir)
+ env = dict(os.environ, MARKER=marker.name, INSTALLED=installed,
+ MAKEPKG_FAILS="1" if makepkg_fails else "0")
+ result = subprocess.run(["bash", "-c", script], capture_output=True,
+ text=True, timeout=30, env=env)
+ with open(marker.name) as f:
+ calls = f.read().splitlines()
+ os.remove(marker.name)
+ return result, calls, source_dir
+
+
+class InstallGnomeKeyringFix(unittest.TestCase):
+ def setUp(self):
+ # A fake archsetup checkout holding a copy of the real package tree.
+ self.checkout = tempfile.mkdtemp(prefix="archsetup-checkout-")
+ self.addCleanup(shutil.rmtree, self.checkout, ignore_errors=True)
+ shutil.copytree(PACKAGE_DIR, os.path.join(self.checkout, "packages", "gnome-keyring"))
+ self.source_dir = tempfile.mkdtemp(prefix="keyring-src-")
+ self.addCleanup(shutil.rmtree, self.source_dir, ignore_errors=True)
+
+ def _run(self, installed, **kw):
+ return run(self.checkout, installed, source_dir=self.source_dir, **kw)
+
+ @staticmethod
+ def _makepkg_calls(calls):
+ return [c for c in calls if c.startswith("MAKEPKG:")]
+
+ # ---------------------------------------------------------- normal ----
+ def test_stock_51_1_gets_the_rebuild(self):
+ result, calls, _ = self._run("1:51.1-1")
+ self.assertEqual(len(self._makepkg_calls(calls)), 1, calls)
+ self.assertIn("RC=0", result.stdout)
+ self.assertNotIn("WARN:", result.stdout)
+
+ def test_builds_as_the_user_with_syncdeps_and_install(self):
+ _, calls, _ = self._run("1:51.1-1")
+ self.assertTrue(any(c.startswith("SUDO: -u tester makepkg") for c in calls), calls)
+ makepkg = self._makepkg_calls(calls)[0]
+ self.assertIn("--noconfirm", makepkg)
+ # -s (sync makedepends) and -i (install), spelled separately or combined.
+ short = "".join(t[1:] for t in makepkg.split() if re.fullmatch(r"-[a-zA-Z]+", t))
+ self.assertIn("s", short, makepkg)
+ self.assertIn("i", short, makepkg)
+
+ def test_builds_from_a_user_owned_copy_not_the_checkout(self):
+ # makepkg writes into its directory; the checkout must stay clean.
+ _, calls, _ = self._run("1:51.1-1")
+ makepkg = self._makepkg_calls(calls)[0]
+ self.assertIn("pwd=" + self.source_dir, makepkg)
+ self.assertNotIn("pwd=" + self.checkout, makepkg)
+ self.assertTrue(os.path.exists(os.path.join(self.source_dir, "gnome-keyring", "PKGBUILD")))
+
+ def test_older_stock_releases_also_get_the_rebuild(self):
+ # 50.0 carries the same bug; the rebuild is 51.1 so it is an upgrade.
+ _, calls, _ = self._run("1:50.0-1")
+ self.assertEqual(len(self._makepkg_calls(calls)), 1)
+
+ # -------------------------------------------------------- boundary ----
+ def test_rebuild_already_installed_is_a_noop(self):
+ result, calls, _ = self._run("1:51.1-1.1")
+ self.assertEqual(self._makepkg_calls(calls), [])
+ self.assertIn("RC=0", result.stdout)
+ self.assertNotIn("WARN:", result.stdout)
+
+ def test_newer_package_skips_visibly_without_counting_an_error(self):
+ for newer in ("1:51.1-2", "1:51.2-1", "1:52.0-1"):
+ with self.subTest(installed=newer):
+ result, calls, _ = self._run(newer)
+ self.assertEqual(self._makepkg_calls(calls), [], newer)
+ self.assertIn("newer than the rebuild", result.stdout,
+ "a newer package means the fix is assumed upstream; say so")
+ self.assertNotIn("WARN:", result.stdout,
+ "a deliberate skip must not count as an installer error")
+ self.assertIn("RC=0", result.stdout)
+
+ def test_rerun_after_success_does_not_rebuild(self):
+ _, calls, _ = self._run("1:51.1-1")
+ self.assertEqual(len(self._makepkg_calls(calls)), 1)
+ _, calls, _ = self._run("1:51.1-1.1")
+ self.assertEqual(self._makepkg_calls(calls), [])
+
+ def test_missing_package_installs_stock_first_then_builds(self):
+ # With no gnome-keyring present, stock is installed as the baseline
+ # and the rebuild then upgrades it; both happen in that order.
+ result, calls, _ = self._run("")
+ self.assertEqual(len(self._makepkg_calls(calls)), 1)
+ stock = [i for i, c in enumerate(calls) if c.startswith("PACMAN_INSTALL: gnome-keyring")]
+ build = [i for i, c in enumerate(calls) if c.startswith("MAKEPKG:")]
+ self.assertEqual(len(stock), 1, calls)
+ self.assertLess(stock[0], build[0], "stock install must precede the build")
+ self.assertIn("RC=0", result.stdout)
+
+ def test_rerun_with_rebuild_installed_never_touches_stock(self):
+ # pacman's --needed only skips an equal version, so a stock install
+ # ahead of the rebuild would downgrade it on every re-run. The step
+ # must not call the stock install at all once anything is installed.
+ for installed in ("1:51.1-1.1", "1:51.1-1", "1:51.2-1"):
+ with self.subTest(installed=installed):
+ _, calls, _ = self._run(installed)
+ self.assertFalse(any(c.startswith("PACMAN_INSTALL:") for c in calls), calls)
+
+ # ----------------------------------------------------------- error ----
+ def test_failed_build_warns_and_leaves_stock_in_place(self):
+ result, calls, _ = self._run("1:51.1-1", makepkg_fails=True)
+ self.assertIn("WARN:", result.stdout)
+ self.assertNotIn("RC=0", result.stdout)
+ self.assertFalse(any(c.startswith("PACMAN: -U") for c in calls),
+ "nothing may be installed by hand after a failed build")
+
+ def test_missing_recipe_warns_instead_of_building(self):
+ shutil.rmtree(os.path.join(self.checkout, "packages", "gnome-keyring"))
+ result, calls, _ = self._run("1:51.1-1")
+ self.assertEqual(self._makepkg_calls(calls), [])
+ self.assertIn("WARN:", result.stdout)
+
+ def test_never_pins_or_holds_the_package(self):
+ # No IgnorePkg edit and no pacman -D hold: the next upstream fix must
+ # replace the rebuild on a normal upgrade.
+ _, calls, _ = self._run("1:51.1-1")
+ self.assertFalse(any("IgnorePkg" in c for c in calls))
+ self.assertFalse(any(c.startswith("PACMAN: -D") for c in calls))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_mask_fwupd_passim.py b/tests/installer-steps/test_mask_fwupd_passim.py
new file mode 100644
index 0000000..8977504
--- /dev/null
+++ b/tests/installer-steps/test_mask_fwupd_passim.py
@@ -0,0 +1,79 @@
+"""Test mask_fwupd_passim — keep fwupd's LAN metadata daemon off.
+
+fwupd pulls in passim, a daemon that shares firmware metadata with other
+machines on the LAN by listening publicly on 0.0.0.0:27500. Any fwupdmgr
+run D-Bus-activates it, and because the unit is static (no [Install]
+section) `systemctl disable` is a no-op: it came back on velox the next
+time fwupdmgr ran (2026-09-12). Masking is what holds, and it is how
+ratio has carried it since 2026-07-21.
+
+Method: sed-extract mask_fwupd_passim from the real `archsetup`; fake
+run_task (minus error_warn, so the failure test sees the function's own
+return path) and systemctl, and assert on the call.
+
+ python3 -m unittest tests.installer-steps.test_mask_fwupd_passim
+"""
+
+import os
+import re
+import subprocess
+import textwrap
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+
+
+def run(systemctl_body='echo "SYSTEMCTL: $*";'):
+ script = textwrap.dedent(f"""\
+ logfile=/dev/null
+ action=""
+ display() {{ :; }}
+ run_task() {{ echo "TASK: $1"; shift; "$@"; }}
+ systemctl() {{ {systemctl_body} }}
+ error_warn() {{ echo "WARN: $1"; return 1; }}
+ source <(sed -n '/^mask_fwupd_passim() {{/,/^}}/p' "{ARCHSETUP}")
+ mask_fwupd_passim
+ echo "RC=$?"
+ exit 0
+ """)
+ return subprocess.run(
+ ["bash", "-c", script], capture_output=True, text=True, timeout=10,
+ )
+
+
+def rc_of(r):
+ m = re.search(r"^RC=(\d+)$", r.stdout, re.M)
+ assert m, "no RC line in output: %r / %r" % (r.stdout, r.stderr)
+ return int(m.group(1))
+
+
+class MaskFwupdPassim(unittest.TestCase):
+ # ------------------------------------------------------------ normal ----
+ def test_masks_the_passim_unit(self):
+ r = run()
+ self.assertIn("SYSTEMCTL: mask passim.service", r.stdout)
+ self.assertEqual(rc_of(r), 0)
+
+ def test_masks_rather_than_disables(self):
+ # disable is a no-op on a static unit and is the mistake this step
+ # exists to avoid; a mask is the only call that sticks.
+ r = run()
+ calls = [l for l in r.stdout.splitlines() if l.startswith("SYSTEMCTL:")]
+ self.assertEqual(calls, ["SYSTEMCTL: mask passim.service"])
+
+ # ---------------------------------------------------------- boundary ----
+ # Re-running is idempotent because `systemctl mask` on an already-masked
+ # unit exits 0 and changes nothing; that property lives in systemctl, so
+ # there is no stateless test here that could tell it apart from a pass.
+
+ # ------------------------------------------------------------- error ----
+ def test_failed_mask_reports_nonzero(self):
+ # The fake run_task returns the command's status without the real
+ # error_warn wiring, so this checks the function's own return path.
+ r = run(systemctl_body='echo "SYSTEMCTL: $*"; return 1;')
+ self.assertNotEqual(rc_of(r), 0)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_mask_tmp_mount_for_zfs.py b/tests/installer-steps/test_mask_tmp_mount_for_zfs.py
new file mode 100644
index 0000000..f03086f
--- /dev/null
+++ b/tests/installer-steps/test_mask_tmp_mount_for_zfs.py
@@ -0,0 +1,109 @@
+"""Test mask_tmp_mount_for_zfs — the tmpfs-over-ZFS /tmp guard.
+
+When the pool carries a /tmp dataset, systemd's tmp.mount (tmpfs) races it at
+boot; when tmpfs wins, the dataset is shadowed and systemd-tmpfiles-clean
+fails repeatedly with "Protocol driver not attached" (velox, 2026-04-10). The
+fix is masking tmp.mount so the dataset owns /tmp — but only when such a
+dataset actually exists, and never on a machine without zfs at all.
+
+Method: sed-extract mask_tmp_mount_for_zfs from the real `archsetup`; fake
+zfs / run_task / systemctl. The zfs-binary-absent case runs with a stripped
+PATH containing only the tools the function itself needs.
+
+Run from repo root:
+ python3 -m unittest tests.installer-steps.test_mask_tmp_mount_for_zfs
+"""
+
+import os
+import re
+import subprocess
+import tempfile
+import textwrap
+import unittest
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
+
+
+def run(zfs_body=None, strip_zfs_from_path=False):
+ """zfs_body: bash body for a fake zfs function, or None for no fake.
+ strip_zfs_from_path: run with a minimal PATH that has grep/sed but no zfs.
+ """
+ with tempfile.TemporaryDirectory() as d:
+ path_setup = ""
+ if strip_zfs_from_path:
+ fakebin = os.path.join(d, "bin")
+ os.makedirs(fakebin)
+ for tool in ("grep", "sed", "cat"):
+ src = subprocess.run(["bash", "-lc", f"command -v {tool}"],
+ capture_output=True, text=True).stdout.strip()
+ if src:
+ os.symlink(src, os.path.join(fakebin, tool))
+ path_setup = f'PATH="{fakebin}"'
+ zfs_fake = f"zfs() {{ {zfs_body} }}" if zfs_body is not None else ""
+ script = textwrap.dedent(f"""\
+ logfile=/dev/null
+ action=""
+ display() {{ :; }}
+ run_task() {{ echo "TASK: $1"; shift; "$@"; }}
+ systemctl() {{ echo "SYSTEMCTL: $*"; }}
+ error_warn() {{ echo "WARN: $1"; return 1; }}
+ {zfs_fake}
+ source <(sed -n '/^mask_tmp_mount_for_zfs() {{/,/^}}/p' "{ARCHSETUP}")
+ {path_setup}
+ mask_tmp_mount_for_zfs
+ echo "RC=$?"
+ exit 0
+ """)
+ return subprocess.run(
+ ["bash", "-c", script], capture_output=True, text=True, timeout=10,
+ )
+
+
+def rc_of(r):
+ m = re.search(r"^RC=(\d+)$", r.stdout, re.M)
+ assert m, "no RC line in output: %r / %r" % (r.stdout, r.stderr)
+ return int(m.group(1))
+
+
+class MaskTmpMountForZfs(unittest.TestCase):
+ # ------------------------------------------------------------ normal ----
+ def test_tmp_dataset_present_masks_tmp_mount(self):
+ r = run(zfs_body='printf "/\\n/home\\n/tmp\\n";')
+ self.assertIn("SYSTEMCTL: mask tmp.mount", r.stdout)
+ self.assertEqual(rc_of(r), 0)
+
+ # ---------------------------------------------------------- boundary ----
+ def test_no_tmp_dataset_is_a_no_op(self):
+ r = run(zfs_body='printf "/\\n/home\\n/var\\n";')
+ self.assertNotIn("SYSTEMCTL:", r.stdout)
+ self.assertEqual(rc_of(r), 0)
+
+ def test_tmp_prefix_dataset_does_not_match(self):
+ # /tmp/scratch or /tmpfoo must not trigger the mask — exact match only.
+ r = run(zfs_body='printf "/\\n/tmp/scratch\\n/tmpfoo\\n";')
+ self.assertNotIn("SYSTEMCTL:", r.stdout)
+ self.assertEqual(rc_of(r), 0)
+
+ def test_legacy_and_none_mountpoints_do_not_match(self):
+ r = run(zfs_body='printf "legacy\\nnone\\n-\\n";')
+ self.assertNotIn("SYSTEMCTL:", r.stdout)
+ self.assertEqual(rc_of(r), 0)
+
+ # ------------------------------------------------------------- error ----
+ def test_zfs_binary_absent_is_a_silent_no_op(self):
+ r = run(zfs_body=None, strip_zfs_from_path=True)
+ self.assertNotIn("SYSTEMCTL:", r.stdout)
+ self.assertNotIn("WARN:", r.stdout)
+ self.assertEqual(rc_of(r), 0)
+
+ def test_zfs_list_failure_is_a_no_op_not_a_crash(self):
+ r = run(zfs_body='echo "no pools available" >&2; return 1;')
+ self.assertNotIn("SYSTEMCTL:", r.stdout)
+ self.assertEqual(rc_of(r), 0)
+ self.assertNotIn("no pools available", r.stdout,
+ "zfs stderr noise must not leak into output")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/installer-steps/test_orchestrators.py b/tests/installer-steps/test_orchestrators.py
index a2b235b..c2eb710 100644
--- a/tests/installer-steps/test_orchestrators.py
+++ b/tests/installer-steps/test_orchestrators.py
@@ -28,6 +28,7 @@ ARCHSETUP = os.path.join(REPO_ROOT, "archsetup")
ORCHESTRATORS = {
"essential_services": [
"configure_randomness", "configure_networking", "configure_power",
+ "configure_hibernate_delay", "configure_backlight_access",
"configure_ssh_server", "configure_fail2ban", "configure_firewall",
"configure_service_discovery", "configure_job_scheduling",
"configure_package_cache", "configure_snapshots",
@@ -43,10 +44,10 @@ ORCHESTRATORS = {
"install_devops_utilities",
],
"boot_ux": [
- "tighten_efi_permissions", "add_nvme_early_module",
- "configure_initramfs_hook", "configure_encrypted_autologin",
- "configure_tlp_power", "trim_firmware", "configure_grub",
- "configure_pre_pacman_snapshots",
+ "install_cpu_microcode", "tighten_efi_permissions",
+ "add_nvme_early_module", "configure_initramfs_hook",
+ "configure_encrypted_autologin", "configure_tlp_power",
+ "trim_firmware", "configure_grub", "configure_pre_pacman_snapshots",
],
"user_customizations": [
"clone_user_repos", "stow_dotfiles", "prune_waybar_battery",
@@ -88,7 +89,8 @@ class OrchestratorSequence(unittest.TestCase):
class SnapshotDispatch(unittest.TestCase):
"""configure_snapshots branches on filesystem; pin each branch."""
- SUBS = ["configure_zfs_snapshots", "configure_btrfs_snapshots"]
+ SUBS = ["configure_zfs_snapshots", "configure_btrfs_snapshots",
+ "mask_tmp_mount_for_zfs"]
def test_zfs_root_runs_zfs_snapshots(self):
result = run_orchestrator(
@@ -96,7 +98,8 @@ class SnapshotDispatch(unittest.TestCase):
extra_defs="is_zfs_root() { return 0; }\nis_btrfs_root() { return 1; }",
)
self.assertEqual(result.returncode, 0, result.stderr)
- self.assertEqual(result.stdout.split(), ["configure_zfs_snapshots"])
+ self.assertEqual(result.stdout.split(),
+ ["configure_zfs_snapshots", "mask_tmp_mount_for_zfs"])
def test_btrfs_root_runs_btrfs_snapshots(self):
result = run_orchestrator(
diff --git a/tests/net-scenarios/test_run_net_scenarios.py b/tests/net-scenarios/test_run_net_scenarios.py
index 1d92185..a9cd275 100644
--- a/tests/net-scenarios/test_run_net_scenarios.py
+++ b/tests/net-scenarios/test_run_net_scenarios.py
@@ -63,6 +63,12 @@ class RunNetScenarios(unittest.TestCase):
return subprocess.run(
["bash", SCRIPT, "--target", "root@fake-vm"],
capture_output=True, text=True, timeout=20, env=env,
+ # The stubbed ssh is `cat >/dev/null`, which drains stdin to EOF.
+ # Without this the stub inherits whatever stdin the test runner
+ # had, so `make test-unit` passed when stdin was redirected and
+ # hung on all five tests when it was a terminal or a live pipe --
+ # which is how it gets run by hand.
+ stdin=subprocess.DEVNULL,
)
def test_all_checks_pass_exits_zero(self):
diff --git a/tests/post-rebuild-check/test_post_rebuild_check.py b/tests/post-rebuild-check/test_post_rebuild_check.py
new file mode 100644
index 0000000..1145f34
--- /dev/null
+++ b/tests/post-rebuild-check/test_post_rebuild_check.py
@@ -0,0 +1,1145 @@
+"""Tests for the post-rebuild-check script.
+
+A rebuilt machine looks finished and isn't: on velox 2026-08-13 five gaps
+surfaced within two days, three of which LOOKED fine (a stowed unit file, an
+enabled timer, a present git clone). The script runs the checks from the
+post-rebuild task and turns each silent no-op into a visible line:
+
+ 1. failed systemd units (user and system scope)
+ 2. user unit files present but not enabled (linked-and-inert timers)
+ 3. tracked *.example files whose real sibling is missing
+ 4. gitignore-mode projects missing tooling paths their own .gitignore names
+ 5. signal-cli holds no registered account
+ 6. every NTP source named by hostname (a wrong clock takes DNS with it)
+ 7. hypridle installed but not running (nothing triggers idle suspend)
+ 8. a working repo cloned read-only (push returns 403)
+
+Exit 0 with every check clean, 1 when any check found something.
+
+Test seams (env vars the production script honors; for each, SET-BUT-EMPTY
+means "the real probe ran and found nothing", UNSET means "run the real
+probe"):
+ PRC_FAILED_UNITS newline list of "scope:unit" (scope user|system)
+ PRC_UNIT_STATES newline list of "unit-file state" for the user unit dir
+ PRC_LOCAL_SCAN_ROOTS newline-separated roots to scan for *.example orphans
+ PRC_PROJECT_ROOTS newline-separated project dirs for the tooling check
+ PRC_SIGNAL_ACCOUNTS signal-cli listAccounts output ("" = no accounts);
+ PRC_NTP_SOURCES newline list of configured NTP server addresses
+ ("MISSING" = no NTP daemon active)
+ the special value MISSING means the binary is absent
+ PRC_IDLE_DAEMON pgrep output for hypridle ("" = installed but not
+ running; "MISSING" = not installed on this machine)
+ PRC_REPO_REMOTES newline list of "path<space>origin-url" for the
+ push-capability check ("" = no repos to check)
+ PRC_UNITS_EXPECTED_DISABLED
+ newline list of units whose not-enabled state is
+ deliberate on this machine ("" = no exemptions)
+
+Run from repo root:
+ python3 -m unittest tests.post-rebuild-check.test_post_rebuild_check
+"""
+
+import os
+import shutil
+import subprocess
+import tempfile
+import time
+import unittest
+
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+CHECK = os.path.join(REPO_ROOT, "scripts", "post-rebuild-check")
+
+
+def run_check(failed_units="", unit_states="", local_roots="",
+ project_roots="", signal_accounts="+15045551234",
+ ntp_sources="162.159.200.1\npool.ntp.org",
+ idle_daemon="4242", repo_remotes="",
+ units_expected_disabled=""):
+ """Run the script with every probe stubbed; defaults are all-clean.
+
+ Roots are newline-separated. Empty means "the seam is set and names no
+ roots" -- the script tests with ${VAR+set}, so an empty value is still
+ set and never falls through to the real probe.
+ """
+ env = dict(os.environ)
+ env["PRC_FAILED_UNITS"] = failed_units
+ env["PRC_UNIT_STATES"] = unit_states
+ env["PRC_LOCAL_SCAN_ROOTS"] = local_roots
+ env["PRC_PROJECT_ROOTS"] = project_roots
+ env["PRC_SIGNAL_ACCOUNTS"] = signal_accounts
+ env["PRC_NTP_SOURCES"] = ntp_sources
+ env["PRC_IDLE_DAEMON"] = idle_daemon
+ env["PRC_REPO_REMOTES"] = repo_remotes
+ env["PRC_UNITS_EXPECTED_DISABLED"] = units_expected_disabled
+ return subprocess.run(
+ ["sh", CHECK], capture_output=True, text=True, timeout=30, env=env,
+ )
+
+
+class NtpBootstrap(unittest.TestCase):
+ """Check 6 — the clock/DNS bootstrap deadlock.
+
+ A wrong clock fails the DoT certificate and DNSSEC signature checks this
+ machine's DNS runs on, so nothing resolves; and an NTP daemon whose every
+ source is a hostname then cannot resolve the servers that would correct
+ the clock. One source addressed by IP is what makes the machine able to
+ recover on its own.
+ """
+
+ # --- Normal cases ---------------------------------------------------
+
+ def test_an_ip_addressed_source_is_clean(self):
+ r = run_check(ntp_sources="162.159.200.1\npool.ntp.org")
+ self.assertIn("check 6/8: NTP bootstrap — ok", r.stdout)
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ def test_all_hostname_sources_is_a_finding(self):
+ # The velox 2026-08-19 shape exactly: stock Arch chrony.conf, whose
+ # only source is a pool hostname.
+ r = run_check(ntp_sources="2.arch.pool.ntp.org")
+ self.assertIn("every NTP source is named by hostname", r.stdout)
+ self.assertEqual(r.returncode, 1)
+
+ def test_an_ipv6_addressed_source_counts(self):
+ r = run_check(ntp_sources="2606:4700:f1::1")
+ self.assertIn("check 6/8: NTP bootstrap — ok", r.stdout)
+
+ # --- Boundary cases -------------------------------------------------
+
+ def test_the_literal_may_sit_anywhere_in_the_list(self):
+ # Order must not matter; the property is "at least one", and the
+ # drop-in that carries it is read after the main config.
+ r = run_check(ntp_sources="a.pool.ntp.org\nb.pool.ntp.org\n162.159.200.1")
+ self.assertIn("check 6/8: NTP bootstrap — ok", r.stdout)
+
+ def test_blank_lines_between_sources_are_ignored(self):
+ r = run_check(ntp_sources="\n\n162.159.200.1\n\n")
+ self.assertIn("check 6/8: NTP bootstrap — ok", r.stdout)
+
+ def test_a_hostname_containing_digits_and_dots_is_not_an_address(self):
+ # The trap in any naive "looks like an IP" test: these resolve through
+ # DNS like any other name, so counting one as an address would hand a
+ # deadlocked machine a clean bill.
+ for host in ("0.arch.pool.ntp.org", "3.us.pool.ntp.org", "time1.google.com"):
+ with self.subTest(host=host):
+ r = run_check(ntp_sources=host)
+ self.assertIn("every NTP source is named by hostname", r.stdout)
+
+ # --- Error cases ----------------------------------------------------
+
+ def test_no_ntp_daemon_is_a_finding(self):
+ r = run_check(ntp_sources="MISSING")
+ self.assertIn("no NTP implementation is active", r.stdout)
+ self.assertEqual(r.returncode, 1)
+
+ def test_no_sources_configured_is_a_finding(self):
+ # Fails closed: an empty list proves nothing about the machine, and
+ # reporting ok would be a false pass on a box with no time sync at all.
+ r = run_check(ntp_sources="")
+ self.assertIn("no NTP sources are configured", r.stdout)
+ self.assertEqual(r.returncode, 1)
+
+ # --- the confdir false pass ------------------------------------------
+ #
+ # A drop-in is inert unless chrony.conf names its directory, and Arch's
+ # stock chrony.conf names none. Reading the drop-in without checking for
+ # confdir would find the IP-addressed source, call the machine healthy, and
+ # be describing a file chrony never opens — a false pass on exactly the
+ # misconfiguration this check exists to catch.
+
+ def _chrony_fixture(self, main_lines, dropin_lines=None):
+ """Write a chrony.conf (plus an adjacent drop-in dir) and return its path."""
+ d = tempfile.mkdtemp(prefix="prc-chrony-")
+ self.addCleanup(shutil.rmtree, d, True)
+ dropin_dir = os.path.join(d, "chrony.d")
+ os.makedirs(dropin_dir)
+ if dropin_lines is not None:
+ with open(os.path.join(dropin_dir, "10-bootstrap-ip-ntp.conf"), "w") as f:
+ f.write(dropin_lines)
+ conf = os.path.join(d, "chrony.conf")
+ with open(conf, "w") as f:
+ f.write(main_lines.replace("@DROPIN@", dropin_dir))
+ return conf
+
+ def _run_real_probe(self, chrony_conf):
+ """Run with PRC_NTP_SOURCES unset so the real chrony reader runs."""
+ env = dict(os.environ)
+ env.update({"PRC_FAILED_UNITS": "", "PRC_UNIT_STATES": "",
+ "PRC_LOCAL_SCAN_ROOTS": "", "PRC_PROJECT_ROOTS": "",
+ "PRC_SIGNAL_ACCOUNTS": "+15045551234",
+ "PRC_IDLE_DAEMON": "4242",
+ "PRC_REPO_REMOTES": "",
+ "PRC_UNITS_EXPECTED_DISABLED": "",
+ "PRC_CHRONY_CONF": chrony_conf})
+ env.pop("PRC_NTP_SOURCES", None)
+ return subprocess.run(["sh", CHECK], capture_output=True, text=True,
+ timeout=30, env=env)
+
+ def test_dropin_without_confdir_does_not_count(self):
+ # The regression. The IP-addressed source is present on disk but
+ # chrony.conf never points at it, so the machine is still deadlock-prone
+ # and the check has to say so.
+ conf = self._chrony_fixture("pool 2.arch.pool.ntp.org iburst\n",
+ "server 162.159.200.1 iburst\n")
+ r = self._run_real_probe(conf)
+ if "no NTP implementation is active" in r.stdout:
+ self.skipTest("no chronyd on this host — the reader branch can't run")
+ self.assertIn("every NTP source is named by hostname", r.stdout)
+
+ def test_dropin_with_confdir_counts(self):
+ # The same two files, with chrony.conf actually naming the directory.
+ conf = self._chrony_fixture(
+ "pool 2.arch.pool.ntp.org iburst\nconfdir @DROPIN@\n",
+ "server 162.159.200.1 iburst\n")
+ r = self._run_real_probe(conf)
+ if "no NTP implementation is active" in r.stdout:
+ self.skipTest("no chronyd on this host — the reader branch can't run")
+ self.assertIn("check 6/8: NTP bootstrap — ok", r.stdout)
+
+ def test_confdir_naming_an_empty_directory_is_not_a_pass(self):
+ # confdir present, nothing behind it: the sources are the hostname-only
+ # main file, so the finding stands.
+ conf = self._chrony_fixture(
+ "pool 2.arch.pool.ntp.org iburst\nconfdir @DROPIN@\n", None)
+ r = self._run_real_probe(conf)
+ if "no NTP implementation is active" in r.stdout:
+ self.skipTest("no chronyd on this host — the reader branch can't run")
+ self.assertIn("every NTP source is named by hostname", r.stdout)
+
+ def test_unset_seam_falls_through_to_the_real_probe(self):
+ # Same contract as every other seam: unset means "really look", so a
+ # caller who forgets the variable cannot silently skip the check.
+ env = dict(os.environ)
+ env.update({"PRC_FAILED_UNITS": "", "PRC_UNIT_STATES": "",
+ "PRC_LOCAL_SCAN_ROOTS": "", "PRC_PROJECT_ROOTS": "",
+ "PRC_SIGNAL_ACCOUNTS": "+15045551234"})
+ env.pop("PRC_NTP_SOURCES", None)
+ r = subprocess.run(["sh", CHECK], capture_output=True, text=True,
+ timeout=30, env=env)
+ self.assertIn("check 6/8: NTP bootstrap", r.stdout)
+
+
+class IdleDaemon(unittest.TestCase):
+ """Check 7 — whether anything still triggers idle lock and suspend.
+
+ A laptop that never sleeps has no symptom until the battery is gone, which
+ is why this needs a check rather than trusting the desktop to look right.
+ On velox 2026-08-19 hypridle started cleanly at 15:29:48 and `settings
+ restore` killed it six seconds later, replaying a caffeine stored in an
+ earlier boot. The machine then ran 11h40m fully awake on battery, died when
+ it flattened, and reset its RTC — which took DNS down with it, the very
+ deadlock check 6 exists for. Nothing looked wrong at any point.
+
+ The check is behavioural: it asks whether the daemon is alive, not why it
+ might not be, so a stale caffeine, a crash and a bad config all surface the
+ same way.
+ """
+
+ # --- Normal cases ---------------------------------------------------
+
+ def test_running_daemon_is_clean(self):
+ r = run_check(idle_daemon="4242")
+ self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
+ self.assertNotIn("DEVIATION", r.stdout)
+
+ def test_installed_but_not_running_flags(self):
+ r = run_check(idle_daemon="")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("hypridle", r.stdout)
+ self.assertIn("DEVIATION", r.stdout)
+
+ def test_the_finding_names_the_consequence_not_just_the_process(self):
+ # "hypridle is not running" reads as a detail. The reason it matters is
+ # that the machine stays awake until the battery is gone, and that is
+ # what has to be in the line someone skims at 1am.
+ r = run_check(idle_daemon="")
+ self.assertIn("awake", r.stdout.lower())
+
+ def test_the_finding_names_the_known_cause(self):
+ # Behavioural checks are cheap to write and expensive to act on. Naming
+ # the one cause already seen saves the reader the investigation this
+ # session had to do from scratch.
+ r = run_check(idle_daemon="")
+ self.assertIn("caffeine", r.stdout.lower())
+
+ # --- Boundary cases -------------------------------------------------
+
+ def test_not_installed_is_not_a_finding(self):
+ # A headless or dwm machine never installs hypridle — archsetup pulls
+ # it in only for Hyprland. Flagging its absence there would be noise on
+ # every run, and noise is how a real finding gets skimmed past.
+ r = run_check(idle_daemon="MISSING")
+ self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
+ self.assertNotIn("DEVIATION", r.stdout)
+
+ def test_several_pids_still_read_as_running(self):
+ # pgrep prints one pid per line. More than one is its own problem (five
+ # concurrent daemons wedged a velox session on 2026-07-22) but it is
+ # not *this* check's, and it must not read as "not running".
+ r = run_check(idle_daemon="4242\n4243")
+ self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
+
+ def test_the_check_always_prints_its_line(self):
+ for pids in ("4242", "", "MISSING"):
+ with self.subTest(pids=pids):
+ self.assertIn("idle daemon",
+ run_check(idle_daemon=pids).stdout.lower())
+
+
+class UnitsExpectedDisabled(unittest.TestCase):
+ """Check 2 — units nothing intends to enable on this machine.
+
+ "Enabled" is the check's proxy for "will actually run", and the proxy is
+ wrong for a unit nobody means to enable here. velox carries four such
+ units, for four different reasons: geoclue-agent is redundant because
+ hyprland's exec-once starts the binary directly, emacs is started on demand
+ by emacsclient, obs-record-watchdog only matters while recording, and
+ obsbot-wb-guard needs an OBSBOT the machine doesn't have.
+
+ Left unexempted they report at every run, which is the standing-findings
+ problem check 4's own comment already argues against: four permanent lines
+ in front of every real one teach you to skim the output.
+
+ The exemption is machine-local rather than a marker in the shared unit
+ file, because obsbot-wb-guard is correctly ENABLED on ratio. Same unit,
+ different right answer per machine.
+ """
+
+ # --- Normal cases ---------------------------------------------------
+
+ def test_an_exempt_unit_is_not_flagged(self):
+ r = run_check(unit_states="emacs.service linked",
+ units_expected_disabled="emacs.service")
+ self.assertEqual(r.returncode, 0, r.stdout)
+ self.assertNotIn("emacs.service", r.stdout)
+
+ def test_a_non_exempt_unit_still_flags(self):
+ r = run_check(unit_states="roam-sync.timer linked",
+ units_expected_disabled="emacs.service")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("roam-sync.timer", r.stdout)
+
+ def test_several_exemptions_all_apply(self):
+ r = run_check(
+ unit_states=("emacs.service linked\n"
+ "geoclue-agent.service linked\n"
+ "obsbot-wb-guard.service linked"),
+ units_expected_disabled=("emacs.service\n"
+ "geoclue-agent.service\n"
+ "obsbot-wb-guard.service"))
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ # --- Boundary cases -------------------------------------------------
+
+ def test_an_exemption_that_is_actually_enabled_is_a_finding(self):
+ # A stale exemption must surface rather than sit there suppressing
+ # nothing. Otherwise the list rots into a place real findings go to
+ # die, which is worse than the noise it was added to remove.
+ r = run_check(unit_states="obsbot-wb-guard.service enabled",
+ units_expected_disabled="obsbot-wb-guard.service")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("obsbot-wb-guard.service", r.stdout)
+
+ def test_comments_and_blank_lines_are_ignored(self):
+ # The reason a unit is exempt is the most useful thing about the
+ # entry, so the format has to hold a comment next to it.
+ r = run_check(unit_states="emacs.service linked",
+ units_expected_disabled=("# started on demand\n"
+ "\n"
+ "emacs.service # not by systemd\n"))
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ def test_no_exemptions_flags_everything_as_before(self):
+ r = run_check(unit_states="emacs.service linked",
+ units_expected_disabled="")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("emacs.service", r.stdout)
+
+ def test_an_exemption_does_not_suppress_a_dangling_link(self):
+ # A stowed unit pointing at a missing target is a different finding,
+ # decided on the filesystem. Exempting the name must not hide that.
+ d = tempfile.mkdtemp(prefix="prc-units-")
+ self.addCleanup(shutil.rmtree, d, True)
+ unit_dir = os.path.join(d, "systemd", "user")
+ os.makedirs(unit_dir)
+ link = os.path.join(unit_dir, "emacs.service")
+ os.symlink(os.path.join(d, "gone.service"), link)
+ env = dict(os.environ)
+ env.update({"PRC_FAILED_UNITS": "", "PRC_LOCAL_SCAN_ROOTS": "",
+ "PRC_PROJECT_ROOTS": "",
+ "PRC_SIGNAL_ACCOUNTS": "+15045551234",
+ "PRC_NTP_SOURCES": "162.159.200.1",
+ "PRC_IDLE_DAEMON": "4242",
+ "PRC_REPO_REMOTES": "",
+ "PRC_UNITS_EXPECTED_DISABLED": "emacs.service",
+ "XDG_CONFIG_HOME": d})
+ env.pop("PRC_UNIT_STATES", None)
+ r = subprocess.run(["sh", CHECK], capture_output=True, text=True,
+ timeout=30, env=env)
+ self.assertIn("points at a missing target", r.stdout)
+ self.assertEqual(r.returncode, 1)
+
+
+class RepoPushCapability(unittest.TestCase):
+ """Check 8 — a working repo cloned from the read-only endpoint.
+
+ archsetup clones the user's own archsetup and dotfiles from
+ https://git.cjennings.net/..., the anonymous read-only endpoint. That is
+ the right default for a stranger installing archsetup, who has no key on
+ the server, and the wrong one for this machine, which has to push. The
+ override exists (ARCHSETUP_REPO / DOTFILES_REPO) but only applies where it
+ is configured — a curl|bash install, or a rebuild from a stock ISO, picks
+ the default straight back up.
+
+ Nothing about the tree shows it. The clone is complete and ordinary, and
+ the machine finds out at the first push, with a 403. That is how velox's
+ dotfiles remote was found on 2026-08-17, four days after its rebuild.
+
+ Only the read-only endpoint is flagged. An https remote to some other host
+ may well be pushable with a credential helper, and guessing about hosts
+ this machine does not own would put standing noise in front of the real
+ findings.
+ """
+
+ RO = "https://git.cjennings.net/dotfiles.git"
+ RW = "git@cjennings.net:dotfiles.git"
+
+ # --- Normal cases ---------------------------------------------------
+
+ def test_an_ssh_remote_is_clean(self):
+ r = run_check(repo_remotes=f"/home/x/.dotfiles {self.RW}")
+ self.assertEqual(r.returncode, 0, r.stdout)
+ self.assertNotIn("DEVIATION", r.stdout)
+
+ def test_the_read_only_endpoint_flags(self):
+ r = run_check(repo_remotes=f"/home/x/.dotfiles {self.RO}")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("/home/x/.dotfiles", r.stdout)
+
+ def test_the_finding_names_the_consequence(self):
+ # "the remote is https" is a detail. That pushing fails is the point.
+ r = run_check(repo_remotes=f"/home/x/.dotfiles {self.RO}")
+ self.assertIn("push", r.stdout.lower())
+
+ def test_every_offending_repo_is_named(self):
+ r = run_check(repo_remotes=(f"/home/x/.dotfiles {self.RO}\n"
+ f"/home/x/code/archsetup {self.RO}"))
+ self.assertIn("/home/x/.dotfiles", r.stdout)
+ self.assertIn("/home/x/code/archsetup", r.stdout)
+
+ # --- Boundary cases -------------------------------------------------
+
+ def test_a_mixed_set_flags_only_the_read_only_one(self):
+ r = run_check(repo_remotes=(f"/home/x/.dotfiles {self.RW}\n"
+ f"/home/x/code/archsetup {self.RO}"))
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("/home/x/code/archsetup", r.stdout)
+ self.assertNotIn("/home/x/.dotfiles", r.stdout)
+
+ def test_an_https_remote_to_another_host_is_not_flagged(self):
+ # GitHub over https is pushable with a credential helper. Flagging it
+ # would be a guess about a host this machine does not own.
+ r = run_check(repo_remotes="/home/x/code/thing https://github.com/a/b.git")
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ def test_no_repos_is_not_a_finding(self):
+ r = run_check(repo_remotes="")
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ def test_blank_lines_are_ignored(self):
+ r = run_check(repo_remotes=f"\n\n/home/x/.dotfiles {self.RW}\n\n")
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ def test_the_check_always_prints_its_line(self):
+ for remotes in ("", f"/home/x/.dotfiles {self.RW}",
+ f"/home/x/.dotfiles {self.RO}"):
+ with self.subTest(remotes=remotes):
+ self.assertIn("repo remotes",
+ run_check(repo_remotes=remotes).stdout.lower())
+
+ # --- Error cases ----------------------------------------------------
+
+ def test_a_repo_with_no_origin_is_a_finding(self):
+ # Fails closed. A repo whose origin could not be read was not checked,
+ # and reporting it clean is the false pass this script exists to avoid.
+ r = run_check(repo_remotes="/home/x/.dotfiles")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("/home/x/.dotfiles", r.stdout)
+
+
+class AllClean(unittest.TestCase):
+ # --- Normal cases ---------------------------------------------------
+
+ def test_all_clean_exits_zero(self):
+ r = run_check()
+ self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
+
+ def test_all_clean_prints_one_line_per_check(self):
+ # The visible line per check is the point of the script: a silent
+ # no-op is exactly what let the velox gaps sit unseen for two days.
+ r = run_check()
+ for label in ("failed units", "unit files", "local files",
+ "project tooling", "signal"):
+ self.assertIn(label, r.stdout.lower())
+
+ def test_all_clean_summary_says_clean(self):
+ r = run_check()
+ self.assertIn("all checks clean", r.stdout.lower())
+
+ def test_all_clean_no_deviation_lines(self):
+ r = run_check()
+ self.assertNotIn("DEVIATION", r.stdout)
+
+
+class FailedUnits(unittest.TestCase):
+ # --- Normal cases ---------------------------------------------------
+
+ def test_failed_user_unit_flags(self):
+ r = run_check(failed_units="user:calendar-sync.service")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("calendar-sync.service", r.stdout)
+ self.assertIn("DEVIATION", r.stdout)
+
+ def test_failed_system_unit_flags(self):
+ r = run_check(failed_units="system:tlp.service")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("tlp.service", r.stdout)
+
+ def test_multiple_failed_units_each_reported(self):
+ r = run_check(
+ failed_units="user:calendar-sync.service\nsystem:tlp.service")
+ self.assertIn("calendar-sync.service", r.stdout)
+ self.assertIn("tlp.service", r.stdout)
+
+ # --- Boundary cases -------------------------------------------------
+
+ def test_blank_lines_in_seam_ignored(self):
+ r = run_check(failed_units="\n\nuser:a.service\n\n")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("a.service", r.stdout)
+
+
+class UnitFilesNotEnabled(unittest.TestCase):
+ # --- Normal cases ---------------------------------------------------
+
+ def test_disabled_timer_flags(self):
+ r = run_check(unit_states="roam-sync.timer disabled")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("roam-sync.timer", r.stdout)
+
+ def test_linked_timer_flags(self):
+ # The exact velox case: a unit symlinked into the user dir by hand,
+ # never enabled — present, inert, and it LOOKS installed.
+ r = run_check(unit_states="signal-receive.timer linked")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("signal-receive.timer", r.stdout)
+
+ def test_enabled_timer_passes(self):
+ r = run_check(unit_states="roam-sync.timer enabled")
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ def test_static_service_passes(self):
+ # A service with no [Install] section is pulled in by its timer;
+ # "static" is its healthy state, not a gap.
+ r = run_check(unit_states="roam-sync.service static")
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ def test_disabled_service_flags(self):
+ r = run_check(unit_states="obsbot-wb-guard.service disabled")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("obsbot-wb-guard.service", r.stdout)
+
+ # --- Boundary cases -------------------------------------------------
+
+ def test_mixed_states_only_inert_reported(self):
+ r = run_check(unit_states="a.timer enabled\nb.timer disabled\n"
+ "c.service static\nd.service linked")
+ self.assertEqual(r.returncode, 1)
+ self.assertNotIn("a.timer", r.stdout)
+ self.assertIn("b.timer", r.stdout)
+ self.assertNotIn("c.service", r.stdout)
+ self.assertIn("d.service", r.stdout)
+
+ def test_masked_unit_passes(self):
+ # Masking is a deliberate act (ppd on laptops), not rebuild rot.
+ r = run_check(unit_states="power-profiles-daemon.service masked")
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ def test_service_whose_timer_is_enabled_passes(self):
+ # A timer-activated service is SUPPOSED to sit linked-not-enabled:
+ # the timer owns activation, and enabling the service too would run
+ # it at boot as well. Six of velox's units are this shape, and
+ # flagging them is the noise that gets a check ignored.
+ r = run_check(unit_states="roam-sync.service linked\n"
+ "roam-sync.timer enabled")
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ def test_service_whose_timer_is_inert_flags_the_timer_only(self):
+ # When the timer itself never got enabled, the timer is the finding.
+ # Naming the service too would double-count one gap.
+ r = run_check(unit_states="obs-record-watchdog.service linked\n"
+ "obs-record-watchdog.timer linked")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("obs-record-watchdog.timer", r.stdout)
+ self.assertNotIn("obs-record-watchdog.service", r.stdout)
+
+ def test_service_without_a_timer_still_flags(self):
+ # Nothing else can start it, so linked-not-enabled means dead.
+ r = run_check(unit_states="emacs.service linked")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("emacs.service", r.stdout)
+
+ def test_a_runtime_enabled_timer_suppresses_its_service(self):
+ # enabled-runtime is a live activation path (enabled until reboot)
+ # and generated means something produced and installed it, so the
+ # service beneath either is being started and is not a finding.
+ for state in ("enabled-runtime", "generated"):
+ with self.subTest(timer=state):
+ r = run_check(unit_states=f"foo.service linked\n"
+ f"foo.timer {state}")
+ self.assertEqual(r.returncode, 0,
+ f"a {state} timer failed to suppress")
+
+ def test_an_indirect_timer_does_not_suppress_its_service(self):
+ # "indirect" means the unit file itself is NOT enabled -- only that
+ # some Also= relative might be. Under this script's own fail-closed
+ # rule the uncertain case flags, so suppressing here would be the
+ # masked blind spot again in a narrower form.
+ r = run_check(unit_states="foo.service linked\nfoo.timer indirect")
+ self.assertEqual(r.returncode, 1,
+ "an indirect timer suppressed a service nothing starts")
+ self.assertIn("foo.service", r.stdout)
+
+ def test_a_service_whose_timer_cannot_start_it_still_flags(self):
+ # Suppression is earned by a timer that can actually run the service.
+ # A masked, static, or absent timer starts nothing, so the service is
+ # as dead as one with no timer at all -- and suppressing on the mere
+ # presence of a timer line hides exactly that.
+ for state in ("masked", "static", "not-found"):
+ with self.subTest(timer=state):
+ r = run_check(unit_states=f"foo.service linked\n"
+ f"foo.timer {state}")
+ self.assertEqual(r.returncode, 1,
+ f"a {state} timer suppressed a dead service")
+ self.assertIn("foo.service", r.stdout)
+
+
+class LocalExampleOrphans(unittest.TestCase):
+ # --- Normal cases ---------------------------------------------------
+
+ def test_example_without_sibling_flags(self):
+ with tempfile.TemporaryDirectory() as root:
+ open(os.path.join(root, "auth.local.el.example"), "w").close()
+ r = run_check(local_roots=root)
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("auth.local.el.example", r.stdout)
+
+ def test_a_real_file_that_is_a_dangling_symlink_still_flags(self):
+ # A sibling that exists only as a broken link is not a config the
+ # machine can read, so it is the same gap as an absent one.
+ with tempfile.TemporaryDirectory() as root:
+ open(os.path.join(root, "auth.local.el.example"), "w").close()
+ os.symlink("/nonexistent/stow/target",
+ os.path.join(root, "auth.local.el"))
+ r = run_check(local_roots=root)
+ self.assertEqual(r.returncode, 1,
+ "a dangling sibling counted as present")
+ self.assertIn("auth.local.el.example", r.stdout)
+
+ def test_example_with_sibling_passes(self):
+ with tempfile.TemporaryDirectory() as root:
+ open(os.path.join(root, "auth.local.el.example"), "w").close()
+ open(os.path.join(root, "auth.local.el"), "w").close()
+ r = run_check(local_roots=root)
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ def test_nested_example_found(self):
+ with tempfile.TemporaryDirectory() as root:
+ sub = os.path.join(root, "modules")
+ os.makedirs(sub)
+ open(os.path.join(sub, "mail.local.el.example"), "w").close()
+ r = run_check(local_roots=root)
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("mail.local.el.example", r.stdout)
+
+ # --- Boundary cases -------------------------------------------------
+
+ def test_two_roots_both_scanned(self):
+ with tempfile.TemporaryDirectory() as a, \
+ tempfile.TemporaryDirectory() as b:
+ open(os.path.join(a, "one.example"), "w").close()
+ open(os.path.join(b, "two.example"), "w").close()
+ r = run_check(local_roots=a + "\n" + b)
+ self.assertIn("one.example", r.stdout)
+ self.assertIn("two.example", r.stdout)
+
+ def test_vendored_package_dirs_not_scanned(self):
+ # elpa/ and friends hold third-party packages that ship their own
+ # .example docs. Those are the package's business, not this machine's,
+ # and one of them (dirvish's) was the only finding check 3 produced on
+ # velox — a standing false positive in front of any real one.
+ with tempfile.TemporaryDirectory() as root:
+ for vendor in ("elpa", "node_modules", ".venv", "straight"):
+ d = os.path.join(root, vendor, "pkg-1.0", "docs")
+ os.makedirs(d)
+ open(os.path.join(d, "config.example"), "w").close()
+ r = run_check(local_roots=root)
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ def test_an_unreadable_vendored_dir_is_not_a_finding(self):
+ # The vendored trees are excluded by design, so failing to descend
+ # into one is not a gap in what this check covers. Filtering find's
+ # output without pruning its descent turns a package directory
+ # nobody wanted read into a standing "could not fully scan".
+ with tempfile.TemporaryDirectory() as root:
+ locked = os.path.join(root, "elpa", "pkg-1.0")
+ os.makedirs(locked)
+ os.chmod(locked, 0o000)
+ try:
+ r = run_check(local_roots=root)
+ finally:
+ os.chmod(locked, 0o755)
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ def test_git_dir_not_scanned(self):
+ # .git holds hooks' sample files; those are git's, not the tree's.
+ with tempfile.TemporaryDirectory() as root:
+ g = os.path.join(root, ".git", "hooks")
+ os.makedirs(g)
+ open(os.path.join(g, "pre-commit.example"), "w").close()
+ r = run_check(local_roots=root)
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ # --- Error cases ----------------------------------------------------
+
+ def test_an_unreadable_subdirectory_is_a_finding_not_a_pass(self):
+ # find exits non-zero when it cannot descend somewhere, and prints
+ # what it did reach. Discarding that status hides every orphan under
+ # the unreadable directory behind a clean "ok" -- the same defect
+ # class as a probe that cannot run reading as a pass.
+ with tempfile.TemporaryDirectory() as root:
+ locked = os.path.join(root, "locked")
+ os.makedirs(locked)
+ open(os.path.join(locked, "auth.local.el.example"), "w").close()
+ os.chmod(locked, 0o000)
+ try:
+ r = run_check(local_roots=root)
+ finally:
+ os.chmod(locked, 0o755)
+ self.assertEqual(r.returncode, 1,
+ "an unreadable directory read as nothing to check")
+ self.assertIn("could not", r.stdout.lower())
+
+ def test_missing_root_is_its_own_finding(self):
+ # A scan root that's gone is a rebuild gap too, not a pass.
+ r = run_check(local_roots="/nonexistent/scan-root")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("/nonexistent/scan-root", r.stdout)
+
+ def test_a_path_with_spaces_is_one_root_not_three(self):
+ # Roots arrive newline-separated for this reason: splitting on spaces
+ # turns one real directory into several imaginary missing ones.
+ with tempfile.TemporaryDirectory() as base:
+ root = os.path.join(base, "a dir with spaces")
+ os.makedirs(root)
+ open(os.path.join(root, "orphan.example"), "w").close()
+ r = run_check(local_roots=root)
+ self.assertEqual(r.stdout.count("DEVIATION"), 1, r.stdout)
+ self.assertIn("orphan.example", r.stdout)
+
+
+class ProjectTooling(unittest.TestCase):
+ def project(self, root, gitignore_lines, present=()):
+ os.makedirs(os.path.join(root, ".git"))
+ with open(os.path.join(root, ".gitignore"), "w") as f:
+ f.write("\n".join(gitignore_lines) + "\n")
+ for p in present:
+ path = os.path.join(root, p)
+ if p.endswith("/"):
+ os.makedirs(path, exist_ok=True)
+ else:
+ open(path, "w").close()
+
+ # --- Normal cases ---------------------------------------------------
+
+ def test_ignored_but_absent_tooling_flags(self):
+ with tempfile.TemporaryDirectory() as root:
+ self.project(root, [".ai/", "todo.org"])
+ r = run_check(project_roots=root)
+ self.assertEqual(r.returncode, 1)
+ for missing in (".ai", "todo.org"):
+ self.assertIn(missing, r.stdout)
+
+ def test_claude_dir_absence_never_flags(self):
+ # Same shape as CLAUDE.md below, and proven the same way. The bootstrap
+ # and the gitignore sweep write `.claude/` into the ignore set of every
+ # gitignore-mode project whether or not one ever exists there, so the
+ # entry is aspirational rather than a promise. Three projects tripped
+ # this on velox, and ratio is missing the identical directory in the
+ # identical three, which is what proves it is the steady state and not
+ # reinstall drift.
+ #
+ # Nor does dropping it lose a real signal. A project that genuinely
+ # carries one (rules and hooks from a language bundle) has it re-synced
+ # by sync-language-bundle.sh at every session start, so a true absence
+ # heals itself before this check would ever run.
+ with tempfile.TemporaryDirectory() as root:
+ self.project(root, [".ai/", ".claude/"], present=(".ai/",))
+ r = run_check(project_roots=root)
+ self.assertEqual(r.returncode, 0, r.stdout)
+ self.assertNotIn(".claude", r.stdout)
+
+ def test_ignored_and_present_tooling_passes(self):
+ with tempfile.TemporaryDirectory() as root:
+ self.project(root, [".ai/", "CLAUDE.md"],
+ present=(".ai/", "CLAUDE.md"))
+ r = run_check(project_roots=root)
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ def test_claude_md_absence_never_flags(self):
+ # CLAUDE.md is seed-only: install-lang writes it once and the project
+ # owns it afterward, so most projects legitimately never have one.
+ # Ratio shows the identical absences in the identical projects, which
+ # is what proves it is the steady state and not reinstall drift.
+ with tempfile.TemporaryDirectory() as root:
+ self.project(root, [".ai/", "CLAUDE.md"], present=(".ai/",))
+ r = run_check(project_roots=root)
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ def test_missing_ai_dir_still_flags(self):
+ # The one that carries real working state — 374 files in .emacs.d's
+ # case — and that nothing restores: not git, not stow, not bootstrap.
+ with tempfile.TemporaryDirectory() as root:
+ self.project(root, [".ai/", "CLAUDE.md"])
+ r = run_check(project_roots=root)
+ self.assertEqual(r.returncode, 1)
+ self.assertIn(".ai", r.stdout)
+ self.assertNotIn("CLAUDE.md", r.stdout)
+
+ def test_unignored_tooling_never_expected(self):
+ # A project that never gitignored todo.org never had one to lose;
+ # the project's own .gitignore is the record of what it should hold.
+ with tempfile.TemporaryDirectory() as root:
+ self.project(root, [".ai/"], present=(".ai/",))
+ r = run_check(project_roots=root)
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ # --- Boundary cases -------------------------------------------------
+
+ def test_anchored_ignore_style_recognized(self):
+ # Both /.ai/ (anchored) and .ai/ (unanchored) styles exist across
+ # the fleet; the sweep-gitignore audit hit exactly this split.
+ with tempfile.TemporaryDirectory() as root:
+ self.project(root, ["/.ai/", "/CLAUDE.md"])
+ r = run_check(project_roots=root)
+ self.assertEqual(r.returncode, 1)
+ self.assertIn(".ai", r.stdout)
+
+ def test_a_worktree_or_submodule_is_still_a_project(self):
+ # In a worktree or submodule, .git is a file pointing at the real
+ # gitdir rather than a directory, so a -d test skips the project
+ # silently.
+ with tempfile.TemporaryDirectory() as root:
+ with open(os.path.join(root, ".git"), "w") as f:
+ f.write("gitdir: /somewhere/else\n")
+ with open(os.path.join(root, ".gitignore"), "w") as f:
+ f.write(".ai/\n")
+ r = run_check(project_roots=root)
+ self.assertEqual(r.returncode, 1)
+ self.assertIn(".ai", r.stdout)
+
+ def test_an_unreadable_gitignore_is_a_finding_not_a_pass(self):
+ # grep exits 2 on error and 1 on no-match, so treating both as
+ # "nothing named" lets an unreadable ignore file pass the project
+ # silently.
+ with tempfile.TemporaryDirectory() as root:
+ self.project(root, [".ai/"])
+ os.chmod(os.path.join(root, ".gitignore"), 0o000)
+ try:
+ r = run_check(project_roots=root)
+ finally:
+ os.chmod(os.path.join(root, ".gitignore"), 0o644)
+ self.assertEqual(r.returncode, 1,
+ "an unreadable .gitignore read as nothing to check")
+ self.assertIn("could not", r.stdout.lower())
+
+ def test_non_git_dir_skipped(self):
+ with tempfile.TemporaryDirectory() as root:
+ with open(os.path.join(root, ".gitignore"), "w") as f:
+ f.write(".ai/\n")
+ r = run_check(project_roots=root)
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ def test_project_without_gitignore_skipped(self):
+ with tempfile.TemporaryDirectory() as root:
+ os.makedirs(os.path.join(root, ".git"))
+ r = run_check(project_roots=root)
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ def test_unrelated_ignore_lines_no_flags(self):
+ with tempfile.TemporaryDirectory() as root:
+ self.project(root, ["*.pyc", "node_modules/", "dist/"])
+ r = run_check(project_roots=root)
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+
+class SignalAccount(unittest.TestCase):
+ # --- Normal cases ---------------------------------------------------
+
+ def test_registered_account_passes(self):
+ r = run_check(signal_accounts="Number: +15045551234 ...")
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ def test_no_account_flags(self):
+ # The velox case: a wiped registration silently breaks paging for
+ # the whole fleet, because agent-text relays into this machine.
+ r = run_check(signal_accounts="")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("signal", r.stdout.lower())
+
+ # --- Error cases ----------------------------------------------------
+
+ def test_missing_binary_flags(self):
+ r = run_check(signal_accounts="MISSING")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("signal-cli", r.stdout)
+
+ def test_a_stray_signal_missing_in_the_environment_is_ignored(self):
+ # The script's own internal flag must not be settable from outside,
+ # or a caller's unrelated variable turns a registered account into a
+ # "signal-cli is not installed" finding.
+ env = dict(os.environ)
+ env.update({"PRC_FAILED_UNITS": "", "PRC_UNIT_STATES": "",
+ "PRC_LOCAL_SCAN_ROOTS": "", "PRC_PROJECT_ROOTS": "",
+ "PRC_NTP_SOURCES": "162.159.200.1",
+ "PRC_SIGNAL_ACCOUNTS": "+15045551234",
+ "PRC_IDLE_DAEMON": "4242",
+ "PRC_REPO_REMOTES": "",
+ "PRC_UNITS_EXPECTED_DISABLED": "",
+ "signal_missing": "1"})
+ r = subprocess.run(["sh", CHECK], capture_output=True, text=True,
+ timeout=30, env=env)
+ self.assertEqual(r.returncode, 0, r.stdout)
+
+ def test_a_stray_idle_absent_in_the_environment_is_ignored(self):
+ # Same class as the flag above, and the dangerous direction: an
+ # inherited idle_absent=1 would make check 7 skip silently and report
+ # ok on a machine that cannot sleep, which is the exact false pass the
+ # check exists to prevent.
+ env = dict(os.environ)
+ env.update({"PRC_FAILED_UNITS": "", "PRC_UNIT_STATES": "",
+ "PRC_LOCAL_SCAN_ROOTS": "", "PRC_PROJECT_ROOTS": "",
+ "PRC_NTP_SOURCES": "162.159.200.1",
+ "PRC_SIGNAL_ACCOUNTS": "+15045551234",
+ "PRC_IDLE_DAEMON": "",
+ "idle_absent": "1"})
+ r = subprocess.run(["sh", CHECK], capture_output=True, text=True,
+ timeout=30, env=env)
+ self.assertEqual(r.returncode, 1, r.stdout)
+ self.assertIn("hypridle is installed but not running", r.stdout)
+
+
+class ProbeFailure(unittest.TestCase):
+ """A probe that could not run must never read as a clean check.
+
+ This is the defect the whole script exists to catch, so it would be the
+ worst possible place to have it. `systemctl --user` exits 1 with empty
+ output when there is no user bus -- over ssh, from cron, under sudo, or on
+ a TTY before the graphical session starts. Reading that as "no failed
+ units" reports a machine as healthy precisely when nothing can be checked.
+ """
+
+ def unset(self, *names):
+ """Run with the named seams unset, so the real probes execute."""
+ env = dict(os.environ)
+ env.update({"PRC_FAILED_UNITS": "", "PRC_UNIT_STATES": "",
+ "PRC_LOCAL_SCAN_ROOTS": "", "PRC_PROJECT_ROOTS": "",
+ "PRC_NTP_SOURCES": "162.159.200.1",
+ "PRC_SIGNAL_ACCOUNTS": "+15045551234"})
+ for n in names:
+ env.pop(n, None)
+ env["XDG_RUNTIME_DIR"] = "/nonexistent-runtime-dir"
+ return subprocess.run(["sh", CHECK], capture_output=True, text=True,
+ timeout=30, env=env)
+
+ # --- Error cases ----------------------------------------------------
+
+ def test_unreachable_user_bus_is_a_finding_not_a_pass(self):
+ r = self.unset("PRC_FAILED_UNITS")
+ self.assertEqual(r.returncode, 1,
+ "a failed probe reported the machine as clean")
+ self.assertIn("could not", r.stdout.lower())
+
+ def test_unreachable_user_bus_fails_the_unit_state_check_too(self):
+ r = self.unset("PRC_UNIT_STATES")
+ self.assertEqual(r.returncode, 1,
+ "a failed probe reported the machine as clean")
+
+ def test_an_unusable_tmpdir_is_a_finding_not_a_pass(self):
+ # Every check stages its input through a temp file. If that write
+ # fails, each loop reads nothing and every check comes back clean --
+ # with real findings passed in.
+ env = dict(os.environ)
+ env.update({"PRC_FAILED_UNITS": "user:calendar-sync.service",
+ "PRC_UNIT_STATES": "", "PRC_LOCAL_SCAN_ROOTS": "",
+ "PRC_PROJECT_ROOTS": "",
+ "PRC_SIGNAL_ACCOUNTS": "+15045551234",
+ "PRC_IDLE_DAEMON": "4242",
+ "PRC_REPO_REMOTES": "",
+ "PRC_UNITS_EXPECTED_DISABLED": "",
+ "TMPDIR": "/nonexistent-tmp-dir"})
+ r = subprocess.run(["sh", CHECK], capture_output=True, text=True,
+ timeout=30, env=env)
+ self.assertEqual(r.returncode, 1,
+ "an unwritable TMPDIR swallowed a real finding")
+
+
+class RealUnitDirEnumeration(unittest.TestCase):
+ """Check 2's unseamed path, where the unit files are read off disk.
+
+ The PRC_UNIT_STATES seam skips this enumeration entirely, so a defect in
+ it survives every seamed test. That is where the dangling-stow-link case
+ lives, and a dangling stow link is precisely the requirement's headline
+ example of a unit file that LOOKED fine.
+ """
+
+ def run_real(self, config_home):
+ env = dict(os.environ)
+ env.update({"PRC_FAILED_UNITS": "", "PRC_LOCAL_SCAN_ROOTS": "",
+ "PRC_PROJECT_ROOTS": "",
+ "PRC_SIGNAL_ACCOUNTS": "+15045551234",
+ "PRC_IDLE_DAEMON": "4242",
+ "PRC_REPO_REMOTES": "",
+ "PRC_UNITS_EXPECTED_DISABLED": "",
+ "XDG_CONFIG_HOME": config_home})
+ env.pop("PRC_UNIT_STATES", None)
+ return subprocess.run(["sh", CHECK], capture_output=True, text=True,
+ timeout=30, env=env)
+
+ # --- Boundary cases -------------------------------------------------
+
+ def test_a_dangling_stow_link_is_enumerated_not_skipped(self):
+ with tempfile.TemporaryDirectory() as home:
+ unit_dir = os.path.join(home, "systemd", "user")
+ os.makedirs(unit_dir)
+ os.symlink("/nonexistent/stow/roam-sync.timer",
+ os.path.join(unit_dir, "roam-sync.timer"))
+ r = self.run_real(home)
+ self.assertEqual(r.returncode, 1,
+ "a dangling stow link read as nothing to check")
+ self.assertIn("roam-sync.timer", r.stdout)
+ self.assertIn("missing target", r.stdout)
+
+ # --- Error cases ----------------------------------------------------
+
+ def test_a_missing_unit_directory_is_a_finding(self):
+ with tempfile.TemporaryDirectory() as home:
+ r = self.run_real(home)
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("no user unit directory", r.stdout)
+
+
+class WedgedSystemctl(unittest.TestCase):
+ """A systemd manager that never answers must not hang the check.
+
+ Seen live on velox 2026-08-17: the user manager spun at 96% CPU with
+ `is-enabled`, `cat`, and `list-unit-files` all hanging while `list-units`
+ still returned. Unbounded, the check stops at the first unit and never
+ runs checks 3 through 5, so the machine most in need of checking is the
+ one it reports nothing about.
+ """
+
+ def run_with_fake(self, script_body, timeout_s="1"):
+ """Run against a fake systemctl, with check 2's unit dir empty.
+
+ Pointing XDG_CONFIG_HOME at an empty directory keeps check 2 from
+ making one call per real unit, so the test measures the bound rather
+ than the size of this machine's unit directory.
+ """
+ with tempfile.TemporaryDirectory() as d:
+ fake = os.path.join(d, "systemctl")
+ with open(fake, "w") as f:
+ f.write(script_body)
+ os.chmod(fake, 0o755)
+ env = dict(os.environ)
+ env.update({"PRC_LOCAL_SCAN_ROOTS": "", "PRC_PROJECT_ROOTS": "",
+ "PRC_SIGNAL_ACCOUNTS": "+15045551234",
+ "PRC_IDLE_DAEMON": "4242",
+ "PRC_REPO_REMOTES": "",
+ "PRC_UNITS_EXPECTED_DISABLED": "",
+ "PRC_SYSTEMCTL": fake,
+ "PRC_SYSTEMCTL_TIMEOUT": timeout_s,
+ "XDG_CONFIG_HOME": d})
+ env.pop("PRC_FAILED_UNITS", None)
+ env.pop("PRC_UNIT_STATES", None)
+ start = time.monotonic()
+ r = subprocess.run(["sh", CHECK], capture_output=True,
+ text=True, timeout=60, env=env)
+ return r, time.monotonic() - start
+
+ # --- Error cases ----------------------------------------------------
+
+ def test_a_hanging_systemctl_is_bounded_and_reported(self):
+ r, _ = self.run_with_fake("#!/bin/sh\nsleep 300\n")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("could not query user units", r.stdout)
+ # The run must reach the end rather than stopping at the first call.
+ self.assertIn("check 8/8", r.stdout)
+
+ def test_a_hanging_systemctl_does_not_stall_the_whole_run(self):
+ # The fake sleeps 8s against a 1s bound, so a bounded run lands near
+ # 2s (two calls) and an unbounded one near 16s. Deliberately short
+ # enough that losing the bound fails this assertion in seconds rather
+ # than hitting the subprocess ceiling a minute later -- a regression
+ # nobody waits out is a regression nobody catches.
+ _, elapsed = self.run_with_fake("#!/bin/sh\nsleep 8\n")
+ self.assertLess(elapsed, 6,
+ "the run was not bounded by PRC_SYSTEMCTL_TIMEOUT")
+
+
+class Reporting(unittest.TestCase):
+ # --- Normal cases ---------------------------------------------------
+
+ def test_findings_counted_in_summary(self):
+ # Assert the count in the summary line specifically. A bare
+ # assertIn("2") passes on the always-present "check 2/5" text, so it
+ # stays green even when the counter is arithmetically wrong.
+ r = run_check(failed_units="user:a.service\nuser:b.service",
+ unit_states="c.timer disabled")
+ self.assertEqual(r.returncode, 1)
+ summary = r.stdout.strip().splitlines()[-1]
+ self.assertEqual(summary, "3 finding(s) across 8 checks")
+
+ def test_the_summary_count_tracks_every_check(self):
+ # One finding from each of the five, so a counter that drops or
+ # double-counts any single check shows up here.
+ with tempfile.TemporaryDirectory() as scan, \
+ tempfile.TemporaryDirectory() as proj:
+ open(os.path.join(scan, "orphan.example"), "w").close()
+ os.makedirs(os.path.join(proj, ".git"))
+ with open(os.path.join(proj, ".gitignore"), "w") as f:
+ f.write(".ai/\n")
+ r = run_check(failed_units="user:a.service",
+ unit_states="b.timer disabled",
+ local_roots=scan, project_roots=proj,
+ signal_accounts="")
+ summary = r.stdout.strip().splitlines()[-1]
+ self.assertEqual(summary, "5 finding(s) across 8 checks")
+
+ def test_help_exits_zero(self):
+ r = subprocess.run(["sh", CHECK, "--help"],
+ capture_output=True, text=True, timeout=10)
+ self.assertEqual(r.returncode, 0)
+ self.assertIn("post-rebuild-check", r.stdout)
+
+ # --- Error cases ----------------------------------------------------
+
+ def test_unknown_flag_errors(self):
+ r = subprocess.run(["sh", CHECK, "--bogus"],
+ capture_output=True, text=True, timeout=10)
+ self.assertNotEqual(r.returncode, 0)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/todo.org b/todo.org
index e2e6727..eb8d353 100644
--- a/todo.org
+++ b/todo.org
@@ -1,6 +1,7 @@
#+TITLE: ArchSetup Tasks
#+AUTHOR: Craig Jennings
#+DATE: 2026-02-14
+#+PRIORITIES: A D D
* Archsetup Priority Scheme
@@ -45,10 +46,2054 @@ below):
input-side-spec.org (DRAFT, four decisions open).
* Archsetup Open Work
-** TODO [#B] Desktop settings don't survive a session restart :bug:dotfiles:hyprland:
+
+** TODO [#C] Settings-panel brightness drum skips digits from x5 levels :bug:dotfiles:solo:
+:PROPERTIES:
+:CREATED: [2026-10-07 Wed]
+:LAST_REVIEWED: 2026-10-07
+:END:
+Found 2026-10-06 while investigating Craig's report that the desktop-settings
+panel will not take 60 percent. Confirmed defect in
+=~/.dotfiles/settings/src/settings/=: =bench.drum_of= uses Python's =round()=,
+which rounds halves to even, and =gui._commit_drums= applies a drag only when
+the dragged digit differs from =drum_of(live percent)=. At a live level of 55
+or 65 percent (the XF86 keys step by 10 from the 5 percent floor, so x5 levels
+are normal) the drum already reads 6, a drag to 6 is a no-op, and 60 is
+unreachable without passing through 5 or 7 first. Same at 35 and 45, which
+both read 4. The backend is fine: =brightness.set_percent(60)= read back 60
+live on velox, the sysfs file is the udev rule's root:video 664, and the keys
+work.
+
+Grading: Minor severity (a specific level can't be hit from the panel; a
+detour through the next digit works) x some users sometimes (only from an x5
+level) = P3 = [#C].
+
+Fix: in =_commit_drums=, compare =bench.percent_of(screen)= against the live
+percent rather than digit against digit (or round half up in =drum_of=; the
+comparison fix is the smaller change and keeps the display honest). Unit
+tests live in =~/.dotfiles/tests/settings/= (=test_presenters.py= has the
+drum-rounding cases). Per the archsetup-owns-dotfiles rule, make the change
+from here, run the settings tests, commit and push dotfiles, and drop a note
+in =~/.dotfiles/inbox/=.
+
+Not the same thing: the screen reading 10 percent all afternoon on 2026-10-06
+was hypridle's 60 s dim stage (=brightnessctl -s set 10%= with =-r= on resume;
+the saved pre-dim level in =/run/user/1000/brightnessctl/= was 100 percent).
+*** VERIFY What does the panel do on a drag to 6: no movement, lands on 6 with no screen change, or snaps back?
+If it lands on 6 with no change, the rounding defect above is the cause and
+the fix can go in as written.
+
+** TODO [#B] calendar-sync fails headless after every boot until authinfo is unlocked :bug:dotfiles:gpg:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-10-04
+:END:
+Found in the 2026-10-04 velox health check. The calendar-sync timer (every 10 minutes)
+failed on every run from the 2026-09-29 boot onward (711 journal errors, last success
+2026-09-27) with "Decryption failed, Bad session key". The feed URLs live in
+=~/.authinfo.gpg= (dotfiles =common/=), which is symmetric AES256. The batch Emacs
+the timer runs can't answer a passphrase prompt, so the sync fails until something
+interactive caches that passphrase in gpg-agent. The agent's 400-day cache TTL means
+it holds once entered, but every reboot starts cold.
+
+Re-encrypting to the GPG key alone doesn't fix it. It only swaps which passphrase
+has to be typed per boot, and on 2026-10-04 ratio's agent had no GPG key cached at
+all (its sync works only on the cached symmetric passphrase), so the switch would
+break ratio until the key is unlocked there.
+
+The fix that actually makes it headless: install =pam-gnupg= so login presets the
+key passphrase into gpg-agent, then re-encrypt authinfo to the =c@cjennings.net= key
+(B832F070). That needs the key passphrase to match the login password, or a decision
+to make it match. pam-gnupg is three pieces, and all three are required: add
+=allow-preset-passphrase= to =gpg-agent.conf= (the current config lacks it, and
+without it the preset is refused), list the encryption subkey's keygrip in
+=~/.pam-gnupg=, and add the =pam_gnupg.so= auth and session lines to the login PAM
+stack. Do it on both daily drivers. Re-encrypt by piping decrypt into
+encrypt, never via plaintext on disk, and verify by comparing plaintext hashes.
+
+** TODO [#C] Closed tasks pinned to the agenda by a live planning line :chore:quick:solo:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-09-25
+:END:
+Four closed tasks in the Resolved section kept the SCHEDULED or DEADLINE they
+carried while open, so org still renders them on the agenda as weeks overdue. Find
+them with the grep below rather than by line number — an insertion anywhere above
+shifts every number, and the first version of this task carried four that were
+already stale when I wrote them:
+
+#+begin_src sh :results output
+grep -nE '^(CLOSED|SCHEDULED|DEADLINE):.*(CLOSED|SCHEDULED|DEADLINE):' todo.org
+#+end_src
+
+My org config sets =org-agenda-skip-scheduled-if-done= to nil, so a terminal
+keyword doesn't suppress them — only removing the planning line does.
+
+This is the top-level counterpart to the rule that strips the planning line from
+a dated sub-task entry. An interactive close stamps =CLOSED:= and leaves any
+pre-existing =SCHEDULED:= in place, which is how all four survived.
+
+Fix: delete the SCHEDULED/DEADLINE token from each line the grep returns. At =**=
+keep the =CLOSED:= cookie; at =***= and deeper delete the whole planning line,
+CLOSED included, since a dated log heading carries its date in the heading. All
+four current instances are =**=, but the task is built to be run later, which is
+when a deeper one could appear. Verify by re-running the grep and getting no
+output.
+
+The pattern is order-independent on purpose. Matching =CLOSED:.*SCHEDULED:= would
+miss a planning line written the other way round and then report clean over an
+instance it never looked at, and the =^= anchor is what stops the grep matching
+its own source line.
+
+Found 2026-09-25 while fixing a fifth instance that a review caught in the
+then-uncommitted diff. That one is fixed; these four predate it and were left
+out so the commit didn't grow a second concern.
+
+** TODO [#B] velox's USB hub tears down and rebuilds under the Jabra :bug:velox:hardware:audio:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-09-25
+:END:
+The hub at usb 3-2 and whatever hangs off 3-2.1 disconnect and re-enumerate
+together, near-daily, and it takes the call audio with it. When the Jabra
+Speak2 75 comes back as a new device number, Zoom doesn't follow it, so output
+goes nowhere while the device name and every setting still look correct.
+
+Grading: Major severity (audio dies mid-call and needs a manual re-pick; no
+data loss, and a workaround exists) x most-users-frequently (near-daily across
+the whole retained journal, on the primary call path) = P2 = [#B].
+
+Evidence, 2026-09-25 from velox's persistent journal:
+- Today: 11:06:01 the Jabra (0b0e:24ef) enumerates on 3-2.1 as device 5;
+ 11:06:02 both it and parent hub 3-2 (device 4) disconnect; 11:06:03 the hub
+ returns as device 6 and the Jabra as 7. One second after plug-in.
+- The same 3-2 / 3-2.1 pair drops on 09-18, 09-20, 09-21 (five times between
+ 08:49 and 08:53), 09-21 18:08, 09-22 (four), 09-23, 09-24 (two), 09-25.
+- Companion lines in today's window: "5:0: failed to get current value for
+ ch 0 (-22)", "cannot get min/max values for control 2 (id 5)", and
+ "ucsi_acpi USBC000:00: unknown error 256".
+
+The parent hub is 0a12:4010 — a dock or dongle, not the headset — so the hub is
+the likelier fault and the Jabra the casualty. Some of the listed drops are
+plausibly me unplugging the dock at the end of a day; the 09-21 cluster of five
+inside five minutes and today's one-second-after-plug-in re-enumeration are not.
+
+Not :solo: — splitting hub from headset needs hardware I have to move: the Jabra
+on a direct port with no dock, a different dock or cable, and the dock with
+something else on it. The log work is done.
+
+*** 2026-09-25 Fri @ 12:30:00 -0400 Work's read: bypass the dock, which runs the experiment for free
+Work landed the same conclusion about the hub being the fault rather than the
+Jabra, and drew the better practical consequence from it. Both call paths now
+have a measured failure mode — flaky hub on wired, flaky HFP on bluetooth — so
+choosing between them is the wrong frame. Plugging the Jabra straight into a
+laptop port avoids both.
+
+That also collapses the hardware isolation this task is waiting on. If the direct
+port holds for a few days of real calls, the hub is implicated and the headset is
+cleared, with no deliberate test to run — just using it is the experiment. If it
+drops anyway, the fault is downstream of the hub and this task's scope changes.
+Try the direct port first and read the result off the journal.
+
+Cross-boot queries need =journalctl _TRANSPORT=kernel= with no =-b=. Plain
+=journalctl -k= implies =-b= and silently scopes to the current boot, which is
+what made this look like a single event with no baseline.
+
+** TODO [#B] xhci on velox refuses D3hot thousands of times per boot :bug:velox:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-09-25
+:END:
+=xhci_hcd 0000:c3:00.0: Refused to change power state from D0 to D3hot=, at a
+roughly fixed rate all session, every boot. The controller never reaches D3hot,
+so it holds D0 for the life of the boot — a power-management failure and a
+plausible battery cost on a laptop.
+
+Grading: Minor severity (nothing the user does fails; log noise plus a suspected
+but unmeasured power cost) x every-boot-every-time = P2 = [#B]. Regrade to Major
+if the drain turns out to be measurable — grading the being-in-it, a controller
+pinned in D0 costs continuously rather than in a bounded trickle.
+
+Counts across the five retained boots (oldest to newest): 15772, 1859, 16644,
+9987, 5747. Today's 5747 is the lowest of the set, not an anomaly.
+
+Lead, not a conclusion: the installer puts TLP on every battery machine and TLP
+owns USB power policy. Check its USB autosuspend handling against both these
+refusals and the hub instability in the task above — they may share a cause.
+
+Not :solo: — the diagnosis half is mine to run, but deciding whether to change
+velox's power policy is a preference call about battery versus device stability,
+so it needs your answer before anything is written.
+
+** TODO [#B] The installer never makes the journal persistent :bug:solo:quick:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-09-25
+:END:
+=configure_encrypted_autologin= writes =/etc/systemd/journald.conf.d/
+retention.conf= with =SystemMaxUse=500M= and stops there (archsetup:3624).
+=Storage== is left at its default of =auto=, which is persistent only when
+=/var/log/journal= already exists — and on a fresh Arch install it does not. So
+a machine this installer builds keeps no journal across a reboot, and any
+post-incident question that spans a boot is unanswerable on it.
+
+Grading: Minor severity (the machine works; what's lost is the ability to
+diagnose across a reboot) x every-fresh-install = P2 = [#B]. Not [#A] because no
+live machine is impaired — see below.
+
+Both daily drivers already carry a hand-written =persistent.conf= with
+=Storage=persistent=, so this bites only future installs. velox's is dated
+2026-08-13 17:48, an hour before the installer's own journald block ran at
+18:51 on rebuild day, which is how I know the installer didn't write it. Ratio
+has the same pair of files.
+
+Fix: add =Storage=persistent= to the block that already writes retention.conf,
+with a test pinning it beside the SystemMaxUse assertion. One file, and it
+belongs in the config the installer already owns rather than a second drop-in.
+
+Found while verifying a work handoff that had concluded velox kept no baseline —
+it did, because of the hand-written file. A fresh machine wouldn't have.
+** TODO [#C] Airplane panel key follow-ups from the f977418 commit :refactor:dotfiles:quick:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-09-23
+:END:
+Four items left standing when the airplane keybind moved into the net panel
+(dotfiles f977418, 2026-09-23). None blocked the commit. archsetup drives the
+dotfiles work end to end per the standing rule in notes.org.
+
+- The AIRPLANE console key is shown on desktops (net/src/net/gui.py:576). On
+ ratio the flow is: confirm the prompt, then "airplane mode isn't available
+ on this machine". PanelModel already carries has_wifi and has_speedtest
+ capability flags; a battery or laptop flag could desensitize the key before
+ the question is asked. This one is a design call, which is why the task
+ isn't :solo:.
+- "LEAVE AIRPLANE" is hardcoded in net/src/net/classify.py:52 and diag.py:84
+ instead of shared from viewmodel.AIRPLANE_LEAVE_KEY. Tests pin the coupling,
+ so a rename would surface, but one source is cleaner.
+- tests/net/panel_smoke.py:57 checks the DOCTOR and SPEED TEST console keys
+ only. Add AIRPLANE, and consider a smoke step that opens the confirm dialog
+ and cancels. Needs a compositor to run.
+- Two pre-existing comments on untouched lines still say "keybind":
+ hyprland/.local/bin/airplane-mode:38 and
+ tests/airplane-mode/test_airplane_mode.py:353.
+
+** TODO [#C] Post-install check that every mimeapps.list handler exists :feature:quick:solo:
+:PROPERTIES:
+:CREATED: [2026-09-22 Tue]
+:LAST_REVIEWED: 2026-09-22
+:END:
+Work's optional ask from the 2026-09-18 libreoffice handoff, kept because the
+failure it catches is silent.
+
+The dotfiles =common/.config/mimeapps.list= "[Default Applications]" block
+names a .desktop file per type. When the package behind one is missing,
+xdg-mime does not error — it falls through to the next application claiming
+that type. On velox that meant every .pptx opened PowerPoint inside the
+Windows VM for a month, and the only symptom was that it felt slow.
+
+Add a post-install check that reads every .desktop named in that block and
+reports the ones absent from the machine. Declaring the packages (done
+2026-09-22 for libreoffice-fresh, imv and git-lfs) fixes today's instance; this
+catches the next one, including a handler the dotfiles add later.
+
+Natural home is =scripts/post-rebuild-check=, which already runs this shape of
+verification.
+
+** TODO [#B] Own the meeting transcription service install :feature:velox:ratio:tooling:
+:PROPERTIES:
+:CREATED: [2026-09-22 Tue]
+:LAST_REVIEWED: 2026-09-22
+:END:
+Work built a self-hosted meeting transcription service (whisper.cpp plus
+pyannote diarization) that has run on ratio and velox since 2026-09-17, and
+handed the service side here on 2026-09-19 because it is machine setup rather
+than application work. Installed by hand on both machines today; nothing
+reinstalls it.
+
+The bundle is in [[file:working/meeting-transcription-service/][working/meeting-transcription-service/]], with work's handoff note
+beside it. Scanned for credentials on arrival: clean.
+
+What the install has to provide per machine:
+- =~/.local/share/pyannote-diarize/.venv= — Python 3.12, CPU torch,
+ pyannote.audio 4.0.7, about 1.3 GB, built with uv.
+- =~/.local/share/whisper-models/ggml-large-v3-turbo-q5_0.bin=, plus
+ whisper-cpp itself.
+- The three =src/= scripts where the units expect them, and both user units
+ enabled with linger on so the path unit fires without a login session.
+
+Open decisions before this is buildable, which is why it isn't =:solo:=:
+- Where the code lives in this repo — a new top-level dir, or under =scripts/=.
+- How the Hugging Face step is handled. Accepting the pyannote model terms and
+ caching the model is one-time, online, and interactive. The token is a
+ credential and this repo is anonymously cloneable, so it cannot be committed
+ here; the installer can only prompt for it or read it from the private
+ secrets path.
+
+Known rough edge work flagged: when two runs overlap the second finds the lock
+held, the worker returns silently, and the client reports "finished without
+producing a transcript". Rerunning works. The message should name the lock.
+
+** TODO [#C] Orchestrator sequence pin misses an added step :test:quick:solo:
+:PROPERTIES:
+:CREATED: [2026-09-17 Thu]
+:LAST_REVIEWED: 2026-09-17
+:END:
+Noticed during the 2026-08-08 pre-vacation sweep and never filed; confirmed
+still open 2026-09-17.
+
+=tests/installer-steps/test_orchestrators.py= defines recorder stubs only for
+the sub-steps it expects. A step added to an orchestrator without updating the
+pin calls an undefined function: bash prints "command not found" to stderr,
+nothing reaches stdout, and the recorded sequence still matches. The
+=returncode= assertion sees only the last call's status, so the test passes
+unless the new step happens to be last. The module docstring claims it catches
+"a dropped, added, or reordered" call; it catches drops and reorders.
+
+Fix: define =command_not_found_handle() { echo "UNSTUBBED:$1"; }= in the
+generated script so an unstubbed call lands in stdout and fails the equality,
+plus a test proving it (the file already has one of those for guarded helpers,
+=test_the_check_would_notice_a_missing_call=).
+
+Grading: Minor severity (the VM harness still runs the real steps; only the
+fast pin is blind) x some developers sometimes (fires only when a step is added
+without updating the pin) = P3 = [#C].
+** TODO [#B] Swap velox's MT7925 for an Intel AX210 :chore:velox:hardware:
+:PROPERTIES:
+:CREATED: [2026-09-16 Wed]
+:LAST_REVIEWED: 2026-09-16
+:END:
+Ordered from the Framework Marketplace on 2026-09-16; waiting on delivery.
+An Intel AX210 will replace velox's MediaTek MT7925
+(RZ717, Filogic 360) in the M.2 2230 slot. The ordered part is AX210.NGWG.NV
+(the .NV suffix means no vPro). A vPro card won't work in the Framework, so
+check the part number on the card when it arrives.
+
+Why: HFP call audio on the MT7925 fails in firmware. It sends zero-filled SCO
+frames on sentinel handle 0x0E00, and the kernel logs "SCO packet for unknown
+connection handle 3584". It's pending upstream with no fix. Three headsets fail
+on velox. The MT7925 is also step 4 of the hibernate-freeze mitigations. Losing
+WiFi 7 is fine; the AX210 does WiFi 6E and BT 5.3. linux-firmware-intel is
+already installed on velox, and the installer's firmware trim only runs on
+Intel-CPU Framework 13s, so archsetup needs no change. See the Bluetooth wedge
+bug's 2026-09-16 entries.
+
+Before the swap: note the saved WiFi profiles (NetworkManager keeps them, they
+aren't tied to the card), and power off fully (not hibernate). Keep the MT7925
+in case the AX210 is a dud.
+
+After the swap, verify:
+- lspci -k shows the AX210 on iwlwifi; the Bluetooth controller enumerates as
+ Intel (btintel) and hci0 is up.
+- WiFi joins a saved network, and net status/probe/diagnose read it correctly.
+ The net code has an nl80211 path written for the mt7925, so check that the
+ signal line still shows.
+- Re-pair the Sonys and any other Bluetooth devices, since the bonds belong to
+ the old controller's address. Use the agent-backed pairing recipe in the KB,
+ then count key sections to confirm a real bond.
+- A real call load of several minutes in HFP, counting "Failure in Bluetooth
+ audio transport" and kernel "unknown connection handle" errors. Target zero.
+ Note the Intel risk: AX201/AX211 had an eSCO handle-reuse bug with WirePlumber
+ 0.5.17 after repeated profile switching.
+- Suspend/resume and one hibernate cycle with the new card.
+Then close the Bluetooth wedge bug, or regrade it, and update the MT7925 step
+in the hibernate mitigations.
+
+** DOING [#C] Net doctor takes the DNS ladder on a DoT-blocking captive portal :bug:dotfiles:network:
+:PROPERTIES:
+:CREATED: [2026-09-14 Mon]
+:LAST_REVIEWED: 2026-09-14
+:END:
+An open captive-portal network that filters TCP 853 before login, 2026-09-14.
+The network's resolver
+172.20.0.1 answers plain UDP 53, but TCP 853 is filtered until you log in. With
+resolved pinned to DNSOverTLS=yes, every lookup on the WiFi link times out.
+The probe read "no-internet" instead of "captive", and the doctor ran
+repair:dns-test (cleanup-unverified), then repair:dns-override (fail,
+reverted), twice (08:32, 08:35). It never offered portal-login. Craig had to
+tether to his phone. A dns-override to 1.1.1.1 can't pass a walled garden
+anyway. The same flow worked on a different portal on 09-13.
+
+Once DNS resolves (through the tether), the pinned probe sees the portal and
+diagnose recommends net portal.
+
+Also unexplained: the probe log reads "online" on wlp192s0 from 08:43 to
+09:50, then flaps captive/online until 09:52, while a pinned curl shows the
+portal still intercepting. Check whether the probe follows the default route
+(the tether) while labeling the WiFi interface.
+
+Remaining before close: a live doctor run on that network with the tether
+unplugged (the fix is committed and live on velox; ratio gets it on its next
+dotfiles pull).
+
+Follow-ups the review found in existing code (not in this fix):
+- repair.py _extract_portal_url (~603-619) rejects any URL containing a
+ detection-host name anywhere, query string included. This portal echoes the
+ requested URL in its OS= parameter, so the portal URL the probe found gets
+ thrown away, and repair_portal_login (~687) falls back to opening a trigger
+ page (neverssl.com). The login should still work through interception, but
+ the found URL is lost. Fix: match detection hosts against the hostname only,
+ or pass the probe's portal_url through.
+- probe.py CLOUDFLARE_HOSTS: a controller that hosts its login page on 1.1.1.1
+ itself (older Cisco wireless, https://1.1.1.1/login.html) reads as
+ reachable, in both the followed-redirect and body branches.
+
+Grading: Major severity (no path online on such a network without knowing to
+run net portal by hand; the doctor's own repairs can't succeed) × some users,
+sometimes (networks that filter 853 before login; another portal worked) =
+P3 = [#C].
+*** 2026-09-14 Mon @ 12:12:48 -0400 Found the root cause in the probe's IP fallback, fixed it test-first
+My first guess (extend the tunnel-dot shape test to WiFi) was wrong. With DNS
+dead, run_probe falls back to http://1.1.1.1/ over the WiFi. The portal answers that
+literal with HTTP 200 and a meta refresh, so curl's effective URL stays on
+1.1.1.1. classify_ip only checked the effective host, and called the page
+"reachable". So the probe said no-internet, diagnose emitted no portal row, and
+the classifier fell through to dns-test. The hostname path already reads
+body-level redirects; the IP path didn't.
+
+Fix in dotfiles net/src/net/probe.py: classify_ip reads the body with
+extract_portal_url and calls it captive when the target host isn't Cloudflare.
+Tests in tests/net/test_net.py, all using a placeholder-scrubbed portal
+fixture: classify_ip normal, boundary, and error cases (Cloudflare body
+redirect, v6 literal, refresh with no URL, javascript: target, followed
+redirect wins); probe_and_cache end to end; diagnose emitting the portal row;
+doctor --fix running portal-login instead of dns-test/dns-override. Red with
+four failures, green at 991. The live IP probe over that network now
+classifies it captive and finds the portal URL.
+*** 2026-09-14 Mon @ 12:22:23 -0400 Committed and pushed the fix as dotfiles b2688e4
+An isolated review approved it. I added its two Minor test gaps (JS redirect,
+malformed target) and made the comments vendor-generic. Full make test: forked
+and shared runs each 4401 OK, faces 171 pass. I sent an FYI to the dotfiles
+inbox. The net CLI runs from the repo, so velox has the fix now.
+
+** TODO [#B] Bluetooth audio link drops wedge the PipeWire graph on velox :bug:velox:audio:
+:PROPERTIES:
+:CREATED: [2026-09-14 Mon]
+:LAST_REVIEWED: 2026-09-14
+:END:
+First seen 2026-09-14 with the Jabra Speak2 55 MS on velox.
+There are two faults, and the second makes the first much worse.
+
+1. The link drops. "Failure in Bluetooth audio transport" hit six times between
+ 10:09 and 11:10: on A2DP (sep1/fd0) and HFP (fd60), at 10:09, three times
+ around 10:10, 10:54, and 11:10. Each one matches a kernel "Bluetooth: hci0:
+ ACL (or SCO) packet for unknown connection handle" line. The controller is
+ the MediaTek MT7925 (0e8d:7925, btusb; WiFi firmware build 20260813).
+ Suspects I haven't separated yet: MT7925 btusb firmware or driver, range or
+ 2.4 GHz coexistence with the WiFi on the same chip, or A2DP/HFP profile
+ switching when the mic opens.
+2. The graph wedges. After the drops, the Jabra's nodes sat in error and every
+ client round trip hung: pactl info, wpctl status/inspect, pw-dump, and the
+ mic-mute key (four stuck wpctl set-mute processes). pw-cli info 0 still
+ answered, and no thread was spinning. Only a restart of pipewire,
+ pipewire-pulse, and wireplumber cleared it. Versions: pipewire 1.6.8,
+ wireplumber 0.5.17, bluez 5.87, kernel 6.18 LTS.
+
+Grading: Major severity (all audio control is dead until a manual service
+restart; the restart is the only workaround) × most users, frequently (six
+drops in about an hour of use on the one Bluetooth speaker in play) = P2 =
+[#B]. One session of data; re-read the frequency row if it doesn't recur.
+
+Next: on a recurrence, capture btmon and the kernel log across a drop, and
+check whether the wedge follows only HFP use. Try the same speaker on ratio to
+split controller from device. Look upstream for MT7925 "unknown connection
+handle" reports and for wireplumber bluez nodes stuck in error.
+
+*** 2026-09-15 Tue @ 14:12:11 -0400 Reproduced the link drop with a second speaker, a Speak2 75
+The replacement Jabra Speak2 75 dropped the same way while
+paired straight to velox's MT7925: "Failure in Bluetooth audio transport" at
+12:47:20 (A2DP, sep3/fd0) and 12:47:49 (HFP, fd62), with a kernel "hci0: ACL
+packet for unknown connection handle" line at 12:47:21. The graph didn't wedge
+this time; pactl, wpctl, and pw-dump all kept answering. A second device with
+the same signature points further toward the controller side than the speaker.
+I removed velox's pairings for both Jabras, and the 75 now runs over its USB
+cable (0b0e:24ef), which works for playback and mic. Its Link 390 dongle
+(0b0e:2e56) enumerates but was never linked to the speaker, and pairing one
+needs Jabra Direct (Windows/Mac only).
+*** 2026-09-16 Wed @ 13:29:34 -0400 A pairing that never bonded looks like this bug, so rule it out first
+A one-shot bluetoothctl pair, with the adapter at Pairable: no (velox's normal
+state), can leave a bond file with no key section. It shows up as transport and
+AVDTP failures and the device dropping the link. The 09-15 log records the
+Speak2 75 as Bonded: yes, so this doesn't explain the 75's drops. The 55 MS's
+bond state was never checked, and its pairing is gone now. On a recurrence,
+count the device's key sections first (the one-liner in the node).
+[[id:f23b7085-c35e-43c1-ae02-9e9c67e3848b][bluetoothctl one-shot pair can complete without bonding]]
+*** 2026-09-16 Wed @ 13:43:40 -0400 Third device, same kernel handle errors; pairing and settling ruled out
+The Sony WF-1000XM6 (bonded, one LinkKey) failed the same way today, and the
+evidence now points at the MT7925's SCO path.
+
+Work's session: transport failures from 10:44 to 10:50, about 90 clean seconds,
+then six more in two minutes (11:01 to 11:04) during a Meet call, with the buds
+beeping on each disconnect. The kernel logged 23 "SCO packet for unknown
+connection handle" and 38 "ACL packet for unknown connection handle" errors in
+exactly those two windows. mpv played over A2DP for twenty minutes with no
+failures. Every failure came after the card switched to a headset profile. SCO
+carries HFP voice. In work's run, a 35 s full-duplex hold of each HFP codec
+(CVSD, mSBC, LC3-SWB) passed with zero failures while real calls failed. The
+earlier "link settling after a fresh pair" reading was wrong: the quiet stretch
+was just quiet.
+
+Mine, from 13:23: the card churned between profiles for six minutes, then
+pipewire-pulse refused about 200 connections as "too many client application
+connections" (13:30:07 to 13:30:10). pactl, wpctl, and pw-cli info 0 all hung,
+the same wedge as 09-14, and restarting the three user units cleared it. After
+the restart, a 20 s LC3-SWB hold failed: the mic was pure digital zero, both
+bluez nodes went to error, and the kernel logged SCO and ACL handle errors.
+10 s holds of CVSD and mSBC passed. Then at 13:40:00, :17, and :22, three
+headset-profile switches failed the same way while mpv played, each time an
+app opened the mic. WirePlumber then had LC3-SWB saved as the headset profile.
+
+What this rules out: a bad bond (the Sonys are bonded), settling (the failures
+came back under load). The codec isn't ruled out. The only failure with a
+known codec was LC3-SWB (which passed work's hold), CVSD and mSBC have only
+passes on record, and the codec in play during the three 13:40 failures wasn't
+captured. Three devices share the signature, and the handle errors come from
+the controller.
+
+Still untested: WiFi/Bluetooth coexistence on the shared MT7925. velox was on a
+busy shared WiFi network through every failure. Next test: a real call load
+with WiFi off and the network over a USB tether, counting transport failures
+and kernel handle errors. Then a btmon capture across a failure.
+
+Workaround until then: take calls on the Speak2 75 over USB. With autoswitch on,
+any app that opens a mic pulls Bluetooth music into HFP and hits the fault.
+[[id:eaa85ba0-ad4f-4a38-a27f-c39b879c341f][Measure Bluetooth audio dropouts across the load that fails, not a quiet window]]
+*** 2026-09-16 Wed @ 14:42:50 -0400 Codec-filter experiment and a saved-profile trap
+Work tried pinning the call codec at about 14:30 with Craig's go-ahead: it left
+lc3_swb out of monitor.bluez.properties bluez5.codecs and restarted wireplumber.
+The filter does reach HFP. LC3-SWB disappeared, and headset-head-unit became
+MSBC. But every A2DP profile, AAC included, vanished too, even though all the
+A2DP codec names were listed. Work reverted within a minute and all six profiles
+came back. It's unclear whether the config dropped A2DP or the three-second wait
+was just too short for re-enumeration. If codec pinning is still wanted after
+the AX210 swap, retest on a day without calls and wait longer before judging.
+The wireplumber.conf.d directory is empty now, so the revert is on disk.
+
+The saved-profile trap: WirePlumber saves profiles by name, and the name
+headset-head-unit maps to whichever codec is best at load time (LC3-SWB with
+the default config, MSBC under the filter). At 14:42 both the card's
+default-profile and saved-headset-profile read headset-head-unit, so calls
+autoswitch to LC3-SWB. And because default-profile is a headset profile, the
+card may come back in HFP (call-quality music) after a reconnect or a
+wireplumber restart, until something switches it to a2dp-sink. Nothing changed:
+work asked for a hold on velox audio for the rest of 09-16.
+*** 2026-09-16 Wed @ 15:08:18 -0400 Matched to an upstream MT7925 firmware report; fix is a new card
+The kernel's "SCO packet for unknown connection handle 3584" is handle 0x0E00,
+the sentinel handle in an August 2026 linux-bluetooth report: "MT7925
+(0e8d:0717): HFP microphone unusable — firmware delivers zero-filled (e)SCO
+frames on sentinel handle 0x0E00" (bluez/bluetooth-next PR #744). It's the same
+chip under a different USB ID; velox enumerates as 0e8d:7925. That report
+used PipeWire 1.6.8 and MT7925 BT firmware from 20260622 and 20260810, and its
+frames were all zeros, matching the zero-filled LC3-SWB mic hold here. The
+reporter traced it to the firmware's transparent-mode receive path. A
+handle-rewrite patch only proved the payload is zeros. There's no maintainer
+reply, nothing merged, and no fix. The reporter also found it depends on the
+headset. velox at the time: kernel 6.18.51-lts, linux-firmware-mediatek
+20260910, BT firmware built 20260813.
+
+A related report on Intel AX201/AX211 shows handle reuse after repeated
+A2DP/HFP switching with WirePlumber 0.5.17 (velox's version), and the bluetooth
+maintainer called that a firmware bug too. Autoswitch churn makes these faults
+more likely.
+
+So the leading explanation is MediaTek firmware, not the headsets and not our
+config. Coexistence stays untested, but the upstream match means the WiFi-off
+test no longer decides the fix. The fix is replacing the card: an Intel AX210
+is ordered (see the swap task at the top of Open Work). The dotfiles net code
+also calls ratio's card an mt7925, so testing on ratio wouldn't separate
+controller from headset. That's unconfirmed, because ratio didn't answer ssh.
+
+Once the AX210 is in, re-run the call-load test there. Close this bug if it's
+clean, or regrade it if Intel shows the same signature.
+
+*** 2026-09-22 Tue @ 01:47:56 -0400 A fourth failure on 09-17, and restarting pipewire-pulse is not a full recovery
+From the work session's 09-19 handoff, read back from velox's journal.
+
+The failure recurred Thursday 2026-09-17 at 10:59:32 EDT: kernel "ACL packet
+for unknown connection handle 3837" on boot a440a2a0, with wireplumber logging
+"Failure in Bluetooth audio transport" for 58:18:62:AA:62:9D at 10:59:11 and
+10:59:32. That boot carried five unknown-handle lines. Daily transport-failure
+counts now run 13 on 09-14, 3 on 09-15, 44 on 09-16, and 11 on 09-17 (eight of
+them between 08:56 and 08:58). The handle differs each time, which is what the
+upstream sentinel-handle report predicts, so this is the same firmware fault
+rather than a new one.
+
+Recovery gap worth knowing before the AX210 lands: restarting pipewire-pulse
+strands every Chromium and Electron audio helper process. Each of those apps
+stays silent until it is itself restarted, so the service restart alone leaves
+the browser and any Electron app on a call dead. Restart the apps too.
+
+** TODO [#B] Visual separator between adjacent waybar modules :feature:waybar:dotfiles:quick:
+:PROPERTIES:
+:CREATED: [2026-09-13 Sun]
+:LAST_REVIEWED: 2026-09-13
+:END:
+Captured by Craig 2026-07-20 and routed here from the roam inbox, then lost
+in the processed pile: the wind (weather) value runs straight into the date
+with no visual stop, so the wind figure reads as the start of the date. Add
+a light separator or spacing between adjacent modules so each one's edge is
+unmistakable. Check the current bar first (the mic/PTT merge and the weather
+chip grouping landed after the capture), then decide the form: a thin rule,
+a dot glyph, or just margin. That choice is mine, so not solo; the CSS itself
+is a quick change in the dotfiles waybar stylesheet.
+
+** TODO [#C] Saving and restoring a window configuration :feature:hyprland:research:
+:PROPERTIES:
+:CREATED: [2026-09-13 Sun]
+:LAST_REVIEWED: 2026-09-13
+:END:
+Research idea captured by Craig 2026-07-24 (the one item of that batch that
+never got filed): when I want a specific window orientation, I indicate it
+and the window-plus-app configuration reappears. What would we need to know
+or store to make that happen? Is there another desktop or OS that does it,
+what information do they keep, and what are their rules? Explore how far
+Hyprland can get, document thoroughly, and review the findings with me before
+building anything. The deliverable is a research note under docs/design, so
+this is not solo.
+
+** TODO [#C] maint backup_freshness probe blind to backup_run remedy runs :bug:maint:dotfiles:solo:
+:PROPERTIES:
+:CREATED: [2026-09-12 Sat]
+:LAST_REVIEWED: 2026-09-12
+:END:
+From home's 2026-09-12 velox health check. The backup_freshness probe
+(dotfiles =maint/src/maint/probes/services.py=) parses the tail of
+=/var/log/rsyncshot.log=, which only the root crontab lines append to. The
+=backup_run= remedy (=remedies.py=) runs rsyncshot without that redirect, so
+right after a successful =maint fix backup_run= (DAILY.0 rotated on truenas
+at 21:29) the probe still said "daily 272h ago" and stayed CRITICAL until
+the next cron daily landed.
+
+Fix: make the remedy log the way the cron lines do (append the run to
+=/var/log/rsyncshot.log=), so the probe and the remedy read and write the
+same record. Reading the snapshot directories instead is the other route,
+but they live on truenas, so the probe can't see them without a network
+call on the status path.
+
+Grading: Minor (a false CRITICAL that hides nothing, but persists for days)
+x some users sometimes (only after a by-hand remedy) = P3 = [#C]. Solo: the
+remedy-logs fix is mechanical and the maint suite covers remedies. Dotfiles
+work, which this project carries end to end.
+
+** TODO [#B] Loopback-only binds in maint's unexpected-listener set :bug:maint:dotfiles:
+:PROPERTIES:
+:CREATED: [2026-09-12 Sat]
+:LAST_REVIEWED: 2026-09-12
+:END:
+From home's 2026-09-12 velox health check. The slack-mcp-deepsat container's
+=docker-proxy= on =127.0.0.1:13080= is a persistent unexpected-listener warn
+on velox (ratio runs the same container). A loopback bind is not a LAN
+exposure, and the firewall digest already distinguishes wildcard-bound
+listeners from the rest.
+
+Two routes: treat loopback-only listeners as informational (drop them from
+the unexpected set, keep them in the rows view), or curate =docker-proxy= as
+expected per machine from the panel's MARK EXPECTED. The first is the durable
+fix; the second is the workaround that works today.
+
+Not solo: whether a loopback port belongs in the signal at all is my call
+(a local port is still reachable from a browser), so the design question
+comes first. Grading: Minor (a permanent warn that trains me to skim past
+the listeners row) x every user every time (both daily drivers run the
+container, and the warn never clears) = P2 = [#B].
+
+** TODO [#B] Speedtest button cancels an in-flight run :feature:dotfiles:network:
+:PROPERTIES:
+:CREATED: [2026-09-01 Tue]
+:LAST_REVIEWED: 2026-09-01
+:END:
+
+From the roam inbox (routed 2026-09-01), Craig's words: "pressing the
+speedtest button on network admin panel when speedtest is already running
+should cancel the speedtest. However, we should leave any numbers on the
+display as if the speedtest completed successfully."
+
+Net panel work lives in ~/.dotfiles; archsetup owns it end-to-end per the
+standing rule in notes.org. Distinct from the [#C] speedtest-history task
+(that one persists results over time; this one is in-flight cancel
+semantics). Behavior is fully specified: second press kills the running
+test, display keeps whatever numbers are already shown as a completed
+result. [#B]: real improvement to the active panel family, no hard date.
+
+** TODO [#B] gcalcli in the installer, token carried from the other daily driver :feature:velox:tooling:solo:
+:PROPERTIES:
+:CREATED: [2026-08-25 Tue]
+:LAST_REVIEWED: 2026-08-25
+:END:
+From home's 2026-08-25 handoff: velox's 08-13 reinstall left it without
+gcalcli, and on 08-21 both calendar write paths on velox were down at once
+(the google-calendar MCP with expired tokens, and no gcalcli), so a booked lab
+appointment sat uncalendared for three days. The daily-drivers one-time-setup
+drift, exactly.
+
+Part 1 is done (2026-08-25, this session): =pipx install gcalcli==4.5.1= on
+velox to match ratio, then ratio's =~/.local/share/gcalcli/{oauth,cache}=
+copied over tailscale (=oauth= is a 1 KB pickled google-auth credential,
+=chmod 600=). =gcalcli list= on velox returned all six calendars with no
+re-consent, so the token is portable between the daily drivers and the OAuth
+click-through is not needed when the other machine is reachable.
+
+Part 2, this task: make the installer do it.
+- =pip_install gcalcli= in the tool set beside =pip_install yt-dlp= (archsetup
+ ~line 3109; =pip_install= wraps =pipx install= as =$username=). Pin or not:
+ ratio and velox are both 4.5.1; unpinned matches how yt-dlp is installed.
+- The credential can't be installed: add a named post-install manual step
+ "copy =~/.local/share/gcalcli/oauth= from the other daily driver
+ (=scp <other>:.local/share/gcalcli/oauth ~/.local/share/gcalcli/=,
+ =chmod 600=), or run =gcalcli init= per
+ =assets/2026-02-01-gcalcli-setup.org= when neither machine has it."
+- A =post-rebuild-check= item: gcalcli on PATH and the oauth file present, so
+ the drift is caught by the checker rather than by a missed appointment.
+- Tests: an installer-steps pytest asserting the tool set carries
+ =pip_install gcalcli=; a post-rebuild-check test for the new item, both
+ states.
+- When it lands, confirm back to home (=inbox-send home=) so it can retire
+ its "gcalcli is not installed on velox" notes.
+
+Grading: feature, no hard date, real improvement to the install = [#B].
+:solo: — build path (installer + checker + tests) and verify path (pytest;
+the live proof already exists on velox) with no open decision.
+
+** TODO [#A] Topgrade guarded-upgrade build :feature:maint:dotfiles:
+SCHEDULED: <2026-09-23 Wed>
+:PROPERTIES:
+:CREATED: [2026-08-25 Tue]
+:LAST_REVIEWED: 2026-09-17
+:SPEC_ID: 81cdfd72-db96-43d3-aa03-779878c99f3e
+:END:
+The waybar maint module's "topgrade freshness" warning never clears: the stamp
+is written only when topgrade exits 0, and the =hypr-live-update-guard=
+PreTransaction hook (mesa, wayland, hyprland, vulkan-*, nvidia-utils,
+xorg-xwayland under a live Hyprland) plus any failing ecosystem step makes that
+exit almost unreachable. Diagnosed 2026-08-24/25; the fix is specced, not
+hacked, because it spans two repos and the design is contested.
+
+Spec: [[file:docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org][2026-08-25-topgrade-guarded-upgrade-spec.org]] (DOING).
+
+Four open decisions, all mine to make before the spec can move:
+1. Freshness means *state* (a guarded upgrade still un-applied stays stale),
+ not recency (any run stamps).
+2. Primary mechanism is alternative D: an armed boot-time oneshot ordered
+ before =getty@tty1= (no display manager to order against).
+3. The boot run is arch-only (=topgrade --only system=), not the full sweep.
+4. The arm flag lives on a persistent path and is one-shot.
+
+Then: flip the decisions DONE, run spec-review (DRAFT → READY), run
+spec-response to decompose the four phases into build tasks here, file the
+vNext =[#D]= kernel-reboot item, and commit the spec.
+
+*** 2026-09-12 Sat @ 23:26:50 -0500 Containers step and initramfs-read findings from the 2026-09-12 velox run
+home's velox health check hit another route to the unreachable exit 0 this
+spec is about: topgrade exited 1 only because its containers step tried to
+=docker pull= locally built images (=cj/telega-server=, =telega-server-glycin=;
+ratio has the same shape with its own local images) and got "pull access
+denied", so the stamp was applied by hand with =maint stamp topgrade=. Two
+fixes: add =containers= to the =--disable= list in the guarded-upgrade's
+topgrade invocation, or list the local images under =[containers]
+ignored_containers= in topgrade.toml. I lean to disabling the step: pulling
+newer images underneath running containers isn't an upgrade path I use, and
+an enumerated ignore list rots as images come and go.
+
+Also for the =kernel-modules-check= gate: reading the initramfs needs root.
+The images are 0600, so an unprivileged =lsinitcpio= exits 1 with "Unable
+to read file" on stderr and nothing on stdout; piped into =grep -c=, that
+empty stdout reads as 0 and looks like a missing module. The gate has to
+run as root and check the exit status, not just the count.
+*** 2026-09-17 Thu @ 08:59:59 -0400 Re-dated to 09-23; the "four open decisions" list above is stale
+The spec is still DRAFT with =Decisions [7/7]=: every decision is made,
+including the four listed above. What's left is the isolated spec-review,
+DRAFT → READY, and spec-response into build tasks. velox has
+=linux-lts 6.18.51 → 6.18.52= pending today, which is the kernel-hold case this
+spec exists for, so no plain topgrade on velox until the hold is built or the
+kernel update runs as its own session.
+*** 2026-10-05 Mon @ 06:08:00 -0500 Spec-review ran: Not ready, 13 blocking findings
+Before the review I named the script =upgrade-guarded= and added =containers= to
+its topgrade =--disable= list. The review recorded 48 findings in the spec's
+=Review findings= section (13 blocking, 27 should-fix, 8 optional); the spec stays
+DRAFT until spec-response dispositions them. The blockers cluster in four places:
+the existing stamp writers (wrapper and doctor.py) would mark a deferred run
+fresh; the split run's =--ignore= needs a dependency closure; =kernel-modules-check=
+as written fails forever on ratio's headerless =linux-lts-strix=; and the boot run
+has no defined form (package source without network, timeout that can kill pacman
+mid-commit, =informant read= run as the user). Also: the installed topgrade
+rejects a comma-joined =--disable= list, so each step is its own argument.
+
+The 2026-10-04 velox health check ran a plain topgrade that moved the kernel
+6.18.51 → 6.18.55, against the note above. I checked the gate by hand before the
+reboot (=dkms status= installed for 6.18.55, =zfs.ko= in both initramfs images
+read with sudo) and the reboot was clean, but the hold this spec builds would
+have kept the kernel out of that run.
+
+Next: spec-response on the findings, then DRAFT → READY and decomposition into
+build tasks here.
+
+*** 2026-10-05 Mon @ 12:30:00 -0500 Spec READY; build tasks below
+The spec went DRAFT → READY at 12:10 after three review rounds. Its
+Implementation phases section is the contract every task below points at,
+and it moves to DOING with these tasks (the first task confirms that). Nine
+non-blocking residual findings stayed open, and the first task closes them
+before any code. The four-decisions list in the body above is history.
+
+The order follows the spec's commit groups: archsetup Phase 1, rollout step
+(a) on both daily drivers, dotfiles Phase 2 and step (b), archsetup Phase 3
+and step (c) (velox only after the VM gate), the Phase 4 README, then the
+flip. While Phase 2 sits unpushed, I push nothing from that dotfiles
+checkout, because any push carries it.
+
+On velox nothing runs plain topgrade, =yay -Syu= or =pacman -Syu=: not a
+shell, not the =sysupgrade= alias, and not the panel's UPDATE or TOPGRADE,
+which run =yay -Syu= and topgrade until step (b) lands Phase 2 there. Before
+step (a), the kernel and zfs-dkms move only in a session I start on purpose,
+with the gate checked by hand as on 2026-10-04. From step (a) until step
+(b), I update velox only with =upgrade-guarded= from a terminal.
+*** TODO Close the nine residual spec findings :chore:maint:solo:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-10-05
+:END:
+Deliverable: the spec, with all nine "round 3 residual" findings under
+Review findings applied and completed in place, the way the other findings
+were: =DONE=, a =CLOSED:= line, and a =Disposition:= line (accepted, or
+modified with the reason). Repo: archsetup, the spec file only.
+
+First the status heading. spec-response flips it READY → DOING when it files
+these tasks. If it still reads READY when I start, I flip it before anything
+else, in three lines: the keyword DOING, a newest-first history line ("READY
+→ DOING. Decomposed into build tasks under the archsetup todo.org parent,
+SPEC_ID 81cdfd72-db96-43d3-aa03-779878c99f3e."), and the Metadata Status
+cell set to doing.
+
+The line numbers inside the findings predate the round-3 consolidation, so I
+locate each edit by its quoted OLD text, never by line number:
+- Decision 5 Consequences: the snapshot-hold clause ("the hold moves only
+ when a later session holds a newer one").
+- AC13's everyday claims: P1-steps' step-7 bullet gains the release of the
+ previous held_snapshot, and Testing maps AC13 to P1-steps too.
+- Finish step 1: when several steps fail, the first one's failed_step and
+ detail win.
+- Recovery's power-cut bullet: register the version after =->=, and nothing
+ when the line is a removed line or there is none.
+- Exit codes and interrupts: narrow the bare-=wait= qualifier.
+- ZFSBootMenu: roll back with MOD+R (never ENTER, MOD+X or MOD+C) in
+ Recovery, D-zbm and its variant.
+- Design and Decision 5: REBOOT hides from the panel's next probe, and the
+ fire-time refusal covers the window before it.
+- The D-zbm variant: =ssh -t=, the headers' upgrading line as the reset cue,
+ and a repeat on a fresh VM when the reset missed extraction.
+- P1-steps' fake-sudo bullet and Readiness: =yay -Sua= is what never runs,
+ since =yay -Pwq= already ran.
+The snapshot-hold and REBOOT findings both rewrite Decision 5's
+Consequences, so I apply those two together. I also reword the three places
+that still say ZFSBootMenu boots the held snapshot (Design's ZFS fallback
+paragraph, the note after AC13, and Risks' last-resort bullet) to a MOD+R
+rollback, so no section points a reader at ENTER.
+
+This task goes first because several findings change test bullets (P1-steps)
+and the manual procedures (Recovery, D-zbm) that later tasks build against.
+
+Verified by re-reading. The findings quote their own OLD text, so a
+whole-file search always matches; I check outside Review findings only. For
+each replaced OLD string this prints 0, and for each inserted or replacing
+NEW sentence (the first-failure rule in Finish is an insertion, so its anchor
+stays) it prints 1:
+: sed '/^\* Review findings/,/^\* Implementation phases/d' SPEC | grep -cF 'TEXT'
+Each residual reads DONE with a CLOSED line and a disposition. The Review
+findings heading cookie reads 138 of 138, updated by hand or with =emacs
+--batch= and =org-update-statistics-cookies=, because an edit outside Emacs
+doesn't recompute it. The status heading reads DOING. It's a doc edit, so
+there's no suite to run.
+Contract: Review findings, the nine "round 3 residual" entries.
+
+*** TODO Thresholds mirror, check 9 and hold-aware prune :feature:installer:zfs:solo:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-10-05
+:END:
+Deliverable (archsetup): Phase 1's three standalone changes, one commit
+each, each green on =make test-unit=:
+- =configs/maintenance-thresholds.toml='s =[updates] guard_patterns=
+ rewritten to exactly the hook heredoc's 15 Target patterns, with the
+ comment reworded; the TOML pin test in =tests/installer-steps/=.
+- =scripts/post-rebuild-check= check 9 (the =10-= hook name wherever the
+ guard is installed) behind =PRC_GUARD_BIN= and =PRC_PACMAN_HOOKS_DIR=, with
+ the header, usage and counters moved to 9 and every test env setting
+ =PRC_GUARD_BIN= empty.
+- =scripts/zfs-pre-snapshot='s prune skips rows with userrefs above 0, and
+ =tests/zfs-pre-snapshot/fake-zfs= gains hold, release and userrefs, which
+ the =--complete= tests reuse.
+Tests: P1-installer's TOML pin, post-rebuild-check and zfs-pre-snapshot
+cases.
+
+None of these is live on a machine until step (a) installs or re-copies it,
+so they land first. Check 9 flags a legacy hook name if post-rebuild-check
+runs before step (a); that's the check working.
+Verified by =make test-unit= green before and after, plus a mutation spot
+check: hand-edit one TOML pattern and the pin test goes red.
+Contract: Phase 1, Other Phase 1 changes; Phase 1 tests, P1-installer.
+
+*** TODO kernel-modules-check gate :feature:zfs:solo:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-10-05
+:END:
+Deliverable (archsetup): =scripts/kernel-modules-check=, the stateless
+reader that says whether each named kernel would boot with its modules:
+usage and exit codes, the per-pkgbase items, the =--since= initramfs and
+snapshot items, the =sudo -n lsinitcpio= read on a ZFS root, and the seams
+=KMC_MODULES_DIR=, =KMC_BOOT_DIR= and =KMC_DKMS=. It doesn't depend on
+=upgrade-guarded=, so it lands first.
+Tests: P1-gate in =tests/kernel-modules-check/=, with fakes for dkms,
+findmnt, =zfs list= and an lsinitcpio that fails unless run through the
+fake sudo.
+Verified by =make test-unit= green, plus a read-only real run from the repo
+on ratio against =linux-lts=, expecting exit 0 (btrfs root, so no sudo
+read). I don't point it at the running =linux-lts-strix=: its zfs is only
+'added', and the gate never checks it. The first real =sudo -n lsinitcpio=
+read is step (a)'s structural run on velox.
+Contract: Phase 1, The gate; Phase 1 tests, P1-gate.
+
+*** TODO upgrade-guarded foundation: modes, preconditions, sets, record :feature:maint:solo:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-10-05
+:END:
+Deliverable (archsetup): =scripts/upgrade-guarded= up to, but not
+including, the closure and the transaction steps:
+- mode and option parsing (exit 2, no record);
+- the preconditions in order: usage, EUID 0, the flock held on fd 9,
+ =db.lck=, and the =10-= hook's Target lines;
+- the sets: pending (with =pacman -Qu='s exit-1-empty rule and its
+ =[ignored]= rows dropped), GPU patterns, kernel set, DKMS set and live;
+- the record (maint's cache envelope, tmp file then rename) and Finish's
+ result, failed_step and detail rules, with the reason as the last stderr
+ line;
+- the exit codes, and the INT/TERM/HUP trap that waits on the child's PID;
+- =--dry-run= on its private =CHECKUPDATES_DB= with =checkupdates --nocolor=;
+- the harness: every seam, the fakes on PATH, and subprocess-driven
+ unittest suites.
+Nothing calls the script until Phase 2, so a partial script is harmless.
+Tests: P1-sets' cases that need neither the closure nor a transaction (the
+three-kernel fixture with the foreign headerless kernel, firmware and
+api-headers, and the unowned vmlinuz, minus its gate.pkgbases clause; the
+empty DKMS set; the =-Qu= exit handling; the fail-closed hook), plus a
+supporting case that the pending set drops =[ignored]= rows; and P1-steps'
+usage, EUID 0, lock, =db.lck= and =--dry-run= cases (the probe =--dbpath=
+clause lands with the closure). P1-sets' post-transaction deferred-set case
+waits for the everyday-run task, and the gate.pkgbases clause for the
+=--complete= task.
+Verified by =make test-unit= green, plus a real =--dry-run= from the repo on
+ratio. It refuses with exit 3 naming the missing =10-= hook, because ratio
+keeps the legacy name until step (a). With =UPGRADE_GUARDED_HOOK= pointed at
+the legacy file, it prints ratio's real sets, and =linux-lts-strix= isn't
+pending.
+Contract: Phase 1, Identity and privilege; Modes and options;
+Preconditions; Sets; The record; Finish; Exit codes and interrupts; Seams
+and fakes.
+
+*** TODO upgrade-guarded dependency closure :feature:maint:solo:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-10-05
+:END:
+Deliverable (archsetup): the closure loop, meaning the read-only =-Sup=
+print-mode probe, the everyday stage-A seed, the live-only stage-B seed,
+the =--complete= GPU seed, the two line forms, every refusal rule with its
+detail, the iteration bound, the kinds each stage assigns, and the held set
+built from them. =--dry-run= then prints the GPU closure, the predicted
+deferred set and the would-be =-Su= argv. I keep this apart from the
+transaction steps because it's the riskiest logic in Phase 1, and it gets
+its own verification pass.
+Tests: P1-closure, except its single =-Sy= case and the "=-Su= runs" clause
+of its new-dependency case, which need the everyday transaction (next
+task); P1-sets' held-set cases (the full =--ignore= list with each entry its
+own element, the everyday half of the live and not-live case, the
+=zfs-dkms=/=zfs-utils= pair held together, a pkgrel-only bump not held),
+asserted on =--dry-run='s printed =-Su= argv until the next task re-points
+them at the fake pacman's real call; P1-steps' =--dry-run= probe =--dbpath=
+clause. The orphan case's "no =-Su= runs" clause is vacuous until the next
+task adds the transaction, and bites from then on.
+Verified by =make test-unit= green, plus the real =--dry-run= on ratio
+(hook seam as before), expecting a closure without a refusal. A refusal
+naming a real orphan is itself a finding I clear by hand before step (a).
+Contract: Phase 1, Dependency closure; Sets (Kinds, Held set, GPU closure).
+
+*** TODO upgrade-guarded everyday run, arm procedure and stamp :feature:maint:solo:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-10-05
+:END:
+Deliverable (archsetup): the everyday sequence, steps 1 to 10, except step
+7's kernel-side detection, which lands with the snapshot-hold task:
+- =yay -Pwq= news capture and the record's merge, dedup and cap;
+- the informant clear under =timeout 60 sudo -n=;
+- one =sudo -n pacman -Sy=, then =-Su= with the =--ignore= list and no =-y=;
+- =yay -Sua= on every run, and the sweep argv by absolute path;
+- step 9's flag cases, with the arm procedure (resolve check, =-Sw=, the
+ =install= and =mv=) and the unit-enabled seam;
+- Finish's stamp step through =UPGRADE_GUARDED_MAINT=, the everyday stamp
+ predicate, and the first-failure rule the first task adds to Finish;
+- the canonical fixture
+ =tests/upgrade-guarded/fixtures/upgrade_deferred.json= from a fixed fake
+ run.
+Tests: P1-steps (the =sudo -n= refusal, news, informant, the exit-124
+abort, the sweep argv and its real-binary =--version= check, the failing
+yay, the INT cases); P1-closure's single =-Sy= case and the "=-Su= runs"
+clause of its new-dependency case; P1-sets' post-transaction deferred-set
+case (a fake post-transaction =-Qu= intersected with the held set, excluding
+an =[ignored]= row), with its held-set cases re-pointed at the real =-Su=
+call; P1-record-stamp's everyday cases and the fixture equality;
+P1-arm-boot's two everyday flag cases; and one case for the first-failure
+rule: a failing fake yay and a failing fake sweep leave failed_step aur and
+yay's detail.
+Verified by =make test-unit= green on ratio, where =/usr/bin/topgrade=
+exists, so the real-binary check runs instead of skipping. The first run
+against real pacman is M-split-live at step (a).
+Contract: Phase 1, Everyday sequence; The arm flag; Stamp predicate;
+Finish; The record (Fixtures).
+
+*** TODO upgrade-guarded --complete and its gate entry :feature:maint:solo:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-10-05
+:END:
+Deliverable (archsetup): the =--complete= sequence, steps 1 to 7, built and
+tested on fixtures that aren't a ZFS root (the fake findmnt reports another
+filesystem, so no snapshot or hold runs):
+- refresh then flag removal, 'nothing to complete', the pre-stage-1 record
+ and gate entry, stage 1 (with the reinstall targets when a gate is
+ already open), the gate in its structural or =--since= form, the GPU step
+ (stage 2, arm, or the unit-not-enabled line), and the tty-only reboot
+ prompt after Finish;
+- the =--complete= stamp predicate and the Invariants.
+Nothing calls or installs the script until Phase 1 is done, so a
+=--complete= without its hold is harmless; the next task adds the hold.
+Tests: P1-complete except its three snapshot-hold bullets; the
+gate.pkgbases clause of P1-sets' three-kernel case; P1-record-stamp's
+=--complete= cases; P1-arm-boot's =--complete= arm, unit-not-enabled and
+prompt cases; the =--complete= half of P1-sets' live and not-live case; the
+=--complete= side of P1-steps' news and informant cases.
+Verified by =make test-unit= green.
+Contract: Phase 1, =--complete= sequence; Invariants; Stamp predicate;
+Exit codes and interrupts (the prompt).
+
+*** TODO Snapshot hold and the everyday AUR kernel-side check :feature:maint:zfs:solo:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-10-05
+:END:
+Deliverable (archsetup), after the =--complete= task:
+- =--complete= step 3's own snapshot and hold on a ZFS root, the post-gate
+ =--since= target rule, and the release of the previous hold;
+- everyday step 7's kernel-side detection after yay: the gate entry, the
+ pre-yay snapshot hold, the flag removal and the immediate record write;
+- the trap's matching step-7 path.
+Tests: P1-complete's three snapshot-hold bullets; P1-steps' AUR kernel-side
+case, with its INT cases and the release-of-the-previous-hold clause the
+first task adds. Its "a following =--complete= runs the =--since= form"
+clause needs the previous task, which is why this one comes after it.
+Verified by =make test-unit= green. The first real snapshot and hold is
+M-boot-armed's =--complete= in the VM, which opens the entry on a ZFS root.
+Contract: Phase 1, The snapshot hold; Everyday sequence (step 7); Exit
+codes and interrupts; Invariants.
+
+*** TODO upgrade-guarded --apply-armed boot form :feature:maint:solo:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-10-05
+:END:
+Deliverable (archsetup): =--apply-armed=, steps 1 to 8. That means reading
+the list the unit moved off the flag path (refusing a missing, empty or
+unparseable list, or a live Hyprland), the interrupted pre-write, the tty1
+banner, the journal mirror through a SIGINT-ignoring process substitution,
+the informant clear, the =vercmp= drop of entries already at or above
+their version, the offline =-Sp= check that refuses kernel-side members and
+stale versions, the =-S --needed= transaction from the cache, and Finish
+with its stamp predicate.
+Tests: P1-arm-boot's =--apply-armed= cases, including the =setsid=
+process-group SIGINT case (no SIGPIPE death, the fake lock released, the
+record interrupted); P1-steps' clause that =--apply-armed= never calls
+=yay -Pwq=, and its =--apply-armed= informant and exit-124 cases;
+P1-record-stamp's =--apply-armed= success case.
+Verified by =make test-unit= green. The real boot path is the VM gate.
+Contract: Phase 1, =--apply-armed= sequence; Exit codes and interrupts;
+Stamp predicate.
+
+*** TODO Script install step and health-check workflow edit :feature:installer:solo:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-10-05
+:END:
+Deliverable (archsetup), two commits that close Phase 1's group:
+1. The =hyprland()= step that installs =hypr-live-update-guard= also installs
+ =upgrade-guarded= and =kernel-modules-check= to =/usr/local/bin=, with
+ P1-installer's source-inspection test.
+2. A doc commit after the code: =docs/workflows/system-health-check.org=
+ Phase 3, per the contract's seven bullets. Step 6 runs =upgrade-guarded=
+ where step (a) is done. Kernel and DKMS days go through =--complete=.
+ Until step (c), the GPU set finishes from a console. Where step (a)
+ isn't done and a kernel or DKMS package is pending, plain topgrade
+ doesn't run. The strix addendum is keyed to each invocation. The script
+ stamps, so there's no hand stamp. The 2026-09-12 containers entry gets a
+ resolution note.
+After this, Phase 1 is complete and step (a) can start.
+Verified by =make test-unit= green, and the workflow edit re-read against
+the contract's bullets.
+Contract: Phase 1, Files and install; The system-health-check edit; Phase
+1 tests, P1-installer.
+
+*** TODO Rollout step (a) on ratio :chore:ratio:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-10-05
+:END:
+After Phase 1's last commit, by hand on ratio, in the contract's order:
+- pull archsetup, then =sudo install -m 755= both scripts to
+ =/usr/local/bin/=;
+- write =/etc/pacman.d/hooks/10-hypr-live-update-guard.hook= from the
+ installer heredoc, confirm its Targets, remove the legacy unprefixed hook
+ and check =ls /etc/pacman.d/hooks/=;
+- diff, then re-copy =configs/maintenance-thresholds.toml= to
+ =~/.config/archsetup/=;
+- confirm =pacman -Qmq= lists no DKMS-set package;
+- confirm =upgrade-guarded --dry-run= exits 0;
+- run M-split-live on ratio (Manual testing and validation).
+Ratio's root is btrfs, so the =zfs-pre-snapshot= install and the structural
+gate run belong to velox's step (a).
+Verified by M-split-live on ratio passing, and post-rebuild-check's check 9
+clean here (the =10-= hook present, the legacy name gone). If I build Phase
+2 on ratio, its dotfiles checkout is the stowed, live one, so each Phase 2
+commit is live on ratio as it's written; that's why Phase 2 starts only
+after step (a) on the build machine.
+Contract: Phase 4, Rollout (a) and M-split-live.
+
+*** TODO Rollout step (a) on velox :chore:velox:zfs:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-10-05
+:END:
+After Phase 1's last commit, by hand on velox: the same list as ratio's
+step (a), plus the ZFS-root items:
+- =sudo install -m 755 scripts/zfs-pre-snapshot /usr/local/bin/= for the
+ hold-aware prune;
+- =kernel-modules-check "$(cat /usr/lib/modules/$(uname -r)/pkgbase)"= exits
+ 0, which proves the =sudo -n lsinitcpio= read against the real image;
+- velox's guard hook was hand-placed under the legacy name, so the =10-=
+ rewrite and the legacy removal matter here too;
+- run M-split-live on velox.
+On velox nothing runs plain topgrade, =yay -Syu= or =pacman -Syu=: not a
+shell, not the =sysupgrade= alias, and not the panel's UPDATE or TOPGRADE,
+which run =yay -Syu= and topgrade until step (b) lands Phase 2 there. Before
+this task, the kernel and zfs-dkms move only in a session I start on
+purpose, with the gate checked by hand: =dkms status= installed for the new
+kernel, and =zfs.ko= in each initramfs, read with sudo. From here until
+step (b), I update velox only with =upgrade-guarded= from a terminal, and
+the health-check workflow's step 6 runs it.
+Phase 2 can be built on ratio while this waits, but I don't push any Phase 2
+commit until this is done, because any dotfiles pull on velox would bring it
+in.
+Verified by M-split-live on velox, the structural gate run exiting 0, and
+post-rebuild-check's check 9 clean here (the =10-= hook present, the legacy
+name gone).
+Contract: Phase 4, Rollout (a) and M-split-live.
+
+*** TODO maint deferred row, APPLY and gate-aware REBOOT :feature:maint:dotfiles:solo:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-10-05
+:END:
+Deliverable (dotfiles), one commit, because the row and APPLY land together:
+- the =upgrade_deferred= probe and the helper it shares with
+ =topgrade_freshness= (N, never raises, None on a missing or malformed
+ record), the severity order, the armed text from the flag, and the
+ evidence (rows, detail, held_snapshot, and news lines not in
+ =upgrade_news_dismissed=, which stays empty until DISMISS lands);
+- =apply_deferred= (tier confirm, kind terminal), its =LEVER_KEYS= builder,
+ =topgrade_freshness='s =deferred= evidence, =doctor.review='s routing,
+ =panel.fire_press= launching =foot --hold -e upgrade-guarded --complete=
+ detached, and =maint fix apply_deferred= on inherited stdio;
+- Freshness and REBOOT: the wall note, REBOOT hidden while a gate is open
+ and shown while armed, =iter_fix='s fire-time refusal, and =review=
+ omitting =reboot= while a gate is open;
+- the byte-identical fixture copy at
+ =tests/maint/fixtures/upgrade_deferred.json=, with its test module's
+ docstring naming the archsetup source.
+I land this before the lever repoint, so gate-aware REBOOT already exists
+the first time a lever runs =upgrade-guarded=, whose AUR step can open a
+gate. Until the repoint the row reflects only shell runs, and with no
+record it reads OK '0 deferred'.
+
+Repo: =~/.dotfiles=, a separate project. Confirm the crossing before
+starting, or run it from a dotfiles session. This starts after step (a) on
+the machine I build Phase 2 on (=uname -n=): its dotfiles checkout is stowed
+and live, so each commit reaches that machine as it's written. Baseline:
+=make test= in =~/.dotfiles=; the red screen-lock suite on ratio is the
+known failure, tracked in "screen-lock test suite red on ratio".
+Tests: P2-row, P2-apply, and P2-text's wall-note case. No test imports
+gui.py.
+Verified by the =tests/maint= suites and =make test= green apart from that
+known red; =cmp= on the two fixture copies; and =make test-panel-maint=, the
+AT-SPI smoke over the fixture boards. =make test= never runs it, and it's
+the only check that drives gui.py, which this commit edits. Ratio has no
+weston or sway, so the smoke uses the live compositor and opens a panel
+window; I run it on a spare workspace, and a SKIP is not a pass. The real
+APPLY press is M-boot-armed on ratio and velox.
+Contract: Phase 2, The deferred row; APPLY; Freshness and REBOOT; Phase 1,
+The record (Fixtures).
+
+*** TODO maint levers repointed to upgrade-guarded :feature:maint:dotfiles:solo:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-10-05
+:END:
+Deliverable (dotfiles), the one commit the spec ties together:
+- UPDATE and TOPGRADE argv and the missing-binary message;
+- doctor.py's =topgrade_run= stamp deleted;
+- the guard UX removed: the =live_update= tag, =iter_fix='s refusal and
+ guard event, =--force= and its sentinel wrap, =_update_force=,
+ =_rearm_after_guard=, the guard branches in =_on_fired=, cli.py and
+ panel.py, and the review suffix; =panel.guarded= becomes a set test, and
+ the tripped arm line becomes the annotation;
+- the =sysupgrade= conditional in both common alias files, with minimal's
+ files left as symlinks;
+- the stale text: the =topgrade_freshness= docstring and absent-stamp
+ error, the doctor.py and guard.py docstrings, the README =maint fix=
+ usage line, and the wrapper's header and its test docstring;
+- =tests/maint/panel_smoke.py='s "arm line shows the exact update argv"
+ check, which pins =yay -Syu --noconfirm=, now matching
+ =upgrade-guarded --no-topgrade=. The spec doesn't list the file, but the
+ repoint breaks it.
+The runner's long-remedy timeout (new session, SIGINT to the group, 120 s,
+then SIGKILL, 'timed out — interrupted') can land as its own commit just
+before this one. UPDATE and TOPGRADE are already user-kind and marked long,
+so it applies to today's =yay -Syu= and topgrade argv the moment it lands.
+That's safe: today a timeout SIGKILLs only the direct child
+(=subprocess.run='s timeout), and a SIGINT to the whole group stops pacman
+at a package boundary instead.
+
+Repo: =~/.dotfiles=, a separate project. Confirm the crossing before
+starting, or run it from a dotfiles session.
+Tests: P2-levers, P2-guard-ux (including the rewritten
+test_panel_levers.py and test_panel_phase10.py cases), and P2-text's
+alias, absent-stamp and usage-line cases.
+Verified by the =tests/maint= suites and =make test= green apart from the
+known screen-lock red, and =make test-panel-maint= passing on a spare
+workspace (a SKIP is not a pass). After it, the first UPDATE press on the
+build machine runs the same command M-split-live already ran there.
+Contract: Phase 2, Levers; Guard UX; Alias and README (the alias).
+
+*** TODO maint DISMISS, held-package CVE/QUEUE/strip, README :feature:maint:dotfiles:solo:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-10-05
+:END:
+Deliverable (dotfiles), three commits after the repoint:
+1. DISMISS: the =dismiss= key kind, dispatched explicitly in =_digest_key=,
+ arm-then-fire, and the GTK-free panel.py helper that overwrites
+ =upgrade_news_dismissed= with the record's whole news list. Tests:
+ P2-dismiss.
+2. CVE, QUEUE and the strip: deferred names out of =cve_queued=, the CVE
+ caption, UPDATE's binding and REVIEW & FIX; =[HELD]= tags in QUEUE; and
+ the strip's 'P pending · N held'. Tests: P2-cve-queue.
+3. The 'The live-update guard' section of =maint/README.md= rewritten for
+ the new levers, the sweep argv, the deferred row, APPLY, DISMISS and the
+ armed state, with no press-again or =--force=. The guard banner and the
+ wrapper paragraph stay.
+Then I push Phase 2, and only once step (a) is done on velox. Until then I
+push nothing from this dotfiles checkout, because any push carries it.
+
+Repo: =~/.dotfiles=, a separate project. Confirm the crossing before
+starting, or run it from a dotfiles session.
+Verified by the =tests/maint= suites and =make test= green apart from the
+known red, =make test-panel-maint= passing on a spare workspace (the strip's
+pending caption is in its path; a SKIP is not a pass), and the README
+section re-read against the contract.
+Contract: Phase 2, DISMISS; CVE, QUEUE and the strip; Alias and README.
+
+*** TODO Rollout step (b) on the other daily driver :chore:dotfiles:velox:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-10-05
+:END:
+Pull the Phase 2 commits into the other daily driver's dotfiles, only after
+step (a) is done there. The build machine already has them through its live
+checkout, so this step is for the other one: velox when I build Phase 2 on
+ratio. If I build it on velox, this is ratio's step (b) and the topic tag
+moves to =:ratio:=.
+Verified by that machine's dotfiles HEAD matching the remote, the
+=tests/maint= suites green there, and =maint status= there listing the
+=upgrade_deferred= row.
+Contract: Phase 4, Rollout (b); the Phase 2 intro.
+
+*** TODO archsetup-boot-upgrade.service installer step :feature:installer:solo:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-10-05
+:END:
+Deliverable (archsetup): an installer step that writes the unit from a
+heredoc with =ARCHSETUP_USERNAME= substituted by sed, runs
+=install -d -m 0755 /var/lib/archsetup=, and enables the unit without
+=--now=, documented in its own comments. The spec names no function. It
+can sit beside the guard install in =hyprland()=, or in a top-level
+function called from there, and the source-inspection idiom reads either.
+Tests: P3-unit in =tests/installer-steps/=: every directive the contract
+lists, plus the absence of Requires=, BindsTo=, RequiredBy=, network-online
+and Restart=.
+Verified by =make test-unit= green. The boot behavior itself is the VM gate.
+Contract: Phase 3, Installer step; The unit; Disarm and failure; Phase 3
+tests, P3-unit.
+
+*** TODO Rollout step (c) on ratio :chore:ratio:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-10-05
+:END:
+After Phase 3's commit, by hand on ratio: write the unit from the installer
+heredoc with the username substituted, then run
+=sudo install -d -m 0755 /var/lib/archsetup=, =sudo systemctl daemon-reload=
+and =sudo systemctl enable archsetup-boot-upgrade.service=. The VM gate
+holds back only velox, so ratio can go first.
+Then, on the first day =upgrade-guarded --dry-run= lists a GPU-kind
+package, run M-boot-armed on ratio and M-boot-ratio on the same armed boot.
+Run the strix check on the first =--complete= that lands linux or
+linux-lts. All three are under Manual testing and validation.
+Verified by those three manual tests.
+Contract: Phase 4, Rollout (c) and the ratio-path confirmation.
+
+*** TODO VM gate for the boot unit :test:installer:zfs:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-10-05
+:END:
+Run the five VM-gate tests in the =make test-keep FS_PROFILE=zfs= VM,
+following Phase 3's VM procedure, in this order: M-split-console,
+M-boot-armed, then M-boot-midtx on the same VM, then M-boot-timeout and
+M-boot-fail. M-boot-midtx needs the VM M-boot-armed leaves, because it
+times its cut from that boot's journal, so nothing reinstalls between them.
+Each test is a child of Manual testing and validation.
+=make test-keep= bundles archsetup from HEAD, so Phase 1 and Phase 3 must be
+committed first. The VM clones the published dotfiles, so this runs after
+the Phase 2 push; M-boot-timeout and M-boot-fail then read maint's
+failed-units and deferred rows inside the VM, which AC6 asks for. I never
+use =debug-vm.sh= here: it restores the clean-install snapshot and erases
+the install under test.
+M-boot-timeout and M-boot-fail use small inline stand-in fakes in place of
+the Phase 1 fake sudo and pacman: they pass every query to the real pacman
+and sleep or fail only on the transaction, so the VM tests don't depend on
+how the unit-test fakes are laid out.
+The unit reaches velox only after all five pass.
+Contract: Phase 3, VM procedure and VM gate; Testing / Verification /
+Rollout.
+
+*** TODO Rollout step (c) on velox :chore:velox:zfs:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-10-05
+:END:
+Only after the VM gate passes: the same four steps as ratio's step (c).
+Then, on the first day =upgrade-guarded --dry-run= lists a GPU-kind
+package, run M-boot-armed on velox and M-boot-news.
+Verified by those two manual tests.
+Contract: Phase 4, Rollout (c).
+
+*** TODO maint README flow and recovery steps :chore:maint:dotfiles:solo:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-10-05
+:END:
+Deliverable (dotfiles), one commit to the 'The live-update guard' section
+of =maint/README.md=, after Phase 3. It adds the flow line, the
+=kernel-modules-check <pkgbase>= diagnosis note for a CRIT row (only
+=--complete= clears the gate), and the recovery steps as their only copy.
+The steps come from the spec's Recovery as the first task amends it: the
+MOD+R rollback and the version-after-the-arrow rule.
+Repo: =~/.dotfiles=, a separate project. Confirm the crossing before
+starting, or run it from a dotfiles session.
+The non-gating D-zbm drill and its power-cut variant exercise these steps
+(Manual testing and validation), and nothing waits on them.
+Verified by =make test= green apart from the known screen-lock red, and the
+section re-read against the contract.
+Contract: Phase 4, README flow and recovery; Recovery; Recovery drill.
+
+*** TODO Flip the spec to IMPLEMENTED (+ dated history line + Metadata mirror) :chore:maint:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-10-05
+:END:
+This waits until every task above is done and the gating manual tests have
+passed: M-split-live on both machines, the five VM-gate tests,
+M-boot-armed on ratio and velox, M-boot-news, M-boot-ratio and the strix
+check. Then tick each acceptance criterion against the evidence that
+Testing / Verification / Rollout maps to it. AC1 stays unticked until a live
+run with a GPU-kind entry pending has shown the hook silent. Last, the spec
+edits: the status keyword DOING → IMPLEMENTED, a dated history line that
+says why, and the Metadata Status mirror set to implemented. D-zbm is
+non-gating and doesn't hold this.
+Contract: the spec's status heading and Metadata table.
+
+** TODO [#D] Stale-kernel age in maint :feature:maint:dotfiles:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-10-05
+:END:
+The guarded-upgrade build holds the kernel and DKMS sets on every everyday
+run, so kernel security fixes wait for an =upgrade-guarded --complete= I
+start on purpose. The deferred row is the only reminder, and it shows a
+kernel most days, so it stops reading as a nag. This is the spec's vNext: a
+maint age for how long the kernel set has been held, graded so an overdue
+dedicated session shows up.
+
+The design is still open. The record has no held-since field, so the age
+needs either a new field (both fixture copies change in one rollout) or a
+derivation from pacman.log. maint's CVE probe doesn't cover it: as of
+2026-10-05 the kernel advisories in its cache carry no fixed version, so it
+never flags a held kernel.
+
+I kept this out of v1 on purpose. It replaces the "vNext kernel-reboot
+item" in the build parent's original body.
+Spec: [[file:docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org][guarded-upgrade spec]], Scope tiers and Risks (Standing kernel
+deferral).
+
+** TODO [#C] post-rebuild-check: probe that Emacs frames come up Wayland-native :feature:emacs:velox:solo:quick:
+:PROPERTIES:
+:CREATED: [2026-08-25 Tue]
+:LAST_REVIEWED: 2026-08-25
+:END:
+On 2026-08-24, the first Emacs 31.1 start on velox opened its first frame on
+XWayland (=:0=) with the pgtk "unsupported under X" dialog, while every later
+frame went to =wayland-1=. The cause was in the Emacs config (a startup buffer
+sweep killed =*Warnings*= while 31.1's warnings.el held it for a deferred
+display, so the first =make-frame= failed and emacsclient fell back to
+=$DISPLAY=); fixed in =.emacs.d= the same night. The trap generalizes: any
+first-frame error on a PGTK daemon silently lands the session on X, and nothing
+in the post-rebuild pass would notice.
+
+Design, under the script's fail-closed contract (a probe that cannot run
+reports a finding, never a pass):
+- Bound every =emacsclient= call with =timeout=, as the =systemctl= calls are.
+ A daemon stuck in a prompt is a finding, not a hang.
+- No daemon (=emacsclient= cannot connect): a visible finding, "not checked:
+ no Emacs daemon, start Emacs and re-run". Emacs is started on demand here,
+ so this is the common state right after a rebuild, and the line is the point.
+- Daemon up but no GUI frame yet: never skip, and never call
+ =pgtk-backend-display-class= with no frame (it errors with "Frames are not
+ in use"). Request one invisible frame through a waiting client in the
+ background, =timeout 20 emacsclient -c -F '((visibility . nil) (name .
+ "prc-probe"))'=, so the probe walks the same first-frame path that failed on
+ 2026-08-24. Then wait, bounded (poll for a frame named =prc-probe= for up to
+ the same 20 s), before inspecting: the =-e= must not run before the =-c=
+ has connected. Zero pgtk frames after the request is a finding in its own
+ right, because a frame request that produced nothing is the first-frame
+ failure this check hunts. Delete the probe frame after reading.
+- Inspect every pgtk frame, not the selected display, and only pgtk frames:
+ a tty client frame (=emacsclient -t= in tmux) carries =$DISPLAY= as its
+ display parameter and its terminal is not a display, so it would both trip
+ the =:0= rule and make =pgtk-backend-display-class= error.
+ #+begin_src sh
+ emacsclient -e '(mapcar (lambda (f) (list (frame-parameter f (quote display)) (pgtk-backend-display-class (frame-terminal f)))) (seq-filter (lambda (f) (eq (framep f) (quote pgtk))) (frame-list)))'
+ #+end_src
+ Expected: at least one entry, every display equal to =$WAYLAND_DISPLAY=,
+ every class =GdkWaylandDisplay=. An empty list, a =:0= entry, or a
+ =GdkX11Display= is a finding. A build without =pgtk-backend-display-class=
+ is a finding too: the installer installs =emacs-wayland=.
+- Limit, stated in the check's output: it sees live frames only. A first X
+ frame that was already closed is invisible, so this reports the machine's
+ current state, not its history. The invisible probe frame is created and
+ never mapped, so it exercises =make-frame= (where 2026-08-24 failed), not
+ the window-show path.
+
+Tests alongside the other checks, one per state: no daemon, no frame (probe
+frame requested and waited for), probe frame never appears, Wayland-only, a
+=:0= frame present, a tty client frame present alongside Wayland frames, hung
+daemon, an =*ERROR*= reply from =emacsclient -e= (exit 1, a finding), non-pgtk
+build.
+
+** TODO [#B] Timeline spine test picks the wrong "next" event off Denver :bug:dotfiles:test:
+:PROPERTIES:
+:CREATED: [2026-08-24 Mon]
+:LAST_REVIEWED: 2026-08-24
+:END:
+=make test= in dotfiles is red before any of this session's work. Two failures,
+both in =settings/faces/timeline-face-spine.test.mjs=: "event bars never leave
+the plot" and "exactly one event is marked as next, and it is the soonest ahead".
+
+NOT the bug =c96a216= fixed. Every =spineRows= call in that file is pinned to
+=JUL=, and =scene()= and =EVENTS()= both default to it, so the fixture side is
+already clean and the file's own guard test passes.
+
+TWO THINGS TO SETTLE, and they may be one bug or two:
+
+1. =timeline-face-spine.js:466= — =const next = timedOnly(events).find((e) => e.s
+ >= refMs)= takes the first array element starting at or after now, which is
+ the *soonest* only if =events= is sorted by start time. The test's failure
+ message is exactly that it is not: a bar ahead of the spine starts at x=1651.2
+ while the one marked =event-next= sits at x=2132.8. Either sort before the
+ find, or use a min-by rather than a find.
+
+2. Why it is red *here* and presumably green on ratio. The most recent commit to
+ =timeline-face-spine.js= is =ffe43ab feat(settings): draw home where the
+ machine is, not where its zone is=. This machine is =America/Denver= (Craig
+ travelling); the tests pass =home("New Orleans")= explicitly. If a
+ machine-resolved home overrides the explicit argument, the geometry drifts
+ and the test is machine-dependent — which makes it useless as a gate, since it
+ would only ever fail on the machine nobody runs it on. Confirm by running the
+ faces suite with =TZ=America/Chicago= and again with =TZ=America/Denver=.
+
+If item 2 confirms, the design question is whether machine-resolved home belongs
+in the pure geometry layer at all, or whether the host should resolve it and pass
+it in — which is what the test already assumes.
+
+Not blocking the Lua port: =make test-faces= is disjoint from the hypr config and
+the three suites that work touches.
+
+** TODO [#B] Qt apps render oversized on velox :bug:velox:solo:
+:PROPERTIES:
+:CREATED: [2026-08-19 Wed]
+:LAST_REVIEWED: 2026-08-19
+:END:
+
+From the roam inbox, Craig's words: "qt apps look huge on velox. how do we make
+it look better on this particular machine, and not change ratio. it seems they
+should have different QT configs."
+
+The shape is per-machine Qt scaling. velox is a high-DPI Framework panel and
+ratio drives ordinary-DPI monitors, so one global Qt scale factor cannot suit
+both. The fix has to be host-scoped rather than a value written into the shared
+config, which is the same tier split the dotfiles already use.
+
+Grading: Minor severity (apps work, they are just the wrong size) x every user
+every time (every Qt app launch on velox) = P2 = [#B].
+
+*** 2026-08-19 Wed @ 15:05:00 -0700 Root cause found and fixed; needs a logout to take effect
+velox's =conf.d/local.conf= scaled the panel twice. The monitor line sets
+=1.566667= and the same file exported =QT_SCALE_FACTOR,1.5= and =GDK_SCALE,1.5=,
+and Qt 6 on Wayland already takes its scale from the compositor, so the two
+multiplied. Measured rather than reasoned: with the override Qt reports a
+960x640 logical screen, without it 1440x960, and 2256/1.566667 is exactly 1440.
+That is 1.5x too large, which matches "huge" precisely.
+
+Those env lines were not careless. The comment above them explains they existed
+to compensate for =xwayland:force_zero_scaling = true= in the shared
+hyprland.conf, which makes XWayland clients render unscaled and tiny. The
+approach was what failed: an env var reaches every app, so fixing XWayland broke
+every native Wayland client. Removing the vars alone would have traded "Qt huge"
+for "Zoom tiny", so velox now turns =force_zero_scaling= off for itself instead.
+XWayland scales through the compositor there, coming out correctly sized and
+slightly soft. ratio is untouched and needs nothing, its monitor being scale 1.
+
+=force_zero_scaling= took effect on =hyprctl reload=. The env removal will not:
+Hyprland applies =env== lines with setenv at parse time and never unsets them,
+so the running compositor still hands 1.5 to everything it spawns. Craig has to
+log out and back in.
+
+*** VERIFY Is CALIBRE_OVERRIDE_DPI still needed after the scaling fix?
+The same file pins =CALIBRE_OVERRIDE_DPI,96= with the comment "calibre renders
+oversized at the 1.57 compositor scale". Calibre is a Qt app, so that was almost
+certainly this same double-scaling seen through one application and worked
+around per-app rather than at the root. With the multiplier gone, the pin is
+probably redundant and may now render calibre too small.
+
+Left in place rather than removed on a guess, since it was validated at 96 on
+2026-06-27 and calibre has its own DPI handling. Worth opening calibre after the
+next login and deciding by eye.
+
+The cursor entry in the same file records this identical failure a third time:
+"Pre-scaling it (the old 36 = 24 x 1.5) double-applied on top of the
+compositor's scale." Three instances of one mistake in one file, two previously
+fixed in isolation without anyone naming the pattern.
+
+** TODO [#C] Waybar panels launch expanded instead of collapsed :bug:dotfiles:waybar:
+:PROPERTIES:
+:CREATED: [2026-08-19 Wed]
+:LAST_REVIEWED: 2026-08-19
+:END:
+
+From the roam inbox, Craig's words: "waybar panels should start up collapsed.
+currently both the left and the right waybar panels launch expanded."
+
+Panel source is =~/.dotfiles=. Its heading in the roam inbox read "archsetup."
+with a period rather than a colon, so the routing prefix did not match cleanly;
+claimed on the plain reading of the text.
+
+Grading: Cosmetic severity (presentation only, nothing is lost) x every user
+every time (every session start) = P3 = [#C].
+
+** VERIFY [#C] The visible analog clock avoids being dragged :velox:
+:PROPERTIES:
+:CREATED: [2026-08-19 Wed]
+:LAST_REVIEWED: 2026-08-19
+:END:
+
+From the roam inbox, captured verbatim: "the visible analog clock avoids being
+dragged. ask me about this."
+
+Filed as a VERIFY because the capture asks for a conversation rather than
+describing a defect. What is the clock avoiding being dragged by, and is the
+avoidance the bug or the intended behaviour?
+
+** TODO [#C] A failed hostname lookup takes seven seconds :bug:
+:PROPERTIES:
+:CREATED: [2026-08-19 Wed]
+:LAST_REVIEWED: 2026-08-19
+:END:
+
+=getent hosts fake-vm= takes about 7.2 seconds to return not-found on velox.
+Measured repeatedly with the cache flushed between runs. Anything that looks up
+a name that does not exist pays it: an ssh typo, shell completion, a script
+probing for a host.
+
+Not caused by the DNSSEC change. A/B measured today, cache flushed each time:
+7691ms and 7232ms on =allow-downgrade= against 6804ms and 7482ms on =yes=, so
+the setting makes no difference and this predates it. The likely shape is the
+tailnet search domain (=search tailf3bb8c.ts.net=) being tried first, then the
+two DoT upstreams, each with its own timeout, before NXDOMAIN comes back.
+
+Found because it blew a 20-second timeout in
+=tests.net-scenarios.test_run_net_scenarios=, which shells out to ssh a
+deliberately-bogus =root@fake-vm=. That suite passes on its own and the failure
+did not recur, so the timeout needed this latency plus the DNS disruption from
+the clock testing running alongside it. Worth knowing that the suite sits close
+enough to the edge for a slow resolver to tip it.
+
+Grading: Minor severity (nothing behaves wrong, it just waits) x some users
+sometimes (every failed lookup, which is occasional rather than constant) = P3 =
+[#C].
+
+** TODO [#B] Signal tray icon invisible under waybar (Electron 43 well-known-name SNI) :bug:waybar:velox:
+:PROPERTIES:
+:CREATED: [2026-08-25 Tue]
+:LAST_REVIEWED: 2026-08-25
+:END:
+Since signal-desktop 8.24.0 (Electron 43.4.0) Signal's tray item registers
+under a well-known bus name (=org.freedesktop.StatusNotifierItem-<pid>-1=)
+and answers Properties.Get/GetAll only when addressed by that name. waybar's
+GDBus proxy addresses the owning unique name instead, reads back no Id or
+Category, and logs "Invalid Status Notifier Item", so the icon never shows.
+With =--start-in-tray= that leaves Signal running with no window and no icon;
+launching it again from fuzzel raises the existing window. Slack (older
+Electron, unique-name registration) is unaffected. Measured 2026-08-25 with a
+bus monitor: the same connection returns the value for the well-known name
+and "error occurred in Get" for its own unique name.
+
+Grading: Major severity (the app is unreachable from the desktop while
+"running") × every user every time on velox = P1 by the matrix, held at
+[#B] because the workaround (relaunch to raise the window) is cheap and the
+fix is upstream.
+
+Upstream: [[https://github.com/Alexays/Waybar/issues/5240][Waybar #5240]] (open, proposes a raw-call fallback in item.cpp
+proxyReady) and [[https://github.com/signalapp/Signal-Desktop/issues/7992][Signal-Desktop #7992]] (open, "Upstream Change Needed").
+Downgrading to 8.23.0 is closed off: 8.24.x migrated the SQLCipher schema to
+1770 and 8.23.0 quits with DBVersionFromFutureError (tried and reverted
+2026-08-25).
+
+Re-test after a waybar or signal-desktop upgrade, from the repo root:
+#+begin_src sh :results output
+grep -c 'Invalid Status Notifier Item' "$(\ls -t ~/.local/var/log/waybar-*.log | head -1)"
+n=$(busctl --user list --no-legend | awk '$1 ~ /StatusNotifierItem-/ && $3=="signal-desktop"{print $1}')
+busctl --user call "$n" /StatusNotifierItem org.freedesktop.DBus.Properties Get ss org.kde.StatusNotifierItem Id
+busctl --user call "$(busctl --user call org.freedesktop.DBus /org/freedesktop/DBus org.freedesktop.DBus GetNameOwner s "$n" | cut -d'"' -f2)" /StatusNotifierItem org.freedesktop.DBus.Properties Get ss org.kde.StatusNotifierItem Id
+#+end_src
+Expected when fixed: 0 "Invalid" lines in a fresh waybar log, or both Get
+calls returning the Id (either side fixing it clears the icon).
+
+Ratio is on 8.21.0 and unaffected until its next upgrade brings 8.24.x.
+Alternatives if it drags on: change Signal's tray setting so it keeps a
+window (=~/.config/Signal/ephemeral.json= =system-tray-setting=), or run a
+waybar carrying the #5240 fallback.
+
+** TODO [#B] Truenas session-host VM for long-running agent sessions :feature:tooling:
+:PROPERTIES:
+:CREATED: [2026-08-13 Thu]
+:LAST_REVIEWED: 2026-08-13
+:END:
+A small VM on truenas (TrueNAS SCALE KVM) as the home for long-running /
+away-mode agent sessions. The case, per Craig 2026-08-13: truenas is the
+only machine on ethernet, so network recovery after an outage is automatic
+(wifi hosts may never reassociate unattended); it's UPS-backed through
+blip-to-hours outages; it has the Comet KVM for out-of-band recovery; and
+appliance uptime discipline means it doesn't reboot for workstation
+reasons. Tonight's live demonstration of anchor-staleness risk (39 min
+unlogged during a bare-metal recovery) is the motivating incident — the
+session's durability equals the anchor's lag at interruption, so a host
+that doesn't get interrupted is worth real money.
+Costs to engineer around: a third environment to keep synced (repos,
+rulesets, tailnet identity); credential provisioning — GATED on the
+secrets-repo work (the [#A] secrets task above): the VM should be the
+secrets bundle's second consumer after the personal ISO, not another
+hand-copied key sprawl; a firm RAM carve-out so builds don't fight the
+ZFS ARC; headless only — desktop-coupled sessions stay on ratio/velox.
+Build deliberately AFTER the vacation, not before Sunday.
+Companion idea (cheaper, complementary): put ratio on the UPS.
+** TODO [#B] post-rebuild-check: route every probe through one guarded helper :refactor:solo:
+:PROPERTIES:
+:CREATED: [2026-08-17 Mon]
+:LAST_REVIEWED: 2026-08-17
+:END:
+The script works and is well tested, but its shape keeps producing the same
+bug. Across three review rounds the reviewer found FOUR separate instances of
+"the probe failed and the check reported ok", each in a different place:
+=systemctl= in check 1, the enablement read in check 2, =find= in check 3, and
+=grep= in check 4. A fifth was latent in an unguarded staged write. Every one
+was individually fixed, and I only stopped finding more because someone kept
+looking.
+
+That is a design problem rather than four bugs. The script has five
+hand-written probes, and each one has to remember to branch on its own exit
+status. Nothing enforces it, nothing fails a review that forgets it, and the
+failure is invisible because the wrong behaviour is a clean "ok".
+
+Shape: one helper every probe must go through, which cannot return a value
+without an explicit success, so that "I could not read this" is
+unrepresentable as "nothing to report". Roughly:
+
+: probe "<what>" <command...> # sets a value on success, records a finding otherwise
+
+Then each check consumes the helper's result rather than a raw command
+substitution, and a new check written later inherits the discipline instead of
+having to re-derive it. Worth pairing with a test that asserts no check can
+report ok when its probe exits non-zero, generically, so the fifth instance is
+caught by the suite rather than by a reviewer.
+
+Not urgent: the current version is correct as far as anyone has found, ships
+with 58 tests, and proved itself on a genuinely wedged machine. This is
+prevention.
+
+Grading: Minor severity (no known live defect, the risk is future) x
+most-users-frequently (every future edit to this script) = P3 = [#C]... except
+the failure mode is silent and the script's whole job is catching silent
+failures, so a regression here is uniquely undetectable. P2 = [#B].
+
+:solo: — the surface is one script and its suite, the refactor is
+behaviour-preserving, and the existing 58 tests plus a mutation battery are
+the objective check that it stayed so.
+** TODO [#B] velox's systemd --user spins at 96% and cannot resolve unit files :bug:velox:
+:PROPERTIES:
+:CREATED: [2026-08-17 Mon]
+:LAST_REVIEWED: 2026-08-17
+:END:
+Live on velox 2026-08-17 from about 10:29. =systemd --user= (pid 2235) sits
+in state R at 96% CPU, measured over a 3-second sample rather than taken from
+the lifetime average. It stopped logging at 10:29, so its timers appear to
+have stopped firing too.
+
+The split is the diagnostic: =systemctl --user list-units= still returns
+instantly, while =is-enabled=, =cat=, =show=, and =list-unit-files= all hang
+indefinitely. So the manager answers from its in-memory unit list and wedges
+on anything that has to resolve unit files. It is spinning in userspace, not
+blocked on I/O (=/proc/2235/wchan= is 0, no syscall pending).
+
+Remedies tried, neither worked: =systemctl --user daemon-reexec= hangs like
+every other unit-file call, and the signal form (=kill -59=, SIGRTMIN+25)
+was accepted but changed nothing. The next step is a logout/login or reboot,
+which is Craig's call because it closes his running session. I deliberately
+did not kill the manager: that would tear down the graphical session and
+everything under it.
+
+Suspected cause is the powerprofilesctl crash loop filed above, whose
+repeated activation attempts against a masked unit are the only new load on
+this machine. I cannot prove it, and I have to name the other candidate
+honestly: my own =post-rebuild-check= runs called =systemctl --user
+is-enabled= roughly thirty times per run over several runs, and the wedge
+appeared during that window. The crash loop predates those runs by an hour
+and a half, which is why it is the leading suspect rather than the certain
+one.
+
+What it costs: unit-file operations are unavailable, user timers appear
+stopped, and a core is pinned on a laptop running on battery.
+
+Grading: Major severity (a pinned core and stopped user timers, invisible
+unless you look) x rare edge case (one machine, specific conditions) = P2 =
+[#B]... except that this is a live, ongoing drain on a travelling machine
+rather than a latent defect, so it takes [#A] until the machine is back to
+normal. Re-grade to [#B] once resolved and the question is only prevention.
+
+*** 2026-08-17 Mon @ 19:57:42 -0700 The reboot cleared it; re-graded [#A] to [#B] as the task instructed
+velox rebooted at 16:04. The wedge is gone: =systemctl --user is-enabled
+roam-sync.timer= now answers =enabled= in well under a second, where every
+unit-file call hung indefinitely before, and =list-timers= shows
+calendar-sync, roam-sync and agenda-render-cache all firing on schedule
+again. So the remedy the task named — a logout or reboot — was taken and
+worked.
+
+Nothing here was diagnosed further, which means the cause is still unproven
+and both candidates in the body stand. What is left is prevention, and the
+task's own grading says that is [#B]: the live-drain argument was the only
+thing holding it at [#A], and the drain has stopped. Re-graded per that
+instruction rather than by a fresh judgment.
+
+Reproducing it deliberately is the open question, and it is not obviously
+worth doing — it costs a wedged session to learn something the crash-loop fix
+may make moot.
+** TODO [#B] post-rebuild-check needs a reference-host mode :feature:velox:solo:
+:PROPERTIES:
+:CREATED: [2026-08-17 Mon]
+:LAST_REVIEWED: 2026-08-17
+:END:
+=scripts/post-rebuild-check= ships and works, but its first live run on velox
+2026-08-17 showed the output is mostly steady state rather than drift. Of the
+8 findings that survived three rounds of false-positive removal, comparing
+against ratio says exactly ONE is real: =obsbot-wb-guard= is enabled on ratio
+and merely linked on velox, which is the deliberate deferral recorded
+2026-08-16. The other three unit findings (=emacs=, =geoclue-agent=,
+=obs-record-watchdog.timer=) are linked on ratio too, and the three =.claude=
+absences are absent on ratio too.
+
+So the signal-to-noise is about 1:7, and the thing that separates them is a
+comparison against the other daily driver — the same discipline that kept the
+2026-08-16 session honest when check 4 read as nine projects missing
+=CLAUDE.md= and ratio turned out to be missing the identical files.
+
+Shape: =--reference-host <host>= runs the same five checks on the far machine
+over tailscale (ssh, read-only) and reports only the *differences*. Findings
+present on both machines are steady state and get summarized as a count rather
+than listed. Falls back to the current standalone behavior when the reference
+host is unreachable, and says so.
+
+Grading: Minor severity (the tool works and its findings are accurate; they
+are just buried) x every use = P3 = [#C]... except that a check nobody reads
+is a check that isn't run, which is the failure mode the whole task existed to
+close. Most-users-frequently x Major = P2 = [#B].
+
+:solo: — the checks exist, the ssh path is proven (the 2026-08-17 session ran
+exactly this comparison by hand), and correctness is verifiable locally by
+diffing the two reports.
+*** 2026-08-21 Fri @ 07:10:00 -0700 The premise moved: velox now reports 1 finding, not 8
+Re-scope before building. The 1:7 ratio this task argues from is gone, and two
+of the three things it cites as noise are fixed at the source rather than
+filtered.
+
+=3397463= gave check 2 a machine-local expected-disabled list, so the four unit
+findings are declared intent rather than noise, and an entry whose unit turns
+out to be enabled is itself reported so the list cannot rot. =3686500= dropped
+=.claude= from check 4's expected set, since the gitignore sweep writes that
+line into every project whether or not one exists. velox went 8 findings to 1.
+
+So the open question is no longer "how do we cut the noise" but whether a live
+reference-host diff still earns its place against a static declaration of
+intent. They are different tools: the list is offline, explicit, and states
+what a machine means; the diff is automatic and catches drift nobody declared.
+The reference-host comparison is still what *found* all of this, twice, by
+hand. That is an argument for it and not against.
+
+Worth knowing this task already contained the whole 8-to-1 analysis when it was
+filed 2026-08-17, and a session on 2026-08-20 re-derived it from scratch without
+reading it. Not :solo: any more — the design call above is Craig's.
+** TODO [#C] screen-lock test suite red on ratio :bug:test:dotfiles:
+:PROPERTIES:
+:CREATED: [2026-08-13 Thu]
+:LAST_REVIEWED: 2026-08-13
+:END:
+tests.screen-lock.test_screen_lock fails 21 of 23 (+1 error) on ratio,
+verified pre-existing with unrelated changes stashed (2026-08-13). Breaks
+the make test green bar for every dotfiles commit until triaged. Suspect
+environmental (the suite exercises hyprlock/relaunch behavior that may
+need session state this shell lacks) or a regression from a recent
+screen-lock commit — diagnose, then fix or mark/skip with a reason.
+Grading: Major severity (blinds the pre-commit gate for the whole repo) ×
+every-commit frequency on this machine, but test-infra only, no user
+impact = [#C] judgment call rather than matrix-dictated.
+** TODO [#C] Keyboard backlight binding + boot default :feature:dotfiles:velox:
+:PROPERTIES:
+:CREATED: [2026-08-13 Thu]
+:LAST_REVIEWED: 2026-08-13
+:END:
+Velox's kbd backlight (chromeos::kbd_backlight since the AMD board) boots
+at 0 and the dotfiles carry no keyboard-brightness keybinding at all (swept
+2026-08-13 — never existed). Add: Hyprland binds (XF86KbdBrightness* or a
+chord) driving brightnessctl -d "chromeos::kbd_backlight", a sane boot
+default, and a udev rule granting the video/input group write access so it
+works without sudo (a bare ssh session got EPERM). Check whether Fn+Space
+(EC-handled on Frameworks) already cycles it — if so the bind is a
+complement, not the only path. Ratio: n/a (desktop).
+** TODO [#B] Post-rebuild verification pass :feature:velox:solo:
+:PROPERTIES:
+:CREATED: [2026-08-14 Fri]
+:LAST_REVIEWED: 2026-09-17
+:END:
+A rebuilt machine looks finished and isn't. Five gaps surfaced on velox
+within two days of the 2026-08-13 reinstall, and three of them LOOKED
+fine: a stowed unit file, an enabled timer, a present git clone. From the
+.emacs.d handoffs 2026-08-14 (inbox, both PROCESSED) plus what this
+session found independently.
+The generalizable fix is one pass the installer runs at the end, or a
+=post-rebuild-check= script the checklist points at. Each item is cheap
+and turns a silent no-op into a visible line:
+1. =systemctl --user list-units --state=failed= — calendar-sync had been
+ failing every 15 minutes for two days with nobody watching.
+2. Every stowed/linked user unit that is NOT enabled. roam-sync and
+ signal-receive came back linked and inert; two others were never
+ linked at all. A unit file being present is not the same as running.
+3. Every tracked =*.local.el.example= (or =*.local.*=) with no sibling
+ real file. Three exist in .emacs.d; all three were gone on velox.
+4. Every gitignore-mode project missing its =.ai/=, =.claude/=,
+ =CLAUDE.md=, =todo.org=, =inbox/=. A reinstall drops the entire
+ working state of every such project — 374 files and 4.5 MB in
+ .emacs.d's case — and nothing carries it: not git, not stow, not the
+ bootstrap.
+5. =signal-cli listAccounts= non-empty. velox lost its registration, and
+ because agent-text relays to a hardcoded velox, that breaks the phone
+ channel for the WHOLE FLEET, not just this machine.
+6. =mbsync --list= parses. The Proton Bridge TLS cert
+ (=~/.config/protonbridge.pem=, referenced by =~/.mbsyncrc=) is generated
+ per *installation*, so it cannot be restored or copied between machines.
+ Its absence aborts the config parse, which kills *every* account — gmail
+ and dmail need no bridge and died anyway. The error names only the missing
+ pem, so "no mail at all" and "this one file is missing" look unrelated.
+ Re-derive it off the running bridge's own handshake, no GUI, no secrets:
+ =openssl s_client -connect 127.0.0.1:1143 -starttls imap -showcerts </dev/null | sed -n '/BEGIN CERTIFICATE/,/END CERTIFICATE/p' > ~/.config/protonbridge.pem=
+7. The bridge password (=~/.config/.cmailpass=) is per-install too. It is a
+ real file rather than a stow symlink, so it survived the rebuild holding
+ the *previous* install's value — worse than absent, because it looks
+ right. Diagnostic trap: the bridge answers a wrong password with =no such
+ user=, which reads as "no account signed in" and sends you hunting a login
+ problem that doesn't exist. Never treat =no such user= as evidence about
+ account state.
+
+*The distinction that organizes all seven* (from the .emacs.d handoff
+2026-08-14, inbox): every artifact that broke was generated on the machine by
+an application rather than carried by git, stow, or dotfiles. But they split
+two ways, and conflating them is what produces a file that exists, looks
+right, and authenticates against nothing:
+- *Restore* — the old value is still correct: gitignored tooling (1),
+ roam clone state (3), =*.local.el= configs (5).
+- *Re-derive* — the old value is worthless because the application minted a
+ new one: signal-cli registration (4), bridge cert (6), bridge password (7).
+So the checklist wants two columns, not one.
+
+Originally graded [#A] because item 5 was live and silently disabling paging, with
+a flight that Sunday forcing the date. Both inputs have expired: the flight was
+2026-08-17 and item 5 no longer gates anything time-boxed, so the 2026-09-17
+review dropped this to [#B] and unscheduled it. Kept here because the reason the
+grade was ever [#A] is worth knowing; it is not the current read.
+
+*** 2026-09-13 Sun @ 07:14:54 -0500 Moved the three gap reports into the reinstall working dir
+The 2026-08-14 reports that define the five gaps moved out of inbox/ into
+[[file:docs/design/2026-08-14-velox-reinstall-gaps-1.org][gaps 1-4]],
+[[file:docs/design/2026-08-14-velox-reinstall-gaps-2.org][gap 5]] and
+[[file:docs/design/2026-08-14-velox-reinstall-gaps-3.org][the two email-side gaps]] (the Bridge cert and the Bridge password).
+They file with the rest of the reinstall artifacts when that task closes.
+*** 2026-09-17 Thu @ 08:59:59 -0400 Re-graded [#A] → [#B]; gaps 6 and 7 are the remainder
+=scripts/post-rebuild-check= ships and covers gaps 1-5, with two [#B]
+follow-ups filed (the guarded probe helper and the reference-host mode). Both
+reasons for [#A] are gone: gap 5 is a check now, and the flight has passed.
+What the script still lacks is the two email-side gaps: =mbsync --list=
+parsing (the per-install Bridge cert) and the Bridge password (a real IMAP
+login against =127.0.0.1:1143=, never reading =no such user= as account
+state). Add those two, then close.
+
+** TODO [#B] Restoring a git repo from backup can resurrect a dangerous diff :bug:
+:PROPERTIES:
+:CREATED: [2026-08-14 Fri]
+:LAST_REVIEWED: 2026-08-14
+:END:
+My 2026-08-14 restore of =~/org= from the salvage brought back roam's
+=.git= deliberately ("simpler, preserves everything exactly"). It also
+brought back a clone ten commits stale AND an uncommitted =inbox.org=
+emptied to zero bytes. roam-sync is the repo's only committer and commits
+whatever it finds, so enabling that timer would have committed the
+emptying and pushed it — deleting the live inbox items ON RATIO. A
+.emacs.d session caught it, verified ratio's copy was a strict superset,
+discarded the local diff, fast-forwarded, and only then enabled the timer.
+Lesson to encode somewhere durable: restoring a git repo from a backup is
+not the safe option it looks like. For any repo with a live remote,
+re-clone and carry only proven-needed work; where a backup copy is
+restored anyway, reconcile it against the remote BEFORE any
+auto-committing timer is enabled. The failure here would have been silent
+and landed on a different machine.
+** TODO [#B] Nothing installs the .emacs.d systemd user units :bug:velox:
+:PROPERTIES:
+:CREATED: [2026-08-14 Fri]
+:LAST_REVIEWED: 2026-08-14
+:END:
+=~/.emacs.d/systemd/= ships four user units (agenda-render-cache
+service+timer, calendar-sync service+timer). On ratio they are symlinked
+into =~/.config/systemd/user/= by hand. Nothing does that on a fresh
+machine: they are not stowed (they live in .emacs.d, not dotfiles) and
+archsetup does not link them.
+Consequence found on velox 2026-08-14: the world wallpaper face drew
+nothing, because it reads =~/.cache/settings/agenda.json= and the timer
+that exports it was never installed. Calendar sync was silently dead for
+the same reason — which is the second time that particular timer has gone
+missing (see the 2026-08-01 session, where its auto-start was the bug).
+Linked and enabled by hand on velox; export verified (316 bytes, 1 event).
+Fix belongs in whichever owns the seam: either .emacs.d gains an install
+step for its own units, or archsetup links them alongside the dotfiles
+stow. Prefer the former — the repo that ships a unit should install it.
+Grading: Major severity (two background services silently absent, and the
+failure looks like a data problem rather than a missing timer) x every
+fresh install = P2 = [#B].
+** TODO [#C] Panel can leave a channel selected with nothing to show :bug:dotfiles:
+:PROPERTIES:
+:CREATED: [2026-08-14 Fri]
+:LAST_REVIEWED: 2026-08-14
+:END:
+velox's store carries channel "pair" with pair_sel unset, so
+channels.selected_pair() returns None and wallpaper.apply() fails every
+time. Found 2026-08-14 when the new session-start restore reported
+"unavailable" and fell through to the waypaper fallback — the machine
+still showed dark-lion, which looks exactly like the bug that was just
+fixed. ratio is fine (channel world, pair_sel 0).
+Two candidate fixes, needs a call: either the panel refuses to switch to a
+channel whose selection is empty, or apply() falls back to the first
+minted pair/set when the index is unset. The second is friendlier and
+matches "the store is the source of truth" — a channel with exactly one
+plausible reading should not be a dead end.
+Grading: Minor severity (one fallback still puts a wallpaper up) x some
+users sometimes = P3 = [#C].
+** TODO [#B] archsetup doesn't clone rulesets :bug:velox:
+:PROPERTIES:
+:CREATED: [2026-08-14 Fri]
+:LAST_REVIEWED: 2026-09-17
+:END:
+A fresh install has claude but no =ai=, no skills, no rules, no hooks,
+because =~/code/rulesets= is never cloned. Found on velox 2026-08-14 when
+=ai= wasn't on PATH. archsetup clones dotemacs, dotfiles, the suckless
+tools and itself, so rulesets is the one workstation repo it misses, and
+without it the whole agent tooling layer is absent on a rebuilt machine.
+Fix: clone it alongside the others (=RULESETS_REPO=, defaulting to
+git@cjennings.net:rulesets.git) and run =make install= afterwards, which
+is what links the 54 symlinks into ~/.claude and ~/.local/bin. Graded
+Major severity (a rebuilt machine silently loses every agent workflow)
+x most-users-frequently = P2 = [#B]. Worked around by hand on velox
+already; this is the durable half.
+** TODO [#B] Hibernate in the settings dial power actions :feature:dotfiles:
+:PROPERTIES:
+:CREATED: [2026-08-13 Thu]
+:LAST_REVIEWED: 2026-08-13
+:END:
+Add hibernate alongside suspend/lock in the settings module's dial power
+actions. The wlogout exit menu already carries it (keybind h) and needs no
+work; the dial is the remaining surface. Sequencing (Craig confirmed the
+dial placement 2026-08-13):
+1. DONE 2026-08-14 00:14 — hibernate proven end to end on velox, driven
+ from the exit menu so the wiring was exercised too. Evidence: the boot
+ id was unchanged across the cycle (f14152f9…) and uptime kept counting
+ 3h15m → 3h18m, so it genuinely resumed rather than rebooting; the
+ journal carries "PM: hibernation: hibernation exit" and the
+ HibernateLocation EFI variable being cleared. Took 9.3s wall. The whole
+ chain works: suspend-to-disk into the LUKS-encrypted swap, resume via
+ the keyfile embedded in the initramfs, one passphrase at ZBM.
+2. Then consider suspend-then-hibernate as the default lid behavior
+ (systemd sleep.conf HibernateDelaySec) — hibernate's savings with no
+ button at all; possibly a "deep sleep" toggle in the module.
+3. Then the dial action itself.
+Note: ratio has no swap partition, so hibernate stays velox-only until
+ratio gets one; the dial entry should degrade gracefully where there's no
+resume target.
+** TODO [#A] Move secrets out of public dotfiles → private repo + combined personal ISO :feature:security:dotfiles:
+:PROPERTIES:
+:CREATED: [2026-08-11 Tue]
+:LAST_REVIEWED: 2026-08-11
+:END:
+Structural fix for the root cause behind both 2026-08-09/10 leaks: secrets live
+in the *public* dotfiles repo and rely on in-place encryption, which two commits
+defeated. Design agreed with Craig and tabled before build 2026-08-11. Full
+detail + the pickup point are kept LOCAL (they map the setup, so not in this
+public repo):
+=.ai/private-design/2026-08-11-secrets-repo-and-combined-iso.org=.
+Shape in one line: move secrets to a private, off-public-scan-path repo, keep
+them encrypted even there (defense in depth), and deliver them to a fresh
+install as an encrypted bundle baked into a *personal* ISO — riding the combined
+archangel+archsetup ISO that's already ~80% built. Two ISO modes: generic
+(shareable) vs personal (encrypted secrets, private).
+[#A] because it closes a live-leak class, but *gated on the rotation VERIFY* —
+don't start the migration until the credentials are rotated. Not started.
+Not :solo: — repo standup and history rewrite are Craig's calls; promote to a
+real spec (spec-create) when work resumes.
+
+*Also bake the push-capable repo URLs into the personal ISO* (decided
+2026-08-19). =archsetup:240= and =:245= default =archsetup_repo= and
+=dotfiles_repo= to =https://git.cjennings.net/...=, the anonymous read-only
+endpoint. That default is right for a stranger installing archsetup — no key on
+the server — and wrong for my machines, which have to push: velox came back
+from its rebuild unable to push either repo, and I only found out at a 403 four
+days later. I decided against detecting an ssh key in the installer, because
+archsetup never restores =~/.ssh= (I do that by hand), so key-presence at clone
+time depends on ordering the installer doesn't control, and a naive "any key
+means ssh" would break a stranger who happens to have one. The override already
+exists and is documented — =ARCHSETUP_REPO= / =DOTFILES_REPO= in
+=archsetup.conf.example= — so the personal ISO just needs to carry the ssh
+form of both, alongside the secrets bundle. The generic ISO keeps the https
+default untouched.
+
+The gap that leaves is a curl|bash or stock-ISO install, which takes the https
+default straight back. =post-rebuild-check= check 8 covers that path — it flags
+a working repo whose origin is the read-only endpoint — so the ISO value is the
+fix and the check is the net under it.
+** TODO [#B] Settings toggles reset silently at session start :bug:dotfiles:
:PROPERTIES:
:CREATED: [2026-07-28 Tue]
-:LAST_REVIEWED: 2026-07-28
+:LAST_REVIEWED: 2026-09-17
:END:
Craig, from the roam inbox 2026-07-28: "launching into wayland doesn't honor previous caffeine settings ...or I expect any other settings in the desktop settings module." Captured right after the 08:59 reboot.
@@ -79,7 +2124,6 @@ Related: =[#B] Caffeine state is unreadable on both surfaces= covers display acc
Launched by hand afterward it runs fine and survives, so gammastep is not broken — it loses a race against compositor readiness at session start. Nothing relaunches it, so night light is simply off for the whole session, silently. (An earlier read of this said night light "has likely never worked from the config". That was wrong: the failure is a startup race, not a permanent break.)
Worth its own task — the fix is a readiness wait or a retry around that exec-once, not a persistence change. Filed here for now because it surfaced during this investigation.
-
** TODO [#C] Re-apply the active program at session start :refactor:dotfiles:hyprland:
:PROPERTIES:
:CREATED: [2026-07-30 Thu]
@@ -99,44 +2143,6 @@ Grading: Minor severity (no wrong behavior today; it's a latent drift that produ
:solo: — the design was settled when the fix was chosen, the surface is enumerable (=session.restore=, =programs.py=, =panel.apply_toggle=), and it is verifiable locally: the existing restore suite plus a test that a stored active program is reconstructed field by field after a simulated session start.
-** TODO [#A] Idle commits silently drop the screen-lock wrapper :bug:hyprland:dotfiles:security:
-DEADLINE: <2026-07-29 Wed>
-:PROPERTIES:
-:CREATED: [2026-07-29 Wed]
-:LAST_REVIEWED: 2026-07-29
-:END:
-Caught live 2026-07-29 05:30, seconds after it happened, while verifying that Craig's watch-stage change had landed.
-
-=idle.py= renders the *whole* hypridle.conf, including a hardcoded =GENERAL= block. That block said =lock_cmd = pidof hyprlock || hyprlock=. The live config said =|| screen-lock=. So every idle-stage commit through the panel rewrote =lock_cmd= and dropped the wrapper out of the chain.
-
-The wrapper is not incidental. It carries the flock duplicate guard (the fix for the 2026-07-27 unrecoverable wedge), the crash-relaunch watchdog, and the relaunch log. Parking one stage removed all three in a single write, and nothing said so.
-
-=tests/settings/test_settings.py:562= asserted the bare =|| hyprlock= form, so the suite *enforced* the regression. That is why 3845 tests stayed green through a day of work on exactly this subsystem. A test can pin the bug as readily as the fix.
-
-The false-negative this sets up is worth naming: with the wrapper gone the relaunch log stops receiving entries, and an empty log reads as "the problem is fixed" when it means "the instrument was removed". The =screen-lock= header already warns that an empty file is not proof; this is the mechanism that would have produced one.
-
-FIXED here, TDD, in the working tree pending commit:
-- =idle.py= =GENERAL= now names =screen-lock=, with a comment saying why the line is load-bearing.
-- The test now pins the wrapper form. Red first against the old template.
-- Live config rewritten through the panel's own path and hypridle restarted; =lock_cmd= confirmed back to =screen-lock=, one hypridle running.
-
-Grading: Critical severity (=write_conf= truncates, so any hypridle key the renderer does not model is silently deleted rather than preserved — that is configuration data loss, and the =lock_cmd= case proved it happens in the field) x some users sometimes (only when an idle stage is committed, which is rare) = P2 = [#B].
-
-An earlier draft graded this [#A] on a "security carve-out". That was wrong: disarming the guard is an availability problem, not a leak, and the carve-out is for privacy, security, compliance and safety. The severity band is what carries the weight here, and silent deletion of configuration is the =Critical= band's data-loss case.
-
-Two further fixes came out of an independent review of the first one:
-
-- *Fail-open restored.* =pidof hyprlock || screen-lock= made the wrapper the end of the chain, and =screen-lock= is a stow symlink in =~/.local/bin=, not a system binary. An unstowed tree, or a hypridle started without =~/.local/bin= on PATH, resolves it to 127 — so the screen would never lock *at all*. That is worse than the duplicate client the wrapper prevents. The chain now ends =|| hyprlock=, matching the wrapper's own fail-open discipline.
-- *The file now says it is generated.* Three comment lines at the top of the rendered output name the renderer and warn that edits are overwritten. The absence of that header is how the divergence survived unnoticed.
-
-Still open, and why this stays a task rather than closing with the fixes: the header warns, but nothing *prevents* the next divergence, and the exposure is wider than =lock_cmd= alone. The review enumerated it:
-
-- =before_sleep_cmd= and =after_sleep_cmd= sit in the same hardcoded block, at identical risk.
-- Every stage command is hardcoded in =_stage_commands= (brightness level, lock, watch, dpms, suspend), same one-way overwrite.
-- =write_conf= *truncates* rather than merges, so any hypridle key the renderer does not know about (=ignore_dbus_inhibit=, =ignore_systemd_inhibit=, =inhibit_sleep=, =on-lock=, =on-unlock=) is deleted rather than preserved. That is the largest hole: a key nobody has added yet would vanish the first time a stage is parked.
-
-Options: have the renderer preserve the existing general block and unknown keys instead of emitting its own, or accept the template as the single source and move every hypridle setting into the panel. A design call for Craig, and the truncation half is the part that will bite next.
-
** TODO [#B] "SCREEN OFF" reads as a state, not a stage :bug:dotfiles:design:
:PROPERTIES:
:CREATED: [2026-07-29 Wed]
@@ -156,11 +2162,10 @@ An earlier draft graded this Minor, arrived at [#C], and then wrote [#B] beside
Not :solo: — naming is Craig's taste call, and the rail's vocabulary should be decided as a set.
-** TODO [#A] Night watch and the lock watchdog fight each other :bug:hyprland:dotfiles:
-DEADLINE: <2026-07-31 Fri>
+** TODO [#B] Night watch and the lock watchdog fight each other :bug:hyprland:dotfiles:
:PROPERTIES:
:CREATED: [2026-07-29 Wed]
-:LAST_REVIEWED: 2026-07-29
+:LAST_REVIEWED: 2026-09-17
:END:
ROOT CAUSE of the lockdead screens, found 2026-07-29 00:50 within minutes of the relaunch logging going live. hyprlock is not crashing. It is being killed on purpose, by us.
@@ -171,6 +2176,12 @@ ROOT CAUSE of the lockdead screens, found 2026-07-29 00:50 within minutes of the
The collision, on hypridle's own timings:
- t=450s: =loginctl lock-session= → screen-lock → hyprlock up, watchdog waiting on it.
- t=480s: =settings-watch start= maps the kiosk and kills hyprlock. Exit 143.
+
+*Two relaunches after the mitigation, unexplained (found 2026-08-04).* The watch stage was parked in =db5ac60= at 2026-07-29 05:59, and nine of the eleven entries in =screen-lock.log= fall before that (07-28 19:00 through 07-29 05:08, all rc=143, the collision as diagnosed). But two more landed on *2026-07-30 10:58:11 and 10:58:13* — rc=143 then rc=137, SIGTERM then SIGKILL, two seconds apart. Craig unlocked the keyring 30 seconds later.
+
+So parking the stage did not stop every relaunch. Either something else kills hyprlock, or that pair was Craig at the keyboard. The log cannot distinguish those, and the journal for that window shows only an unrelated settings tick.
+
+What would settle it: the watchdog does not record *who* sent the signal, only the exit code. Logging the killer (or at least distinguishing a session-initiated unlock from an external kill) would turn this from a guess into a reading. Worth doing before designing the handshake, since the handshake assumes the night watch is the only thing killing hyprlock and that assumption is now unproven.
- t=480s + =LOCK_RELAUNCH_DELAY= (0.5s): the watchdog relaunches hyprlock, which comes back *on top of* the night watch.
- In that half-second gap the session is locked with no client, so Hyprland draws lockdead. The replacement hyprlock then blurs it.
@@ -196,11 +2207,15 @@ Not :solo: — the fix is a design decision between two subsystems, both of whic
Option 2 is the one I would argue for, but it is Craig's call.
Previous title and framing of this task, kept for the record: "hyprlock still exits mid-lock; the watchdog relaunch is silent". The instrumentation that closed that gap is dotfiles =5bbe2c3=, and it paid for itself in about six hours.
+*** 2026-09-17 Thu @ 08:59:59 -0400 Re-graded [#A] → [#B] and dropped the 07-31 deadline
+The grading paragraph above already concluded [#B]; the heading never followed
+it. The watch stage was parked in =db5ac60= on 2026-07-29, so the collision is
+mitigated and the choice between the three fixes has no date pressure.
** TODO [#B] The wireguard gpg convention is inert; the installer can't read it :bug:security:network:
:PROPERTIES:
:CREATED: [2026-07-28 Tue]
-:LAST_REVIEWED: 2026-07-28
+:LAST_REVIEWED: 2026-09-17
:END:
Found 2026-07-28 by an independent review, while deciding whether to commit a newly-encrypted =wolf.conf.gpg=.
@@ -221,10 +2236,10 @@ Until it is resolved, do not commit any =*.conf.gpg=. The encrypted =wolf.conf.g
Related: =[#B] Move archsetup off cgit= and =[#B] Audit cgit-published repos for secrets and privacy=. Both are still open, and both argue for keeping new secrets out of this repo until they land.
-** TODO [#B] Timer presets should start in one click :feature:dotfiles:timer:
+** TODO [#C] Timer presets should start in one click :feature:dotfiles:timer:
:PROPERTIES:
:CREATED: [2026-07-28 Tue]
-:LAST_REVIEWED: 2026-07-28
+:LAST_REVIEWED: 2026-09-17
:END:
Craig, captured 2026-07-28, routed here by home's inbox-zero pass from the shared roam inbox.
@@ -237,34 +2252,14 @@ Panel source is =~/.dotfiles/timer/src/timer/= (=gui.py= for the view, =panel.py
Not :solo: — the rearrangement is described but not settled, and the result is a visual judgment Craig has to see. The one-click behaviour is buildable on its own; the layout wants a pass in front of him. Per the UI-prototyping rule, sketch the arrangement before touching production code.
Related: =[#C] Add a time selector to the timer panel= covers a duration picker for the same input area. Design them together when either is picked up.
-
-** TODO [#A] Comet KVM setup for truenas :feature:infra:truenas:
-DEADLINE: <2026-08-02 Sun>
-:PROPERTIES:
-:CREATED: [2026-07-27 Mon]
-:LAST_REVIEWED: 2026-07-27
-:END:
-Wire up the GL.iNet Comet (GL-RM1) IP KVM against truenas. It was bought 2026-01-14 for exactly this job and its KB node still reads "Arrived, not yet set up."
-
-Why now: truenas went dark 2026-07-24 and stayed unreachable. Diagnosis from ratio on 2026-07-27 — no tailnet contact for 3 days, 100% packet loss on 192.168.86.5, ARP entry FAILED (nothing answers ARP for the address, so the NIC is down at layer 2), every service port closed, while the gateway and a dozen other LAN hosts stayed reachable. Wake-on-LAN to 70:85:c2:db:9d:94 drew no response. With no console and no out-of-band power control there was no remote remedy at all, so recovery needed hands on the box. The Comet closes exactly that gap: BIOS/UEFI console, Wake-on-LAN, and browser access over its native Tailscale integration.
-
-Not :solo: — the physical cabling is Craig's, and the Tailscale enrollment needs his account.
-
-Steps, from the KB node ([[id:67bc5994-a763-48e2-926f-4ac0d1bad3db][GL.iNet Comet (GL-RM1) - KVM]]):
-1. HDMI from truenas video out to the Comet's HD IN.
-2. USB-A-to-USB-C from the Comet to a truenas USB port (keyboard/mouse emulation).
-3. Ethernet to the network.
-4. Power via USB-C (5V/2A).
-5. Reach the web interface and enroll it in Tailscale, so it's usable when the LAN side of truenas is the thing that's broken.
-
-Then verify while truenas is healthy, rather than discovering the gaps during the next outage: confirm the console shows POST and the BIOS, that keyboard input reaches the box, and that Wake-on-LAN from the Comet actually powers it on. Enable WOL in the truenas BIOS if that last check fails — this outage never established whether it was on.
-
-Worth considering as a follow-up: the ATX power board accessory gives hard power-cycle control for a truly wedged box, which the KVM alone can't do.
+*** 2026-09-17 Thu @ 08:59:59 -0400 Re-graded [#B] → [#C]
+Untouched for seven weeks, and the layout wants a prototype pass in front of me
+before any production code.
** TODO [#C] Post-upgrade hooks: compositor restart reminder + font cache rebuild :feature:infra:ratio:solo:
:PROPERTIES:
:CREATED: [2026-07-25 Sat]
-:LAST_REVIEWED: 2026-07-25
+:LAST_REVIEWED: 2026-09-17
:END:
Handoff from home (2026-07-25), originally combining the 2026-06-07 stale-compositor incident and 2026-06-08 fontconfig crash diagnosis. Add two reproducible pacman =PostTransaction= hooks through archsetup; do not make one-off =/etc= edits:
@@ -272,16 +2267,10 @@ Handoff from home (2026-07-25), originally combining the 2026-06-07 stale-compos
2. On =Upgrade= of =fontconfig=, =freetype2=, or =harfbuzz=, run =/usr/bin/fc-cache -f= after the transaction. The fontconfig 2.17→2.18 cache-format change left stale cache-9 files that crashed Qt6 apps in =FcCharSetHasChar= until the system font cache was rebuilt.
Acceptance: hook files are source-controlled and installed by archsetup; package/operation/action fields are asserted from the generated hook text; the reminder is print-only and exits successfully; the font hook runs only after successful matching upgrades and invokes the absolute =fc-cache= path. Validate with the fast installer tests plus a disposable pacman-hook parser/install check when practical.
-** TODO [#D] net-scenarios harness times out under back-to-back suite runs :test:tooling:
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-24
-:END:
-=tests/net-scenarios/test_run_net_scenarios.py= errored on all 5 tests twice during round 12, each time =subprocess.TimeoutExpired= after its 20s budget on =scripts/testing/run-net-scenarios.sh --target root@fake-vm=. Both occurrences were in =make test-unit= runs launched immediately after a previous full run. It then passed 6 runs in a row (3 on a pristine tree, 3 with the round-12 change), and standalone it finishes in 0.09s, so this is not a regression from any code change.
-
-The harness stubs =ssh=, =rsync= and =jq= onto =PATH=, so nothing should touch the network at all — which is what makes a 20s timeout suspicious rather than merely slow. Worth reproducing under load before deciding whether the fix is a larger timeout or a real hang in the script. Evidence logs from the round: =/tmp/tu.log= and =/tmp/tu2.log= (tmpfs, gone after reboot).
-
-Not graded on the bug matrix: it is test infrastructure, not the shipped codebase.
-
+*** 2026-09-17 Thu @ 08:59:59 -0400 Lead, not diagnosed: ratio's Hyprland aborted on 08-28
+ratio logged a Hyprland SIGABRT coredump at 2026-08-28 15:44, three days after
+the 714-package upgrade of 08-25. Check whether it's the stale-compositor shape
+from 06-04 before citing it as a second occurrence.
** VERIFY Should coredump entries group as one journal-digest row per binary? :maint:
:PROPERTIES:
:LAST_REVIEWED: 2026-07-24
@@ -295,63 +2284,179 @@ What's off is the *grouping* for multi-line messages. Thirteen =systemd-coredump
Consequence is modest: the digest's top-N rows get eaten by near-duplicates, so genuinely distinct errors fall off the evidence list sooner. It doesn't affect the metric's value or severity.
The question is what you'd want: group coredumps by the binary named in the first line (a special case for =systemd-coredump=), signature only the *first line* of any multi-line message (a general rule, and arguably the right one — the first line is the error, the rest is context), or leave it alone. The middle option is the smallest general change and I'd lean that way, but it changes grouping for every multi-line error, so it's yours to call.
-** TODO [#B] World face: orientation + hour-format pickers in the settings panel :feature:dotfiles:
-The world face (=settings/faces/world.html=, shipped 6afbe09) already supports both orientations and 12/24-hour via =?layout= and =?hour12= query params, defaulting vertical/12h. What's missing is letting Craig CHOOSE them from the desktop-settings panel. Build: (1) two new fields in the wallpaper state (world_layout, world_hour12) with the vertical/12h defaults; (2) =project.py build_uri("world")= appends =&layout=&hour12== read from state; (3) panel controls in the world channel's config section (=gui.py _conf_projected=, currently just a preview) — an orientation toggle and a 12/24 toggle; (4) TDD the URI-building and state round-trip. Solo — buildable, agent-verifiable (URI + state tests, headless render), no open design question (the two faces already exist and are approved). Requested by Craig 2026-07-23; deferred so the vertical face could ship first.
+** TODO [#C] World face: hour-format picker in the settings panel :feature:dotfiles:solo:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-08-02
+:END:
+Put a 12/24-hour toggle for the world face in the desktop-settings panel. The knob already exists — =settings/faces/world.html= reads =hour12= from =SETTINGS= with a =?hour12== URI override, defaulting to 12 — but the only way to change it is to hand-edit a launch URI, which is not a way to change anything.
+
+Build: (1) a =world_hour12= field in the wallpaper state, default true; (2) =project.py build_uri("world")= appends =&hour12== read from state; (3) a 12/24 toggle in the world channel's config section (=gui.py _conf_projected=, currently just a preview); (4) TDD the URI-building and the state round-trip. Solo — buildable, agent-verifiable through the URI and state tests plus a headless render, and nothing left to decide.
+
+Re-scoped 2026-08-02, down from [#B]. As written on 2026-07-23 this also wanted an orientation picker, because the face then supported vertical and horizontal through =?layout==. The 2026-07-31 rebuild replaced that with the single vertical spine and its horizontal day axis, and no =layout= flag survives in =world.html= — so the orientation half has nothing left to pick between and is dropped rather than deferred. One toggle is parking-lot work, not this cycle's, hence [#C].
+
+Not =:quick:= despite being small: four pieces with tests is a sitting rather than a spare moment. I said "probably quick" when recommending the re-scope and that was optimistic — the piece count didn't drop, only the field count within each.
** TODO [#C] Wallpaper channel: timed transitions as an alternative to sunrise/sunset :feature:dotfiles:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-08-02
+:END:
From the roam inbox (Craig, claimed 2026-07-23): the wallpaper channel switches on sunrise/sunset today (the sun-pair mode, =settings/src/settings/wallpaper.py=, location read live via whereami with a state.json cache). Add a timed-schedule mode as an alternative: fixed clock times drive the transitions rather than the solar calc.
Not :solo: — the capture itself flags the missing inputs ("we'll need to know the transition times, and how many of them there are"). The count and the times are a design decision Craig owes: is it a two-image day/night flip at fixed hours, an N-way ring across the day, per-image dwell vs shared interval? The =set= channel already does fixed-interval cycling through a set, so the new part is specifically clock-anchored transition points, not just "a timer". Ask for the schedule shape at pickup, then build against the existing wallpaper.apply presenter vocabulary.
-** TODO [#D] Worldclock tooltip blanks on one bad timezone row :bug:dotfiles:waybar:quick:solo:
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-25
-:END:
-Found by sentry (2026-07-25), verified by exercising. =hyprland/.local/bin/waybar-worldclock= builds each zone with =ZoneInfo(tz)= inside the loop (line ~99) with no guard, so a single malformed timezone row in =worldclock.conf= raises =ZoneInfoNotFoundError= and crashes the whole python pass. The tooltip then renders empty and *every* zone is lost, not just the bad row; the traceback only reaches stderr, where waybar never surfaces it.
-Repro: a conf with =America/Chicago|Home=, =Not/AZone|Bad=, =Europe/London|London= renders =tooltip: ""= (Home and London gone too).
-Grade: minor severity (one module's tooltip blanks, no data loss) x rare edge case (a malformed conf row) = P4 = [#D].
-Fix: wrap the per-row =ZoneInfo=/=datetime= in a try/except and =continue=, so a typo drops only that row and the valid zones still render. Solo + quick: the script already has an env-override test harness (=WAYBAR_TIME_EPOCH=, =WAYBAR_WORLDCLOCK_CONF=), so a red-first test is cheap.
-** TODO [#C] Auto-dim status forgotten on layout change :bug:dotfiles:
+** TODO [#C] Auto-dim status forgotten on layout change :bug:dotfiles:solo:
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-25
+:LAST_REVIEWED: 2026-09-17
:END:
From the roam inbox (Craig, 2026-07-25). If auto-dim is toggled off and the layout then changes, auto-dim silently comes back on. A layout switch should not touch the auto-dim state. Likely related to the 2026-07-25 =layout-cycle= rebuild (floating ring) or a hook it fires — check whether the layout-change path resets the dim toggle, and where auto-dim state lives. Grade: minor severity (dim re-enables unexpectedly, no data loss) x every layout change made while dim is off = P3 = [#C].
-** TODO [#C] Maint queue button status wall needs a copy button :feature:dotfiles:maint:
+** TODO [#C] Maint queue button status wall needs a copy button :feature:dotfiles:maint:quick:solo:
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-25
+:LAST_REVIEWED: 2026-09-17
:END:
From the roam inbox (Craig, 2026-07-25). The maintenance queue button shows the status wall but has no copy button. Add one, following the global COPY key pattern already on the maint doctor wall (dotfiles =8bc79ba=). Grade: cosmetic/feature = [#C].
** TODO [#B] Panel family: unify the look across net/bt/maint/audio and desktop-settings :feature:design:dotfiles:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-08-02
+:END:
From the roam inbox (Craig, claimed 2026-07-24): the network, bt, maint, and audio waybar panels look alike, but the desktop-settings panel looks quite different. He wants them to read as one family. Deliverable: enumerate every difference (chrome, header layout, typography, spacing, control styling, color roles, close-button placement, section dividers) between the two groups and a plan to converge them on one look. Not :solo: — it needs a design pass and Craig's taste calls on which direction each group moves. When picked up, catalogue the deltas from live captures of all five, propose the shared design language (likely the Dupre instrument-console the settings panel uses, since that's the newest and most deliberate), then bring Craig the change list before touching code.
-** TODO [#C] Panel text cut off — needs a few px more space :bug:dotfiles:solo:
+** TODO [#C] Panel text cut off — needs a few px more space :bug:dotfiles:quick:solo:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-08-02
+:END:
From the roam inbox (Craig, claimed 2026-07-24): panel labels look cut off; a few more pixels of space fixes it. He named the audio and bt panels, but his "before" capture is the networking panel (=~/pictures/screenshots/2026-07-23_202419.png=; "after" resizing =~/pictures/screenshots/2026-07-23_202458.png=), so the whole panel family likely shares the tight spacing. Confirm which panels clip at pickup, then add the padding/width. Grade: cosmetic × every glance at the affected panels = P3 = [#C]. Solo — buildable (CSS/size tweak) and screenshot-verifiable, no design call once the clipping panels are identified.
** TODO [#C] Night-watch live telemetry :feature:maint:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-08-02
+:END:
Craig, 2026-07-21 ("mind. blown."): drive the Dupre Night Watch screensaver (docs/prototypes/2026-07-21-night-watch-screensaver-prototype-1.html, the idle-pipeline eye-candy stage in the desktop-settings spec) with real system data instead of synthetic signals — a passive status wall while the machine idles. Candidate mappings: scope = CPU load trace, drift chart = memory pressure history, spectrum = per-core utilization, VU pair = net throughput up/down, blinkenlights = disk I/O, tape counter = uptime, systems lamps / annunciator = maint status verdicts, engine-order telegraph = current power profile, VFD wire = maint status one-liner (temps, battery, pending updates). Browser prototype can poll a small local JSON endpoint; the production shape belongs to the idle-stage build. Depends on the spec's idle-pipeline implementation landing first.
** TODO [#C] Wlogout screen review :bug:hyprland:dotfiles:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-08-02
+:END:
Craig, 2026-07-21: the wlogout window (Super+Shift+Q — lock/reboot/shutdown/logout/suspend/hibernate) "isn't great and has bugs." Review it end to end: catalogue the specific bugs, then assess the design against the Dupre instrument-console family (it predates the panel aesthetic). Config lives in dotfiles; the bind is hyprland.conf:428 (=pgrep -x wlogout || wlogout-menu=). Context: the desktop-settings panel spec withdrew lock/suspend in favor of this screen (2026-07-21 amendment), so it's now the sole owner of session-exit actions — worth being good. Grade each bug found via the severity×frequency matrix; this parent stays a [#C] review until specifics emerge.
-** TODO [#B] Audit cgit-published repos for secrets and privacy :bug:security:
+** TODO [#A] Audit cgit-published repos for secrets and privacy :bug:security:
+SCHEDULED: <2026-09-24 Thu>
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-21
+:LAST_REVIEWED: 2026-09-17
:END:
-Grading: security carve-out — cgit at git.cjennings.net serves every repo under scan-path=/var/git over unauthenticated https (any repo is anonymously cloneable). archsetup being public is by design (curl-install), but this means ANY secret in ANY /var/git repo is world-readable, and any repo meant to be private is not. Severity depends on what else lives there = P2 = [#B], raise if a private repo with secrets is found.
-Not :solo: — needs Craig's decisions. Steps: list repos under /var/git; for each, decide intended public vs private; scan each for secrets (keys, tokens, credentials) the way this WireGuard leak was found; for any meant-to-be-private repo, actually restrict access (cgit repo.hide only hides from the index — a known repo name is still cloneable; use http auth or move it off the public scan-path); for public repos, confirm no secrets and add a pre-receive/CI secret scan. Check dotfiles (git.cjennings.net/dotfiles) specifically — it is also under /var/git. archsetup's own move is decided and tracked separately below.
+Grading: security carve-out — cgit at git.cjennings.net serves every repo under scan-path=/var/git over unauthenticated https (any repo is anonymously cloneable). Raised [#B] → [#A] on 2026-08-09: the scan found a real live-credential leak (below), so this is now a confirmed exposure with an open rotation blocking, not a hypothetical. Drops back to [#B] once rotation is done and the visibility rulings are made.
+Not :solo: — needs Craig's decisions and the credential rotation. Steps: list repos under /var/git; for each, decide intended public vs private; scan each for secrets (done, below); for any meant-to-be-private repo, actually restrict access (cgit repo.hide only hides from the index — a known repo name is still cloneable; use http auth or move it off the public scan-path); for public repos, confirm no secrets and add a pre-receive/CI secret scan. archsetup's own move is decided and tracked separately below.
+*** VERIFY [#A] Rotate the credentials exposed by the 2026-08-09 dotfiles leak
+SCHEDULED: <2026-09-24 Thu>
+A plaintext credential file was briefly public in the dotfiles repo and was
+confirmed pulled by an external crawler before the purge, so every credential
+in it must be rotated. Full list, forensic detail, and the remediation record
+are kept LOCAL, not in this public repo:
+=.ai/private-design/2026-08-09-cgit-secrets-audit.md=. A second, low-severity
+unencrypted token was also exposed and needs a re-auth + purge (same doc).
+Craig's action; the purge only stopped further copies. Desktop alarm set
+(=at= job 61, 15:00) as the backup nudge.
+*** 2026-08-09 Sun @ 12:05:00 -0500 Found + purged a public plaintext-credential leak
+A file that looked encrypted by its name was plaintext in one commit; a
+second, content-verifying triage caught it. Purged from history in both repos
+(the shareable dotfiles and this one) and reconciled the local clones; a fresh
+anonymous clone no longer serves it. Everything else the audit flagged was
+benign. The forensics, exact mechanism, and remediation steps are in the local
+doc above (not published, since they map the setup). Follow-ons: the rotation
+VERIFY above, velox reconcile on return, the secrets-repo split (top of Open
+Work), the wireguard =.gitignore= bug (line ~191), the cgit move (below), and a
+pre-receive secret-scan hook so this can't recur.
+*** 2026-08-17 Mon @ 19:57:42 -0700 Moot — the 08-13 wipe re-cloned velox from the rewritten remotes
+This asked velox to reconcile clones that no longer exist. The machine was
+wiped and reinstalled on 2026-08-13, so every repo on it was cloned fresh
+*after* the purge and never held the pre-rewrite history at all. The runbook
+anticipated this ("fresh clones automatically carry the post-purge rewritten
+git history"); nobody closed the task once the reinstall took that route.
+
+Verified rather than assumed: both repos are level with =origin/main= today —
+archsetup at =41fea69=, dotfiles at =65940f2=, both trees clean.
+
+One thing the reinstall did leave, and it is filed separately: the installer
+cloned both repos =--depth 1=, so the history was present-but-truncated until
+today's =git fetch --unshallow= (see the shallow-clone =[#A]=). A reconcile
+against the rewritten remote was still unnecessary — a shallow clone of the
+right history is not a diverged clone of the wrong one.
+
+*** 2026-10-06 Tue @ 06:08:06 -0600 Scrubbed the Signal pager number from this repo's history
+The agent's Signal identity number sat in two tracked files from 08-17
+(a post-rebuild-check fixture) and 09-13 (a filed design note). A
+working-tree redaction landed on 10-05, but the number stayed reachable in
+history for anyone cloning over cgit, so I rewrote it out: =git filter-repo
+--replace-text= in a fresh clone, verified by content search, diff search and
+a grep over every revision (all zero) and by a byte-identical HEAD tree, then
+a force-push of main, a server-side reflog expire and =gc --prune=now= on the
+bare repo (the old head object is gone), and a hard reset of the velox and
+ratio clones, each followed by a reflog expire and =gc --prune=now= so the
+old objects are gone there too. 59 of 951 commits changed SHA, every
+descendant of the 08-17 post-rebuild-check commit; tag v0.5 predates it and
+is unchanged.
+The old-to-new map is =assets/2026-10-06-history-rewrite-commit-map.txt=,
+and the citations in this file, the task archive, the topgrade spec and the
+KB nodes are remapped. Session logs keep the old SHAs; use the map.
** TODO [#B] Move archsetup off cgit to cjennings@cjennings.net :chore:security:
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-21
+:LAST_REVIEWED: 2026-08-17
:END:
Decided (Craig, 2026-07-20): move the archsetup repo off the public cgit host (git@cjennings.net, scan-path /var/git) to Craig's private account remote cjennings@cjennings.net, so it is no longer world-cloneable. This is the archsetup-specific fix for the cgit-exposure finding above.
Plan: create a bare repo under cjennings's control off the cgit scan-path (e.g. =~cjennings/git/archsetup.git=); push current main + tags there; migrate the post-receive hook that publishes the installer to =/var/www/cjennings/archsetup= so curl-install keeps working (the single published file stays public by design; only the repo goes private); update the origin remote on ratio and velox to =cjennings@cjennings.net:git/archsetup.git=; remove =/var/git/archsetup.git= so cgit no longer serves it. Verify: anonymous =git clone https://git.cjennings.net/archsetup.git= fails, the new private clone works from both machines, and the curl-install URL still returns the installer. Keep the two daily drivers' remotes in sync (daily-drivers rule).
+
+*** 2026-08-17 Mon @ 19:57:42 -0700 Re-checked: unstarted, and the exposure is confirmed live
+Ran the task's own verification step as it stands today, which is the honest
+way to check an unstarted task rather than reading its body back. Anonymous
+=git ls-remote https://git.cjennings.net/archsetup.git= succeeded with no
+credentials and returned =41fea69= — this afternoon's HEAD. So the repo is
+still world-cloneable and current to the commit, not a stale published
+snapshot.
+
+=origin= on this machine is still =git@cjennings.net:archsetup.git=, the cgit
+account, so nothing has moved. Everything in the plan stands unchanged.
+
+*** 2026-08-21 Fri @ 14:12:46 -0700 Recorded the publication mechanism: placement is the only control
+The work project verified its own repo reads "not served" against a control
+repo that reads PUBLIC, and reported the mechanism back: the host publishes via
+=GIT_HTTP_EXPORT_ALL= over =GIT_PROJECT_ROOT=/var/git=, so *publication is
+directory placement and nothing else* — there is no per-repo marker, no
+=git-daemon-export-ok= file, no opt-in flag to check. A repo is public because
+of where it sits.
+
+That is the durable hazard for this task's plan, and it cuts both ways. It
+confirms the approach — a bare repo created outside the scan-path is private by
+construction, which is exactly what the plan already specifies. It also means
+nothing in a repo itself records whether it is exposed, so any future move
+*into* =/var/git= publishes silently, with no local artifact to notice. Their
+own repo is private for this reason alone: it lives under =/var/cjennings/git/=,
+outside the served root.
+
+Caveat they raised and I agree with: any enumeration of the served set is a
+snapshot, not a standing fact. The set moved twice while three projects were
+measuring it. Verify placement at the time of the move rather than trusting a
+recorded list.
** TODO [#B] Velox boot-failure retrospective — upgrade guard gaps :bug:zfs:maint:
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-21
+:LAST_REVIEWED: 2026-08-26
:END:
Post-mortem for the 2026-07-15 velox no-kernel boot failure, from the archsetup/maint code review:
- maint's UPDATE remedy runs a plain =yay -Syu --noconfirm= (remedies.py:297). The live-update guard (guard.py) only matches mesa/hyprland (the 2026-06-07 live-swap class) — it never checks /boot, kernel, initramfs, or mkinitcpio exit. No post-upgrade /boot assertion exists. An interrupted kernel transaction slips straight through.
- Add a post-upgrade /boot assertion: after a transaction touching linux/linux-*, confirm vmlinuz-* + initramfs-*.img present and mkinitcpio exit 0; refuse to end the run (or page Craig) otherwise. Would have caught this.
- Sanoid-vs-actual dataset drift: configure_zfs_snapshots configures zroot/var/log + zroot/var/lib/pacman as separate datasets; velox's actual layout has neither separate (/var/log sits inside zroot/var). Reconcile.
- CONFIRMED (2026-07-21): the pre-pacman snapshot hook fired on velox — the 2026-07-15 no-kernel boot was recovered via the pre-pacman ZFS snapshot rollback, and velox is back on the tailnet running linux-lts 6.18.38 with initramfs present (2026-07-19 session). Root-cause hook-ordering fix shipped separately. Still open: the post-upgrade /boot assertion in guard.py and the sanoid-vs-actual dataset drift reconcile (the two bullets above).
-
-** TODO [#B] Assess a Hyprland left-drag window gesture :feature:hyprland:
+*** 2026-08-26 Wed @ 22:35:01 -0600 The /boot assertion now lives in the topgrade spec; the dataset drift is what remains here
+The post-upgrade /boot assertion is covered by the kernel-modules-check gate in
+[[file:docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org][the topgrade guarded-upgrade spec]]
+(dkms built for the new kernel, initramfs newer than vmlinuz, pre-pacman
+snapshot on a ZFS root), which ships with that spec's Phase 1 rather than here.
+What this task still owns is the sanoid-vs-actual dataset drift: whether to
+split zroot/var/log and zroot/var/lib/pacman out as configure_zfs_snapshots
+assumes, or change the config to match the layout velox actually has. That is
+a call I have not made, so the task stays [#B] and not solo.
+
+*** 2026-09-13 Sun @ 07:14:54 -0500 Filed the original boot-failure handoff under docs/design
+The 2026-07-15 diagnosis and recovery plan that opened this task sat in
+inbox/ as a processed file; it now lives at
+[[file:docs/design/2026-07-15-velox-boot-failure-handoff.org][docs/design/2026-07-15-velox-boot-failure-handoff.org]]
+so the timeline survives the inbox sweep.
+
+** TODO [#C] Assess a Hyprland left-drag window gesture :feature:hyprland:
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-21
+:LAST_REVIEWED: 2026-08-26
:END:
Evaluate whether a global left-click drag can move ordinary windows without
breaking application selection, text interaction, or Wayland security
@@ -360,35 +2465,94 @@ any binding.
** TODO [#B] Reconcile panel keybindings around Super+N :feature:hyprland:
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-21
+:LAST_REVIEWED: 2026-08-21
:END:
-Swap the notification and networking bindings so primary panels are one
-Super-plus-letter chord away, audit the other exceptions, and bring the
-proposed family to Craig for a final mapping decision.
+Put every panel on one consistent chord family — net, bluetooth, audio, timer,
+and the maintenance console — as a shared modifier set plus a mnemonic letter
+per panel (N/B/A/T/M). Today they open by waybar click only, so a uniform
+family is what makes them keyboard-reachable and predictable. The immediate
+move is swapping the notification and networking bindings so the primary panels
+sit one Super-plus-letter chord away.
+
+Maintenance (M) is the chord I want first — it is the panel I keep reaching for
+without one.
+
+Constraints:
+- Super+Shift+A is already the PTT toggle, and the hold-to-talk grave bind is
+ load-bearing. Audit every current hyprland bind for conflicts before
+ proposing a family, and treat these two as fixed.
+- Both machines have to work the same way. Velox can't QMK-remap, so the chords
+ have to be typable on a plain laptop keyboard.
+
+Steps: settle the modifier family, audit the existing binds for collisions,
+wire it through the dotfiles hyprland config, and document it in the keybind
+reference.
+
+The family itself is the one call I haven't made — the audit and the wiring
+follow from it, so that decision comes first rather than last.
+
+*** 2026-08-21 Fri @ 14:15:22 -0700 Merged the duplicate keybinding-family task into this one
+Two tasks were carrying one job: this one and =[#B] Consistent keybinding family
+for the panel console=, filed separately and both stalled. This one had the
+tighter framing and the more recent review; that one had the better body — the
+specific collisions, the velox plain-keyboard constraint, and maintenance-M as
+the priority chord. Folded its detail in here and cancelled it, since two
+half-specified tasks for one decision is plausibly why neither moved.
+
+Not =:solo:= and not =:quick:=: the modifier family is a preference call I have
+to make, judging what's load-bearing among the existing binds needs me too, and
+the audit plus wiring plus docs runs past thirty minutes on its own.
** TODO [#B] Add storage-capacity signals to the maintenance module :feature:maint:
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-21
+:LAST_REVIEWED: 2026-08-26
:END:
Investigate capacity and growth diagnostics for full disks, identify the
appropriate remedies, and incorporate a clear storage signal into the
maintenance console.
-** TODO [#B] Add per-channel controls to the audio panel :feature:audio:
+** TODO [#C] Add per-channel controls to the audio panel :feature:audio:
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-21
+:LAST_REVIEWED: 2026-08-26
:END:
Expose channel-level input and output volume controls without losing the
existing device-level workflow.
** DOING [#B] Widget gallery upgrades :feature:design:
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-13
+:LAST_REVIEWED: 2026-08-23
:END:
-Usability + documentation pass over the [[file:docs/prototypes/panel-widget-gallery.html][panel widget gallery]], orthogonal to the component-generation spec work, so it runs on the =gallery-upgrades= branch (squash merge to main after Craig's UI confirmation + tweaks). Items 1-4 run as a no-approvals speedrun (Craig authorized 2026-07-12); item 5 is a joint brainstorm after the merge.
+Usability + documentation pass over the [[file:docs/prototypes/panel-widget-gallery.html][panel widget gallery]], orthogonal to the component-generation spec work. Items 1-4 run as a no-approvals speedrun (Craig authorized 2026-07-12); item 5 is a joint brainstorm.
+
+The =gallery-upgrades= branch this originally described is gone — no local or remote ref, and every gallery commit since has landed straight on main. Whether it was squash-merged or abandoned, the branch stopped describing how this work runs, so the line came out at the 2026-08-23 review rather than being left to mislead. Work on main.
*** TODO Extraction-readiness bar for every gallery component :refactor:design:
Craig's standing directive (2026-07-18, set while finishing the split-flap): every =DUPRE.*= builder should meet the bar the split-flap now sets, since these become regular components. The bar: a contract comment documenting every opt and the full handle surface; no page globals touched (page owns cadence via handles/callbacks, e.g. =onSettle=); all component CSS in one named =DUPRE_CSS= block; refactored until no opportunity worth doing remains (small named helpers, no duplication); construction axes declared via =STYLES= where the component has them. Sweep the existing builders against that list, fix the gaps, and make the bar a stated convention in the widgets.js header or README so new builders inherit it. Overlaps the component-generation spec's extraction phase — reconcile there rather than doing the work twice.
+*Audited 2026-08-23 — the sweep covered the bulk and stopped short.* Ten commits
+on 2026-07-18 (=43725ff= … =1dd929d=) carried ~104 of the 112 builders over. Two
+criteria are fully met: 105 builders carry a real contract comment naming opts
+and the handle surface, and component CSS is wholly consolidated — the gallery's
+own =<style>= block holds nothing but page chrome (masthead, grid, toc, card
+frames, validation lamps, palette). Three gaps remain, and they are the whole of
+what's left:
+
+1. *Seven builders were never swept*: =telegraphIndicator=, =radarSweep=,
+ =dotMatrix=, =flipDisc=, =dekatron=, =gearIndicator=, =blinkenlights=. Each
+ carries a one-line comment describing what the widget does, with no opts, no
+ handle surface, no CSS statement. They sit at the tail of =widgets.js= after
+ =responseGraph=, and the last batch was "well-through-response" — the sweep
+ stopped one builder short of the end and never came back. Not a clean cut:
+ =dayDateCal= is in that tail and does have a contract.
+2. *The bar was never written down.* The README documents the API shape (Builder
+ contract) and =DUPRE_CSS= (Styling) — two of the five criteria. The checklist
+ itself appears nowhere, so a new builder inherits nothing and the sweep has to
+ be re-derived from this task every time.
+3. *One page-global reach survived*: =patchBay= does
+ =window.addEventListener('resize', draw)= and never removes it. Fails the
+ no-page-globals criterion and leaks besides — a torn-down instance keeps
+ redrawing on every resize. The other two =document= reaches are benign
+ (=indexPlate= guards a shared SVG def; the other is the CSS injector).
+
*** 2026-07-18 Sat @ 04:32:20 -0500 Made the N20 split-flap an honest Solari mechanism
=GW.splitFlap= rebuilt from the drop-fade fake: charset-as-drum stepping (=opts.chars= is the flap order, one flip at a time through intermediates, staggered arrival), re-aim-not-queue retargeting, and the real two-half-panel fold (WAAPI, backfaces hidden), with =animate:false= collapsing to instant jump for reduced motion. Handle grew =setText=/=chars=/=reading()=; default width 3 → 4 cells. Nine probe checks written red-first (arrival, intermediates, one-flap stepping, re-aim discriminator, instant path); technique studied from HotFX and re-derived — no license on their repo, nothing copied (reference filed in =working/retro-stereo-widgets/references/=). Review: sound; its two test-strength notes addressed in the same change.
@@ -545,11 +2709,25 @@ The cross's cell assignments were read off card names and spec sheets, not audit
** DOING [#B] Retro widget catalogue :feature:design:
:PROPERTIES:
:SPEC_ID: 3ac0d42c-db1a-4d21-bce4-e63785fef0ba
-:LAST_REVIEWED: 2026-07-13
+:LAST_REVIEWED: 2026-08-23
:END:
The panel widget gallery ([[file:docs/prototypes/panel-widget-gallery.html][docs/prototypes/panel-widget-gallery.html]]) grows into a retro-instrument component catalogue: reference photos of period hardware → gallery cards (the visual + behavioral spec) → reusable components for three targets (emacs svg.el, web/React, waybar). Tokens single-sourced in [[file:docs/prototypes/tokens.json][tokens.json]] (gen_tokens.py emits web/waybar/elisp); svg.el proof widget shipped (gallery-widget.el, needle gauge). Reference photos live in [[file:working/retro-stereo-widgets/][working/retro-stereo-widgets/]]. Collection converged at R56, then reopened at R57 as the taxonomy found empty cells (110 cards, all behaviorally verified; probes in [[file:tests/gallery-probes/][tests/gallery-probes/]]).
Build runs per the [[file:docs/specs/2026-07-12-component-generation-spec.org][component-generation spec]] (DOING; reviewed + decomposed 2026-07-12): web extraction first (ungated, lossless), then demand-gated Emacs/waybar ports. Banked variant/composition ledger lives in the 2026-07-11/12 session archive.
+
+*The eight open subtasks are really one decision plus three builds* (noted at the
+2026-08-23 review, because "8 open" reads as more contested than it is). Phase 1
+shipped. Phases 3, 4 and 5 and the spec flip are each gated, directly or
+transitively, on *Phase 2 — the demand inventory*, which is my matrix to write
+and nobody else's. That single artifact has been the whole chain's blocker since
+2026-07-12. The three genuinely independent items are the magic-eye rebuild, the
+wind-direction rose, and weather kit integration.
+
+Weather kit integration may already be unblocked: its note says live panel
+verification "awaits a stowed desktop with a private weather location
+configured", and both daily drivers are stowed now. Check whether
+=$WEATHER_LAT=/=$WEATHER_LON= or =~/.config/weather/config.json= is set before
+treating it as still waiting.
*** TODO [#B] Rebuild the magic-eye tube component :feature:design:
Reinstate the magic-eye tuning/level indicator (EM34/EM84/6E5 family), but
replace the earlier weak UI rather than reviving it unchanged. The component
@@ -611,10 +2789,18 @@ Restyle the audio panel's GTK CSS onto =tokens-waybar.css= + the banked composit
After ~5 hand ports, weigh widget-level codegen with evidence (mechanical duplication vs judgment per port). Recorded as a dated decision in the spec; go spawns its own spec.
*** TODO Flip the spec to IMPLEMENTED
When the phases above close: status heading keyword → =IMPLEMENTED=, dated history line with the reason, Metadata =Status= mirror. Three lines, one file.
-** TODO [#B] Net doctor expansion v1 — VM live verification :feature:dotfiles:network:
+
+*** 2026-09-13 Sun @ 07:21:18 -0500 Filed the two Maeda applets into the clock display references
+The Line (C2, 1997) and Cosmos (C1, 1995) standalone applets sent from the
+website project on 2026-07-30 sat in inbox/ as processed files; they and
+their notes now live beside the other references as
+=2026-07-30-maeda-{line,cosmos}-standalone.html= and =-notes.org=.
+Reference only, regenerate rather than edit.
+
+** TODO [#B] Net doctor expansion v1 — VM live verification :feature:dotfiles:network:solo:
:PROPERTIES:
:SPEC_ID: ce29b103-ed9d-4f56-bf8c-9ed8fe680ff3
-:LAST_REVIEWED: 2026-07-13
+:LAST_REVIEWED: 2026-08-25
:END:
Build the [[file:docs/specs/2026-07-11-net-doctor-expansion-spec.org][net doctor expansion]] (IMPLEMENTED). Adds the control-plane cluster (rival-manager / nm-masked / keyfile-perms) and a sharper auth verdict to the shipped net doctor (=~/.dotfiles/net/=). Archsetup owns the dotfiles work end to end — edit, test, commit, and push in =~/.dotfiles=, then drop an inbox note. All build phases shipped and fake-verified; the one open piece is the VM live verification below.
*** 2026-07-11 Sat @ 02:47:47 -0500 Built the read-only control-plane probe
@@ -634,23 +2820,15 @@ On dotfiles main (=12e3e76=, pushed). =gather_context= derives an auth cause on
*** 2026-07-12 Sun @ 09:14:00 -0500 Flipped the net spec to IMPLEMENTED and logged the vNext items
Spec status heading now IMPLEMENTED (dated history line + Status mirror); all four phase headings DONE. vNext items (flaky/drops cluster, DoT/DNSSEC verdict, profile hygiene) logged as the "Net doctor vNext" task. The privileged-fix live halves remain with the VM live-verification sub-task and the manual-testing checklist — findings there come back as bugs.
-** TODO [#B] Consistent keybinding family for the panel console :feature:hyprland:
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-09
-:END:
-Consider putting every panel (net, bluetooth, audio, timer, and the coming maintenance console) on one consistent chord family — a shared modifier set (Super+Shift, Control+Alt, or similar) plus a mnemonic letter per panel (N/B/A/T/M). Today the panels open via waybar clicks only; a uniform chord family makes them keyboard-reachable and predictable. Watch for collisions with existing binds: Super+Shift+A is already PTT toggle, and the hold-to-talk grave bind is load-bearing. Decide the family, audit current hyprland binds for conflicts, wire via the dotfiles hyprland config, and document in the keybind reference. Both machines (velox can't QMK-remap, so chords must work on a plain laptop keyboard).
-*** 2026-07-14 Tue @ 00:31:36 -0500 Folded Craig's ask for a maintenance-panel keybinding; bumped [#C] → [#B]
-Craig asked (in session, 2026-07-14) for a maintenance keybinding specifically — the panel he's reaching for without one. Maintenance (M) is the priority chord when this task gets worked. The capture graduated the task from parking lot to active backlog.
-
** DOING [#B] Run-time privilege model, standard across every panel doctor :feature:dotfiles:
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-13
+:LAST_REVIEWED: 2026-08-25
:END:
The audio input/output doctor is gaining a run-time privilege model (see [[file:docs/specs/2026-07-10-audio-doctor-input-side-spec.org][docs/specs/2026-07-10-audio-doctor-input-side-spec.org]], decision "The doctor may use sudo, resolved by context at run time"). Craig's call, 2026-07-10: make it a standard, "revise the other panels to be consistent with these changes."
The model: a doctor resolves its privilege at run time from three signals — passwordless sudo available (=sudo -n true=, which never hangs), a tty to prompt at, and whether it is the GUI panel. Four remedy classes: Auto (user-scope, reversible), Privileged (needs sudo — runs where passwordless, prompts on a CLI tty, degrades to Guide in a GUI with neither), Reboot-tail (run the applicable part, then instruct the reboot), and Guide (physical/BIOS/wait-for-upstream, nothing to run). Safety floor: every Privileged and Reboot-tail remedy defaults to Confirm or Arm tier, never silent Auto, because passwordless sudo is not consequence-free.
-The shared helper is built (see the dated entry below). What remains is per-panel adoption: wire each doctor's remedies through =panelkit.privmodel.resolve()= and audit them against the Confirm/Arm floor, and reconcile maint's =priv.py= build/fire table with the model rather than leaving its implicit always-passwordless assumption. That wiring lives in the per-panel fix phases (net Phase 1, bt Phase 2, audio input/output), each needing a real privileged host to verify =--fix= end to end, so none is agent-solo.
+The shared helper is built (see the dated entries below), maint is reconciled onto it, and net is wired: =classify.py= carries the =remedy_class= on its privileged verdicts and =doctor.py= resolves each through =panelkit.privmodel.resolve()= (net Phase 1, shipped 07-11). Adoption is the gate only — every panel's repair actions already exist; what adoption changes is whether and how an existing privileged action is allowed to run (RUN where passwordless, PROMPT on a CLI tty, GUIDE in a GUI), under the Confirm/Arm floor. What remains, checked against the tree 2026-08-25: bluetooth is part-wired (=bt/doctor.py= makes one =resolve(PRIVILEGED, ...)= call, no per-remedy classes yet — audit its individual fixes against the floor), and audio has nothing on the doctor side (pending the input-side spec). Each needs a real privileged host to verify =--fix= end to end, so not agent-solo.
Craig's decision, 2026-07-12: maint's harmless-reclaim privileged remedies (the silent CLEAN UP set — paccache keep3, journal vacuum, coredump clean) STAY silent-auto. The reconciliation gives that class a sanctioned, documented exception to the confirm floor rather than forcing Confirm/Arm; the value of the floor holds for everything else. Where sudo is not passwordless, maint should degrade per the model (prompt on a tty, guide in a GUI) instead of hard-failing.
@@ -660,10 +2838,12 @@ On dotfiles main (=b987820=, pushed). Craig chose to share a library, not per-pa
*** 2026-07-12 Sun @ 09:01:25 -0500 Reconciled maint onto the shared privilege model (dotfiles 7937a9e)
maint's doctor now resolves every privileged step through =panelkit.privmodel= instead of the old always-=sudo -n= hard-fail: RUN where passwordless (unchanged), PROMPT on a CLI tty (plain sudo, preceded by a wall note), GUIDE events in a GUI with neither — the typable command on the wall, nothing executed. User-scope steps never resolve; a macro's user steps still run around guided privileged ones. Craig's silent-auto decision (above) is implemented as the documented exception: recorded in =maint/remedies.py= (tier semantics) and =panelkit/privmodel.py= (the floor's own docstring), waiving only the confirm gesture, only for the auto-tier reclaim set. TDD (15 new tests red→green incl. both wall renderers), full suite 65/65, live-verified RUN + GUIDE end-to-end on this host via the real launcher shim. Bonus root-cause fix caught by the live run: =probes/logs.py= crashed on journalctl MESSAGE=null entries (key present, so the =.get= default never applied), which broke every real fix's re-probe — normalized at =_journal_lines= with a regression test. Remaining under this task: net Phase 1 / bt Phase 2 / audio adoption (queue items 2-3), and the PROMPT-path + GUI-wall manual checks (see Manual testing and validation).
-** TODO [#B] Scrolling/Carousel layout: frame fit + wrap-around :hyprland:
+** TODO [#C] Scrolling/Carousel layout: frame fit + wrap-around :hyprland:
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-06
+:LAST_REVIEWED: 2026-08-08
:END:
+Demoted [#B] → [#C] at the 2026-08-08 review: no motion in two months —
+parking lot until the frame-fit design gets real interest.
Disabled 2026-06-12 (bind and cycle entry points removed; Super+Shift+S reassigned to whole-desktop screenshot). The layout needs real work before it earns its chord back:
- What fits in each frame: column/frame sizing so windows land at usable widths instead of arbitrary slices.
- Wrap-around: navigating past the last frame should wrap to the first (and vice versa).
@@ -673,7 +2853,7 @@ The support machinery was deliberately kept for this task: =layout-navigate= and
** TODO [#B] Audit dotfiles/common directory :chore:dotfiles:
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-14
+:LAST_REVIEWED: 2026-08-25
:END:
Refiled from the archsetup task audit (2026-06-28), landed via ~/.dotfiles/inbox; the dotfiles content split into its own repo 2026-06-16 but the task tracking stays here per Craig (2026-07-02). Three parts:
- Review all 50+ scripts in =~/.local/bin= and remove unused ones.
@@ -685,125 +2865,36 @@ ACTION before the kill pass: redo the reference scan to grep all invocation sour
*** 2026-07-14 Tue @ 01:40:48 -0500 Built the audit evidence report
Shipped as =docs/2026-07-14-bin-audit-evidence.org= in the dotfiles repo (260752a). 150 scripts bucketed: 69 keep (referenced or cron-driven), 74 kill candidates (zero references in the tree), 7 flagged (all dwm-tier, expected on a hyprland host). Config sweep: audacious and wofi configs are orphan candidates, ranger needs an install-vs-delete call (declared in archsetup but not installed on ratio). Shell history was too shallow (~700 lines) to prove by-hand disuse either way — the kill pass stays Craig's call in the parent task.
-** TODO [#B] Waybar network module — custom/net :feature:waybar:network:
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-09
-:END:
-Unifies the old wifi-no-internet indicator (was =[#C]=) and the network-manager
-dropdown (was =[#B]=) into one =custom/net= module: a tested Python =net= engine
-(nmcli + diagnostics), a thin bar indicator, and a GTK4 layer-shell panel. Code
-lives in the dotfiles repo (hyprland tier + a =net/= package like pocketbook);
-archsetup only installs deps. Secrets stay in NetworkManager's own store (no
-separate credential store). The =captive= script becomes the diagnostics engine.
-Full design, acceptance criteria, and the failure-mode coverage table:
-[[file:docs/design/2026-06-29-waybar-network-module-spec.org][2026-06-29-waybar-network-module-spec.org]].
-
-Phases below, dependency order. Engine/unit work is agent-verifiable (=unittest=
-+ fakes on PATH, coverage via venv); the live-network and visual states need real
-conditions, filed under "Manual testing and validation".
-
-*** 2026-06-29 Mon @ 20:19:11 -0400 Phase 1 shipped — indicator + console recovery
-Shipped to the dotfiles repo (10 commits, =5254bd8=..=c095a22=, pushed to main).
-The =net= engine is a src-layout Python package in-tree, imported by a bin shim
-that resolves the stow symlink back to the repo — so it runs from a bare TTY with
-no install, which the recovery path depends on.
-
-Landed: =net status= (fast path, one nmcli call + sysfs, degraded fallback in
-budget) + =net probe= (native captive probe, single-flight flock, atomic cache,
-fresh/stale/expired/unknown classes, iface/SSID/UUID invalidation); =waybar-net=
-replacing =custom/netspeed=, throughput → tooltip, CSS states in both themes +
-live; =net diagnose= (read-only steps) + =net repair= (rfkill/reset/bounce/
-dns-test, cleanup-verified) + =net doctor [--fix]= with the four terminal
-classifications; =net portal= + the =captive --probe-json= refactor; redacted
-JSONL event log; Makefile recovery targets (=make online= etc.); =~/.config/net/
-config=. Verified live: =make net-status= reads the real wlp170s0 / @Hyatt_WiFi.
-
-Airplane (Craig's call, option 1): =custom/net= absorbs only the *display* — net
-reads the airplane-mode state file and shows an airplane state/glyph. The
-airplane-mode toggle stays (it's a low-power mode — radios + CPU + brightness +
-services — not a radio switch), now on =custom/net='s right-click + signal 15.
-Deleted: =waybar-airplane=, =waybar-netspeed=, =custom/airplane=, their tests +
-css. =airplane-mode= kept.
-
-Tests: 160 in =tests/net/= (fake nmcli/curl/rfkill/resolvectl/ping/getent/
-systemctl on a temp PATH; doctor-classification fixtures; degraded-under-slow-
-nmcli benchmark) + the =captive= probe-mode tests; full dotfiles suite green (32
-suites). Coverage-gap pass via throwaway venv: pure modules ≥90% branch
-(classify 100%), IO-error branches excused in the test docstring.
-Deferred to Phase 2/3: archsetup deps (gtk4-layer-shell/python-gobject Phase 2,
-speedtest-go-bin Phase 3 — not added before the code that needs them).
-Verify (manual, live): see Manual testing and validation.
-
-*** 2026-06-29 Mon @ 22:19:25 -0400 Phase 2 shipped — panel shell + connection management
-Shipped to dotfiles (commits =4e7740f=..=24bcac5=, pushed). Engine: =net list= (saved
-MRU + in-range wifi scan, infrastructure types filtered), =net up/down= (UUID-keyed,
-mutation safety — keep prior link until target activates, classify wrong-password vs
-generic, report auto-reactivation), =net add/edit/remove/rescan= (open + WPA-PSK;
-enterprise activate-only; secret to NM's store, never our JSON/log — tested).
-
-Panel: a GTK-free PanelModel (selection, four state machines, the UX-flow enable
-rules, terminal states) + a GTK4 gtk4-layer-shell window (=net panel=) anchored
-top-right under the bar — Connections section with MRU list, active marked, signal
-glyph, row-click select, Connect/Add/Forget/Rescan, confirm-on-forget, worker-thread
-engine calls via GLib.idle_add. GTK imported lazily so the CLI/tests stay GTK-free.
-
-Bar interactions (settled with Craig over live iteration): left = =net-panel= toggle,
-middle = =net portal=, right = =net-fix= (notify the doctor result when one-way; open
-a terminal only when the outcome is fixable — the sudo/interactive case). Airplane on
-Super+Shift+A. archsetup adds =gtk4-layer-shell= + =python-gobject= (this commit);
-already on velox.
-
-Tests: 204 in tests/net (merge ordering/dedup, up/down mutation safety, no-secret-leak
-on add/edit, panel model + state machines, gui row-format helpers). Full dotfiles suite
-green (32 suites). Live-verified on velox: panel opens/toggles, list shows real 24
-profiles, right-click notification delivers (Craig confirmed). Phase 3 (diagnose/repair/
-speedtest IN the panel) is next; the engine for it already exists from Phase 1.
-
-*** 2026-06-29 Mon @ 22:43:40 -0400 Phase 3 shipped — diagnostics + speed test in the panel
-Shipped to dotfiles (=91277cf=..=691abcb=) + archsetup (=48052d6=, speedtest-go-bin),
-pushed. Engine: =net speedtest= (parses speedtest-go --json → ping from latency ns,
-down/up from per-server byte rates; missing-backend / offline / malformed → error
-envelope per the failure table). Panel grew a section switcher with four pages:
-- Connections (Phase 2).
-- Diagnose: =net diagnose= on a worker thread, each step a row (✓/✗/… glyph + title +
- redacted evidence), read-only; Open-portal button when captive.
-- Repair: "Get me online" (=net doctor --fix=) + tiers (rfkill/reset/bounce/dns-test)
- + force portal. Confirmations in-panel with the spec's exact wording; the privileged
- tiers run via =net-popup= terminal (where the sudo prompt + step output, incl.
- cleanup-verified, show) — a panel has no tty, and pkexec would mean a prompt per op.
-- Speed test: in-process =net speedtest= (no privilege → inline result: ↓/↑ Mbps + ping
- + server), Run/Cancel (Cancel pkills the child), error envelope shown.
-
-213 net tests; pure helpers (step_indicator, format_speedtest) unit-tested. Full
-dotfiles suite green (32 suites). One unverified assumption: speedtest-go's dl/ul unit
-(taken as bytes/s; =BYTES_PER_SEC= flips it) — needs one real run vs a reference. The
-in-panel repair streaming (vs terminal) is a named future polish once the GUI-privilege
-story settles.
-
-The waybar network module ([#B] parent) is now COMPLETE through Phase 3. Phase 4
-(in-app help + user guide) and Phase 5 (VPN/WireGuard) remain as future work; the core
-feature (indicator + recovery + panel + diagnostics + speed test) is done.
-Verify (manual, live): see Manual testing and validation.
-
-*** 2026-07-09 Thu @ 16:32:54 -0500 Audit reconcile: Phase 4 is filed on the dotfiles side, waiting on them
-The dotfiles project accepted the Phase 4 handoff and filed it as a =[#C]= task in their own =todo.org= (their note, 2026-07-08 16:56): the help-text audit + panel help affordance, the user-guide/README, and the ratio rollout doc. Not started there. They ping when it lands, and this task's Phase 4 child closes then. Nothing to do here meanwhile.
-
-*** TODO Phase 4 — docs + rollout :network:blocked:
-Deliverable: in-app help (=net --help= + per-command, panel help affordance);
-README/user-guide (commands, indicator states, panel, config keys, make targets,
-troubleshooting from the failure table, rollback); archsetup Hyprland dep install
-(=gtk4-layer-shell=, =python-gobject=, =speedtest-go-bin=); ratio manual dep +
-stow step.
-Verify: =net --help= and each subcommand complete; user-guide covers every command
-+ the recovery targets.
-Build handed off to the dotfiles project 2026-07-04 (=~/.dotfiles/inbox/2026-07-04-1305-from-archsetup-phase4-handoff.md=): archsetup deps confirmed installed, the remaining help/user-guide/rollout-doc work is in the net package. dotfiles pings back when it lands.
-
-*** TODO Phase 5 — VPN / WireGuard CLI fold (vNext) :network:
-Rescoped 2026-07-04 (audit): the tunnels track already shipped most of the original Phase 5. Panel tunnel bring-up/down and detection landed (dotfiles 2d9d060 probes tailscale/NM-wireguard/Proton; 21db05a brings overlays up/down from the panel's Tunnels sub-view; 31ba056 diagnose/doctor understand tunnel routes; archsetup 2e40781 wireguard config import; the net-panel-other-interfaces spec is IMPLEMENTED). What remains for Phase 5 is only the =net vpn ...= CLI subcommand — cli.py still has no vpn/tunnel parser. Fold the panel's existing tunnel operations into a CLI surface; spec separately when picked up.
+** TODO [#C] net vpn CLI subcommand :feature:network:dotfiles:solo:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-08-21
+:END:
+=cli.py= in the dotfiles =net/= package has no vpn/tunnel parser, so everything
+the panel can already do with tunnels has no command-line equivalent. Fold the
+panel's existing tunnel operations into a =net vpn ...= surface mirroring what
+the Tunnels sub-view does — bring an overlay up, take it down, report status.
+
+The operations themselves already exist and are tested: dotfiles =2d9d060=
+probes tailscale / NM-wireguard / Proton, =21db05a= brings overlays up and down
+from the panel, =31ba056= taught diagnose and doctor to understand tunnel
+routes, and archsetup =2e40781= imports wireguard configs. This is a CLI surface
+over shipped behavior, not new capability.
+
+Graded [#C] rather than [#B]: the panel already does the job, so this is
+convenience rather than a gap. It earns a bump if I find myself wanting tunnel
+control from a bare TTY — which is the same recovery-path argument that made the
+rest of =net= worth having as a CLI.
+
+=:solo:= — the subcommand shape is obvious (it mirrors the panel), the =net=
+package's fake-based harness covers the build and verify path, and archsetup owns
+the dotfiles work end to end. Not =:quick:=: every prior net phase landed with
+twenty-odd new tests and a review pass, so this runs past a spare moment.
+
+Carved out of the =custom/net= umbrella when that closed on 2026-08-21.
** TODO [#B] Local offline LLM runtime + per-host model cache :tooling:llm:
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-06
+:LAST_REVIEWED: 2026-08-08
:END:
Add a local-LLM provisioning track so machines can run an offline coding agent when there's no network. Install =llama.cpp= (CPU + Vulkan where practical) and prefetch per-host model files while network is available; expose OpenAI-compat local endpoints (=127.0.0.1:8081= coding, =:8082= general; =:11434= reserved for =ollama= if used). Per the rulesets generic-agent-runtime design pass — rulesets becomes runtime-neutral and owns the runtime manifests + project instructions; archsetup owns machine provisioning + the per-machine model inventory. Source: handoff from rulesets 2026-05-28 ([[file:assets/outbox/2026-05-28-from-rulesets-local-llm-install.org][outbox copy]]).
@@ -839,66 +2930,9 @@ Boot the configured endpoint and send a short prompt; surface success/failure +
Acceptance: fresh VM install of the ratio profile reaches an endpoint on =:8081= that answers a smoke prompt; velox profile gets Q4_K_M + 8B and answers a prompt within reasonable laptop latency; network-down install completes successfully with the pending-models warning surfaced.
-** TODO [#B] Review post-archsetup laptop setup steps (velox 2026-04-10)
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-04
-:END:
-Items discovered during velox setup that needed manual intervention after archsetup.
-Decide which should be automated in archsetup vs documented as post-install steps.
-
-*** TODO Review: rfkill soft blocks bluetooth and wifi at boot
-Both bluetooth and wifi were soft-blocked by rfkill. Fix was ~rfkill unblock bluetooth/wifi~.
-~systemd-rfkill~ persists state, so unblocking once should stick, but new installs may default to blocked.
-Consider: add ~rfkill unblock all~ to archsetup post-install or a firstboot script.
-
-*** 2026-07-04 Sat @ 11:48:24 -0500 Automated /efi restrictive mount permissions in fstab generation
-archsetup:2827-2836 now rewrites the /efi fstab line to =fmask=0177,dmask=0077= (idempotent), so fresh installs no longer land the world-accessible =fmask=0022,dmask=0022= default. Confirmed via the 2026-07-04 task audit. (Original velox note: default vfat mount had =fmask=0022,dmask=0022=, hand-fixed to restrictive; bootctl warned about a world-accessible random-seed file.)
-
-*** TODO Review: tmpfs layered over ZFS /tmp causing systemd-tmpfiles failures
-~systemd-tmpfiles-clean.service~ failed repeatedly with "Protocol driver not attached".
-Root cause: systemd's ~tmp.mount~ (tmpfs) mounted before ZFS's ~/tmp~ dataset, creating a stale layer.
-Fix: ~systemctl mask tmp.mount~. Consider: mask tmp.mount in archsetup when ZFS is used.
-
-*** TODO Review: intel-ucode not installed
-CPU running old microcode. Installed ~intel-ucode~ and rebuilt initramfs.
-Consider: add intel-ucode (or amd-ucode) to archsetup package list based on CPU vendor.
-
-*** 2026-07-04 Sat @ 11:48:24 -0500 Automated syncthing user-service enable in archsetup
-archsetup:2263-2271 now installs syncthing and enables the user service (via symlink), so fresh installs no longer leave it installed-but-disabled. Confirmed via the 2026-07-04 task audit. (Original velox note: package installed but service not enabled; hand-fixed with =systemctl enable --now syncthing@cjennings=.)
-
-*** TODO Review: awww-daemon crashes at boot (coredump)
-Wallpaper daemon crashed with abort() shortly after boot. Hyprland also coredumped at same time.
-May be a race condition. Restarting awww-daemon fixed it. Monitor for recurrence.
-
-*** TODO Review: touchpad-indicator missing (X11 only, no Wayland equivalent)
-Old ~touchpad-indicator-git~ was X11-only and removed as broken.
-Created ~touchpad-auto~ (auto-disable touchpad when mouse connected) and ~toggle-touchpad~ scripts.
-~touchpad-auto~ watches Hyprland socket for mouseadded/mouseremoved/configreloaded events.
-Device name ~pixa3854:00-093a:0274-touchpad~ is hardcoded — will differ on other machines.
-Added to exec-once and $mod+F9 keybinding.
-Consider: add scripts to stowed dotfiles, make touchpad device name auto-detected.
-
-*** TODO Review: Bluetooth mouse pairing is manual post-install
-Paired Logi M650 via ~bluetoothctl scan on~, ~pair~, ~trust~.
-This is inherently interactive (scan, select device, pair, trust).
-Consider: document as post-install step. No automation possible.
-
-*** 2026-05-26 Tue @ 13:32:31 -0500 pocketbook install concern moot — pulled from publication, folded in-tree
-Resolved by removing pocketbook from archsetup's provisioning entirely. It's nowhere near ready, so the github mirror + cjennings.net repo were deleted and the project was folded into the archsetup tree at =pocketbook/=. Dropped the =gtk4-layer-shell= dep + =pip_install= from =archsetup= and the clone from =scripts/post-install.sh=. No fresh install pulls pocketbook now, so "not installed on velox" no longer applies. Re-wiring the install is tracked in the new pocketbook development backlog.
-
-*** TODO Review: Tailscale needs login after install
-~tailscaled~ service was enabled but needed ~tailscale up~ for interactive auth.
-Old machine entry needed cleanup in admin console.
-Consider: document as post-install step.
-
-*** TODO Review: docs/ directories need manual sync from existing machine
-docs/ dirs (gitignored) for ~/code and ~/projects repos needed scp/rsync from ratio.
-Same for ~/.emacs.d/docs/. Not in git, so not available after clone.
-Consider: document as post-install step or create a sync script.
-
** TODO [#B] Test + CI infrastructure :test:
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-13
+:LAST_REVIEWED: 2026-08-25
:END:
Umbrella for the test-harness and CI-automation buildout. Consolidated from the 2026-06-28 task audit: these were scattered top-level tasks circling one effort, re-homed as children so the work reads as a unit. Each child ships independently and keeps the priority it carried before. No CI runner exists yet, so the CI/CD-pipeline child gates several of the others.
@@ -970,7 +3004,7 @@ Keep test runs performant as installs and post-install tests grow (target < 2 ho
:LAST_REVIEWED: 2026-05-21
:END:
Proactive monitoring integrated with testing
-*** TODO [#B] Fix VM cloning machine-ID conflicts for parallel testing
+*** TODO [#C] Fix VM cloning machine-ID conflicts for parallel testing :no-sync:
:PROPERTIES:
:LAST_REVIEWED: 2026-05-21
:END:
@@ -981,9 +3015,9 @@ Need to investigate proper machine-ID regeneration that doesn't break networking
Would enable parallel test execution in CI/CD
Priority C because snapshot-based testing meets current needs
-** TODO [#B] Review undeclared ratio packages for installer inclusion :chore:
+** TODO [#C] Review undeclared ratio packages for installer inclusion :chore:
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-09
+:LAST_REVIEWED: 2026-08-21
:END:
Triggered by the 2026-06-14 =make package-diff= run on ratio: 62 packages are installed but not declared in archsetup. Stripped of the structural buckets — pacstrap base/boot/kernel (base, linux*, grub, efibootmgr, sudo, btrfs-progs, fwupd, logrotate, ex-vi-compat, linux-lts-strix, zram-generator), the =make deps= VM set (qemu-full, virt-manager, virt-viewer, libguestfs, bridge-utils, dnsmasq, archiso), and the yay bootstrap — these 40 remain. Check the ones to add to the installer, then rerun =make package-diff= to confirm they clear.
@@ -991,6 +3025,10 @@ Evidence report (2026-07-14, count now 64): [[file:docs/design/2026-07-14-undecl
Some entries are libraries likely pulled in as dependencies (blas-openblas, openblas, eigen, tk, lib32-openal, pkcs11-helper, gtk4-layer-shell, webkit2gtk, sane, freerdp, rust-bindgen) — check those only if you want them declared explicitly rather than left to dependency resolution.
+git-lfs, imv and libreoffice-fresh are ticked: the installer declares all three
+as of 2026-09-25, so a later walk of this list should skip them rather than
+re-deriving the case for each. The rest of the list is untouched.
+
- [ ] aws-cli-v2
- [ ] bats
- [ ] blas-openblas
@@ -1000,16 +3038,16 @@ Some entries are libraries likely pulled in as dependencies (blas-openblas, open
- [ ] flatpak
- [ ] freerdp
- [ ] geeqie
-- [ ] git-lfs
+- [X] git-lfs
- [ ] github-cli
- [ ] gtk4-layer-shell
- [ ] hugo
-- [ ] imv
+- [X] imv
- [ ] lc0
- [ ] lc0-network-sm
- [ ] ledger
- [ ] lib32-openal
-- [ ] libreoffice-fresh
+- [X] libreoffice-fresh
- [ ] minidlna
- [ ] openai-codex
- [ ] openblas
@@ -1032,13 +3070,45 @@ Some entries are libraries likely pulled in as dependencies (blas-openblas, open
- [ ] webkit2gtk
- [ ] whisper.cpp
-** TODO [#B] Installed-package drift audit :chore:packages:
+*** 2026-08-21 Fri @ 14:28:18 -0700 Dropped to [#C], and the sharper measurement is on velox now
+Re-graded [#B] → [#C]. Not a change of mind about the value — nothing has been
+ticked since I filed it on 2026-06-14, across two full cycles, and by my own
+scheme [#B] means "this cycle" while [#C] is the parking lot. The grade should
+say where it actually sits.
+
+The premise also moved. This list measures ratio, which carries years of
+accumulated manual installs tangled up with whatever archsetup put there, so a
+package being undeclared says little about whether it matters. Velox is the
+better instrument now: rebuilt from archsetup on 2026-08-13 and working, so a
+=make package-diff= there compares what the installer declares against what a
+machine actually needs, with only days of drift on top. Re-run it on velox
+before walking these forty by hand.
+
+Worth noting the empirical result already came in. The gaps that actually hurt
+after that rebuild — rulesets never cloned, the .emacs.d systemd units never
+linked, the missing =*.local.*= configs — surfaced on their own, and not one of
+them is on this list. That is evidence about what this kind of list catches.
+
+Related but distinct: =[#B] Installed-package drift audit= below builds the tool
+for the opposite direction (declared but missing, and provider substitutions).
+If that lands first it plausibly subsumes the detection half of this one, leaving
+only the include/ignore judgment.
+
+** TODO [#B] Installed-package drift audit :chore:packages:solo:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-08-02
+:END:
Compare the packages explicitly declared by =pacman_install= / =aur_install=
against the installed system, separately from the existing inventory's
unexpected-package review. Report missing declared packages and substitutions
where a provider is installed instead of the named package (the ratio
-=emacs= versus =emacs-wayland= case). Decide whether the audit is on-demand,
-timer-driven, or surfaced at startup; it must not make package changes.
+=emacs= versus =emacs-wayland= case). It must not make package changes.
+
+*On demand only* (Craig, 2026-08-02) — a command I run when I want it, not a
+timer and not a startup line. It is the only mode that cannot nag me, and the
+other two stay cheap to add later once the report has proven itself. This was
+the open decision that kept the task off =:solo:=; with it answered the rest is
+mechanical, so the tag goes on.
From the rulesets Emacs/package-audit handoffs, 2026-07-16. The existing
=scripts/audit-packages.sh= validates repository availability, not installed
@@ -1046,7 +3116,7 @@ machine state.
** TODO [#B] Security hardening + audit :security:
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-14
+:LAST_REVIEWED: 2026-08-25
:END:
Umbrella for the security-hardening and audit effort. Consolidated from the 2026-06-28 task audit, re-homing the scattered security tasks as children so the work reads as a unit. Each child ships independently and keeps its prior priority.
@@ -1062,12 +3132,12 @@ Umbrella for the security-hardening and audit effort. Consolidated from the 2026
**** TODO [#B] Implement port scanning check
**** TODO [#B] Create security posture verification script
**** TODO [#B] Set up intrusion detection monitoring
-*** TODO [#B] Document threat model and mitigations within 6 months
+*** TODO [#B] Document threat model and mitigations
:PROPERTIES:
:LAST_REVIEWED: 2026-05-21
:END:
Identify attack vectors, what's mitigated, what remains
-*** TODO [#B] Complete security education within 3 months
+*** TODO [#B] Security education
:PROPERTIES:
:LAST_REVIEWED: 2026-06-24
:END:
@@ -1078,24 +3148,485 @@ Read recommended resources to make informed security decisions (see metrics for
:END:
Practical guidelines for working in public spaces
-** TODO [#B] Ensure sleep/suspend works on laptops
+** TODO [#A] Ensure sleep/suspend works on laptops
+SCHEDULED: <2026-09-25 Fri>
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-06
+:LAST_REVIEWED: 2026-09-17
:END:
+Raised [#B] → [#A] and scheduled at the 2026-08-08 review: Craig leaves on
+vacation ~2026-08-15 and velox is the travel machine — suspend and battery
+drain must be verified working before departure. Verification plan: apply the
+params, suspend velox, measure overnight drain; the hands-on resume check is
+Craig's (a manual-testing entry rides the fix).
+
+While on velox for this (out for repair until ~2026-08-11/12, so these ride
+along when it returns):
+- Append =DEVICES_TO_ENABLE_ON_STARTUP="bluetooth wifi"= to its
+ =/etc/tlp.d/01-custom.conf= (the installer now writes it; velox predates
+ that) and confirm radios unblocked after reboot.
+- Pull dotfiles so the touchpad auto-detection lands; spot-check
+ =touchpad-auto --detect= prints the pixa name on real hardware.
+- Set up the wolf WireGuard profile (Craig's 2026-08-08 decision: velox
+ should reach home over wolf on the road; profile exists on ratio only).
+- Enable the rootless podman socket and install the
+ =72-usb-passthrough-cameras.rules= file (both live on ratio as of
+ 2026-08-09; the installer now ships both, velox predates it).
Critical functionality for laptop use - current battery drain unacceptable
*NOTE:* This applies to Framework Laptop (velox), not Framework Desktop (ratio)
Add kernel parameter: ~rtc_cmos.use_acpi_alarm=1~ (will become systemd default)
Consider: ~acpi_mask_gpe=0x1A~ for battery drain, suspend-then-hibernate config
See Framework community notes on logind.conf and sleep.conf settings
+*** 2026-08-17 Mon @ 19:57:42 -0700 Four of the five riders are done; WireGuard is the one left
+The riders were written for "when velox returns from repair". It came back as
+a full reinstall instead, and the installer carried most of them, so I checked
+each on the live machine rather than reading the list back:
+
+- tlp radio-enable — done. =/etc/tlp.d/01-custom.conf:10= carries
+ =DEVICES_TO_ENABLE_ON_STARTUP="bluetooth wifi"=, written by the installer.
+- touchpad auto-detection — the dotfiles half is done: =touchpad-auto
+ --detect= prints =pixa3854:00-093a:0274-touchpad=. Read that carefully
+ though — it names the device the config expects, not a device delivering
+ events. The touchpad is still dead on the ribbon fault, so this rider is
+ satisfied and the hardware still is not.
+- podman socket — done, =podman.socket= is enabled.
+- camera udev — done, =72-usb-passthrough-cameras.rules= is installed.
+- *wolf WireGuard — not done, and it is the one that was time-critical.* No
+ =~/.config/wireguard/wolf.conf.gpg= and no WireGuard profile in
+ NetworkManager. The 08-08 decision set this up specifically so velox could
+ reach home from the road, on the argument that it is cheap at home and
+ expensive from a hotel. velox is now in the hotel.
+
+The suspend work itself is untouched — no kernel parameter, no drain
+measurement. Only the riders moved.
+
+*** 2026-08-25 Tue @ 11:57:48 -0600 Logged the two Aug 23 hibernate-leg failures
+suspend-then-hibernate failed its hibernate leg twice on 2026-08-23 (21:06 and
+23:29): "Failed to put system to sleep. System resumed again: Device or
+resource busy". Noticed during the 08-24 Lua-port session and parked there;
+filed here at Craig's direction so the sleep task carries it. Nothing
+diagnosed yet — first step is =journalctl -b -1 -u systemd-suspend-then-hibernate=
+around those timestamps to see which device reported busy.
+
+*** 2026-08-26 Wed @ 16:16:08 -0600 Diagnosed the hibernate battery drain: three separate faults, one task each
+Craig hibernated twice in ten days and found the battery dead both times. Read
+all 39 boots since the 08-13 reinstall, upower's charge history
+(=/var/lib/upower/history-charge-Framewo-55-03F5.dat=, root-only, starts
+08-19), sysfs, and the scripts inside =/efi/EFI/ZBM/zfsbootmenu.efi=.
+
+Hibernate is configured right and has worked: five hibernate+resume cycles
+since reinstall (08-13, 08-17 14:31, and three suspend-then-hibernate cycles on
+08-20/21). The two fatal events are the two overnight explicit
+=systemctl hibernate= runs, 08-17 22:20 and 08-21 19:58. Both journals end at
+"PM: hibernation: hibernation entry"; the next power-ons (08-18 10:13, 08-22
+17:22) were fresh boots whose resume hook found no image, no later swapon
+reported a leftover suspend signature, and on 08-22 the battery read 2% at
+power-on. The 08-23/24 night was on AC and not a battery death (three suspends
+failed to enter, machine awake all night at the charge limit; the 09:57 end was
+three power-key presses and a hard cut at 63%). The 08-19 death was the
+caffeine/hypridle one already diagnosed.
+
+Three faults, tracked as the children below:
+- Hibernate hard-freezes on entry (documented on Framework 13 AMD incl. Ryzen
+ AI 300: black screen, never powers off, intermittent, amdgpu-side). Fits
+ everything: the freeze precedes the swap signature, so the next boot is
+ fresh, and a frozen laptop at ~5 W empties 44.7 Wh in ~8 h. Unprovable from
+ logs by nature; the alternative (completed hibernate, unattended power-on to
+ the ZBM passphrase prompt) predicts a surviving image, which neither boot
+ had — see the VERIFY.
+- ZFS ARC starves the hibernate image: "Image allocation is 8118265 pages
+ short" today 14:09, "390678 pages short" 08-20 09:14. ARC 58 GB of 93,
+ =zfs_arc_max=0= so =c_max= = RAM − 1 GiB; the kernel must free RAM −
+ =image_size= (37.4 GB) ≈ 56 GB. systemd falls back to s2idle and retries
+ every 90 min, so suspend-then-hibernate never actually hibernates.
+- The SD card reader (090c:3350, =sda=, no media) can block suspend entirely:
+ "Freezing remaining freezable tasks failed after 20s (wq_busy=1)", pending
+ =disk_events_workfn= on =events_freezable_pwr_efficient=, three times on
+ 08-23/24. On battery that is a dead laptop by morning.
+
+Mistake worth remembering: =journalctl --since … -k= silently limits itself to
+the current boot (=-k= implies =-b=); cross-boot kernel facts need
+=_TRANSPORT=kernel= or an explicit =-b=.
+
+*** 2026-10-05 Mon @ 22:18:03 -0600 The bluetooth resume hook never ran: it was installed where systemd-sleep doesn't look
+Bluetooth came back rfkilled after tonight's suspend-then-hibernate (S4 wake
+19:43 MDT, hci0's rfkill entry recreated at resume, bluetoothd "Failed to set
+mode: Failed (0x03)"), the same double fault the 08-21 hook was written for.
+The hook had never fired: btusb was deregistered only twice since 08-20, both
+by hand (the 08-21 07:39 test and tonight's 20:06 relief), across about
+fifteen hibernate cycles. Cause: the installer created
+=/etc/systemd/system-sleep/= and put the hook there, and systemd-sleep (262,
+and 261 before it) scans only =/usr/lib/systemd/system-sleep/=. That is the
+one path in the binary and the one the man page names, which is also why Arch
+ships no =/etc= directory. TLP's hook lives in =/usr/lib= and does run, and
+=tlp resume= restores the rfkill state it saved at suspend, so once bluetooth
+is blocked at any sleep edge TLP carries the block across every later cycle.
+
+Fixed tonight: the installer now installs to =/usr/lib/systemd/system-sleep/=
+(the step's test pins the full install list, so a copy under =/etc= fails it),
+and the live hook on velox was moved there with the dead =/etc= directory
+removed. A hand run from the new path exits 0. Ratio has no hook in either
+directory and no TLP, so nothing to do there. The real-cycle check rides the
+Manual testing task ("Bluetooth resume hook fires on a real sleep cycle").
+One caveat for that check: =man systemd-sleep= says the hooks run in parallel,
+so the zz- prefix buys no ordering after TLP's hook (the hook's own comment
+claims it does and needs correcting). The reload's one-second settle should
+make this hook's unblock land after =tlp resume=, and the re-check step is
+what shows it if the race goes the other way.
+
+*** 2026-10-05 Mon @ 23:08:31 -0600 Real-cycle check passed: the hook fires from /usr/lib
+One suspend-then-hibernate cycle on AC, RTC-woken after thirty seconds
+(suspend entry 23:07:01, exit 23:07:32, s2idle). The kernel logged btusb
+deregistered at 23:07:32 and registered again at 23:07:34, which is the hook's
+unload, settle and load, and rfkill logged "unblock set for type bluetooth"
+at 23:07:34, the hook's last command. No system-sleep line named the hook,
+and afterwards rfkill read "Soft blocked: no" with the controller "Powered:
+yes". The manual check under Manual testing is retired. Not proven by this
+cycle: the plain-suspend race with =tlp resume='s replay (it entered with
+bluetooth unblocked), and a true hibernate-class wake, which the entry-freeze
+test now checks after each of its cycles.
+
+*** TODO Hibernate entry freeze — confirm under observation, then mitigate :bug:velox:hibernate:
+Interim rule until this closes: do not hibernate unattended on battery. Shut
+down, or suspend on AC.
+
+What is known: the two dead-battery hibernates match the Framework 13 AMD
+"hard freeze on hibernate entry" reports (community threads 69516 and 53860,
+Arch bbs 293242): screen black, power LED on, never powers off; intermittent
+(one report: every 6–7 cycles); TTM/amdgpu warnings; improved by newer
+=linux-firmware=; no confirmed fix. Board A9, BIOS 03.05, linux-lts 6.18.46,
+=amdgpu.dcdebugmask=0x610= already on the cmdline.
+
+Confirm first: the "Hibernate entry freeze: five observed cycles on AC" test
+under Manual testing and validation. A failed cycle there is the proof the
+journal cannot give.
+
+Mitigations to try in order once confirmed, one at a time, re-running the
+cycles after each: (1) =linux-firmware= at current, then =linux-firmware-git=
+if the freeze persists; (2) =/sys/power/disk= = =shutdown= instead of
+=platform= (a systemd =HibernateMode=shutdown= drop-in), which skips the ACPI
+S4 path some Framework users found hanging; (3) a newer kernel (=linux= vs
+=linux-lts=) for the amdgpu delta; (4) unload =mt7925e= in a pre-sleep hook
+if the freeze survives the first three. Not =:solo:=: each cycle needs a
+person watching the power LED.
+
+*** TODO ZFS ARC starves the hibernate image — cap it or shrink it pre-hibernate :bug:zfs:velox:solo:
+The arithmetic: the kernel preallocates RAM − =image_size= pages before
+snapshotting; with 93 GB RAM and the default =image_size= (2/5 of RAM,
+37.4 GB) that is ~56 GB, and only free memory plus what shrinkers give back
+counts. ARC was 58 GB today and the ZFS shrinker released little inside the
+preallocation window, so it came up 31 GiB short. Nothing in
+=/etc/modprobe.d/= sets =zfs_arc_max=.
+
+Two fixes, either or both:
+- Cap the ARC: =options zfs zfs_arc_max=<bytes>= in =/etc/modprobe.d/zfs.conf=
+ (16 GiB leaves ~70 GB reclaimable) plus =echo <bytes> >
+ /sys/module/zfs/parameters/zfs_arc_max= for the running system.
+- Or a =/usr/lib/systemd/system-sleep/= pre hook for the hibernate class that
+ lowers =zfs_arc_max=, waits for =size= in
+ =/proc/spl/kstat/zfs/arcstats= to fall, and restores it post-sleep. Keeps
+ the big ARC while awake.
+- Raising =image_size= toward the kernel's ceiling (about half of RAM) also
+ shrinks the demand; combine with the cap.
+Install it through archsetup so the next rebuild carries it (velox-only: ratio
+has no swap partition).
+
+Verify: after the change =arcstats size= drops below the cap within seconds;
+then one live suspend-then-hibernate cycle on AC with the delay temporarily
+short shows "hibernation exit" and no "Image allocation … short" line in the
+journal. That live cycle rides the entry-freeze test above; the ARC half is
+checkable without it.
+
+*** TODO SD card reader media polling can block suspend :bug:velox:solo:
+The reader (USB 090c:3350 Silicon Motion, =sda=, "Media removed, stopped
+polling" at boot yet =events_poll_msecs= = −1 → default 2000 ms) left a
+=disk_events_workfn= item pending on the freezable workqueue three times on
+08-23/24, and the freezer gives up after 20 s: "Failed to put system to
+sleep … Device or resource busy". Same symptom as the flaky expansion slot in
+the ribbon task; a stalled poll never completes.
+
+Fix: a udev rule for that vendor/product setting
+=ATTR{events_poll_msecs}="0"= (or =block.events_dfl_poll_msecs=0= on the
+cmdline if every removable disk should stop polling), shipped by archsetup.
+Verify with =rtcwake -m mem -s 20= on AC: journal shows "PM: suspend entry"
+and "PM: suspend exit" with no "Freezing remaining freezable tasks failed",
+and =/sys/block/sda/events_poll_msecs= reads 0 after a replug. Pulling the
+card before sleeping is the manual workaround meanwhile.
+
+*** VERIFY After the 08-17 and 08-21 dead batteries, did the first power-on hang, or boot straight to a fresh login?
+Decides between the two mechanisms. An entry freeze leaves no image, so the
+next power-on boots straight through. A completed hibernate followed by an
+unattended power-on (phantom power button, ZBM passphrase prompt until dead)
+leaves the image in place, so the next power-on would try to resume — and the
+only way that ends in the fresh boots the journal shows is a hung resume that
+got force-cut. If both power-ons went straight to a fresh login, the freeze
+is the answer.
+
+** TODO [#A] Port Hyprland config to Lua before 0.57 drops .conf support :hyprland:dotfiles:
+SCHEDULED: <2026-09-24 Thu>
+:PROPERTIES:
+:LAST_REVIEWED: 2026-09-17
+:END:
+Hyprland prints "You are using the .conf config format, support for which will be
+removed in Hyprland 0.57" at every start. Installed and in =extra= is 0.56.2-1, so
+the *next* release breaks the config. Craig's call 2026-08-24: port now, under no
+time pressure, rather than pin the package or wait for the upgrade to force it.
+
+STATE (2026-08-24): built and verified in a nested compositor, *not deployed*.
+I deployed it to the dotfiles tree this afternoon and rolled it back the same
+hour on Craig's call — the switch had not been checked on real hardware and the
+machine has to stay usable. The dotfiles repo is untouched at =8f692f5= and the
+live config is the original =hyprland.conf=; =hyprctl reload= after the rollback
+returned zero configerrors and the velox host override is applied
+(=xwayland:force_zero_scaling= false), so the per-host chain is intact.
+
+Everything needed to redeploy is in =working/hyprland-lua-port/= with a README
+carrying the step-by-step: the three =.lua= deliverables, plus the two reader
+changes saved as patches (=reader-changes-for-lua.patch= for dotfiles'
+=dotfiles-validate= and three test suites, =test-desktop-for-lua.patch= for
+archsetup's post-install checks). Both patches were verified to apply clean
+against their repos, and every assertion in them was mutation-tested — each one
+confirmed to go red when the property it guards is removed. Replay them rather
+than rewriting the assertions.
+
+Also settled along the way: =themes/dupre/hyprland.conf= is dead. Nothing sources
+it, no apply script exists, and it has silently drifted from the live config
+(=dab53dff= / =2c2f32ff= against =daa520ff= / =444444ff=). It needs no porting.
+
+HOW IT WAS BUILT. =hyprlang2lua= (github.com/EIonTusk/hyprlang2lua, AUR 0.7.1-1)
+converts hyprlang to the 0.55+ Lua format and preserves comments. Built from
+source into a scratchpad with the local Go rather than installing the AUR package
+— one dependency, and no PKGBUILD executed. Run with =--no-merge=, which emits
+each config section as its own =hl.config()= call at its original position; the
+default merges them into one hoisted call, which both scrambles comment placement
+and puts the =conf.d= source glob BEFORE the config it must override.
+
+THREE DEFECTS THE CONVERTER INTRODUCED, all fixed by hand:
+1. Source glob emitted before the merged config block, silently reversing every
+ per-host override — including velox's =force_zero_scaling = false=, which is
+ the 2026-08-19 Qt scaling fix. =--no-merge= plus moving the glob to the last
+ line fixes it.
+2. =bind = CTRL $mod, S= became ="CTRL" .. mod .. " + S"= → ="CTRLSUPER + S"=;
+ same for =CTRL ALT $mod, K=. Proven fatal, not merely odd: Hyprland answers
+ =hl.bind: failed to parse key string: Unknown keysym: "CTRLSUPER"=. Two dead
+ keybinds.
+3. Super+RETURN is an =exec= of a shell pipeline; the converter pattern-matched
+ the =hyprctl dispatch layoutmsg= prefix and swallowed =&& sleep 0.05 && ...=
+ as the layoutmsg argument. That sleep is the one proven load-bearing 19/19.
+
+Also rebuilt the autostart section: the generator hoists every =exec-once= into
+one block at the end and leaves the comments stranded where the commands were.
+Each command now sits under its own comment again via =at_start= / =at_shutdown=
+/ =at_reload= collectors that the =hl.on()= handlers at the bottom replay.
+
+VERIFIED by running both configs in a nested Hyprland on a headless output and
+diffing runtime state, not by reading: 38 config keys identical (only type
+*rendering* differs — =bool: true= where hyprlang prints =int: 1=);
+=xwayland:force_zero_scaling= false on both sides, so the host override still
+wins; 103 binds registered on both sides with 100 of 103 matching exactly;
+autostart list byte-identical to the 16 =exec-once= lines in order; zero
+=configerrors=; and no ERR/WARN line in the ported run that is absent from the
+original run.
+
+KNOWN DELTA — the three =bindm= binds. hyprlang reports =mouse: true=, the Lua
+path reports =mouse: false=, and the raw bind struct confirms the flag is unset
+rather than merely unreported. Not a transcription error: the wiki documents
+exactly the spelling used (=hl.bind("ALT + mouse:272", hl.dsp.window.drag(),
+{ mouse = true })=), and all four candidate spellings were tested — none sets it.
+Reads as a gap in 0.56.2's Lua config manager. Kept the documented spelling: it
+is correct upstream, harmless now, and starts working when the gap closes. Per
+the wiki that flag is what makes the action fire *while held*, so Super+drag to
+move a window may fire once instead of tracking. Settle it in five seconds after
+switching; worth an upstream report if it survives 0.57.
+
+WHAT REMAINS:
+1. *Craig decides when to switch.* The port is ready to go in; it has not been
+ run on real hardware. The gate is the "Hyprland Lua config" test under Manual
+ testing and validation, which is written to be run right after the switch.
+2. *Four review gates travel with the redeploy*, all written up in that README:
+ exclude any in-repo =retired/= dir from =dotfiles-validate= (its find globs
+ across slashes and would validate the dead config); add tests for the new
+ =dotfiles-validate= Lua branch (25 lines, currently zero coverage — proven
+ vacuous, since stubbing both regexes to =NEVERMATCHES= still passes 15 tests);
+ guard that =hl_source_glob= stays the last statement (the one invariant the
+ per-host layer rests on, and archsetup's VM cannot catch it); and sweep the
+ ~15 prose comments still naming =hyprland.conf=, of which
+ =waybar-reserve:12= is the load-bearing one.
+3. *Redeploy per =working/hyprland-lua-port/README.org=*, which carries the eight
+ steps and the two traps that bit on 2026-08-24: =make restow hyprland= aborts
+ on the pre-existing =obsbot-wb-guard.service= conflict in =common= (restow
+ =hyprland= and the host package individually), and the running Hyprland
+ rewrites a stub =hyprland.conf= within a second of the symlink vanishing
+ (silence it with =hyprctl keyword misc:disable_autoreload 1=, stow, set back
+ to 0).
+4. *Push dotfiles before committing archsetup.* One-directional and load-bearing:
+ archsetup's post-install suite asserts =~/.config/hypr/hyprland.lua= and the
+ installer clones the dotfiles *remote* (=archsetup:1481=), so a local commit is
+ not enough. Confirm with =git ls-tree -r origin/main --name-only | grep
+ hypr/hyprland.lua=.
+5. *Do not leave the =.conf= beside the =.lua= as a rollback.* With both present
+ Hyprland 0.56.2 loads the =.lua= — proven in a nested instance with a fixture
+ whose =.conf= set =gaps_in=11= and =.lua= set =77=; the result was 77. A
+ =.conf= left in place buys nothing and only obscures which file is live. Move
+ it out of the stow package instead.
+6. *=bindm='s missing =mouse= flag is worth an upstream report* if it survives
+ 0.57. Documented spelling, four variants tested, flag never set.
+*** 2026-09-17 Thu @ 08:59:59 -0400 Re-dated to 09-24; 0.57 still isn't in the repos
+velox runs =hyprland 0.56.2-3= with no Hyprland update pending, so nothing has
+broken yet. The switch still needs me at the keyboard for one restart and the
+manual test.
+
** TODO [#B] Manual testing and validation :test:
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-09
+:LAST_REVIEWED: 2026-08-23
:END:
-Craig's standing checklist of everything that isn't agent-verifiable. Each child is one test in the =verification.md= shape (title, what we're verifying, steps, Expected). A child that fails gets its actual behavior written under it and is promoted to a top-level TODO. 44 checks pending as of the 2026-07-09 audit.
+Craig's standing checklist of everything that isn't agent-verifiable. Each child is one test in the =verification.md= shape (title, what we're verifying, steps, Expected). A child that fails gets its actual behavior written under it and is promoted to a top-level TODO. 62 checks pending as of the 2026-08-23 review — up from 44 at the 2026-07-09 audit, so the queue has gained 18 in six weeks and nothing has drained it. A checklist that only grows is on its way to being where tests get filed rather than run; if the next review finds it higher again, the container needs a scheduled sweep rather than another re-stamp.
Priority and type tag added by that audit: the task carried neither, which kept the project's largest live container out of the agenda entirely.
+*** Airplane console key in the net panel: does it engage, confirm, and let you back out?
+What we're verifying: that the new AIRPLANE key actually drives the mode both
+ways, that engaging asks first, and that the way out is visible from inside
+airplane mode. The GTK widget layer has no unit coverage and the AT-SPI smoke
+can't run on velox (no weston/sway, no at-spi-bus-launcher), so this is the
+only check that exercises the real wiring.
+
+Do it on AC, and not while you need the network — it stops tailscale, the VPN,
+syncthing, avahi, cups and inbound ssh, and dims the screen.
+
+- Open the net panel (Super+Shift+N). The CONSOLE row should now show three
+ keys: DOCTOR, SPEED TEST, AIRPLANE.
+- Click AIRPLANE.
+Expected: a dialog naming what it will do — wifi off, services stopped, screen
+dimmed, CPU to power — with Cancel and an AIRPLANE button.
+- Press Cancel.
+Expected: nothing happens. Wifi stays up, the key still reads AIRPLANE.
+- Click AIRPLANE again, then confirm.
+Expected: the key's lamp flashes while it runs, then the faceplate shows the
+AIRPLANE badge with a gold lamp, and the key's label changes to LEAVE AIRPLANE.
+Wifi is off and the screen is dimmer.
+- While engaged, try the faceplate wifi switch.
+Expected: it refuses and the status line says to press LEAVE AIRPLANE. It must
+NOT name a keyboard shortcut — the old message said Super+Shift+A, which is
+push-to-talk.
+- Click LEAVE AIRPLANE.
+Expected: no confirmation this time, it just runs. Wifi comes back, brightness
+returns to where it was, and the key reads AIRPLANE again.
+#+begin_src sh :results output
+# The services it stopped should be back. Anything listed here is still down.
+for s in tailscaled.service avahi-daemon.service cups.service sshd.service fail2ban.service; do
+ systemctl is-active --quiet "$s" || echo "still stopped: $s"
+done
+systemctl --user is-active --quiet syncthing.service || echo "still stopped: syncthing (user)"
+echo "airplane state: $(cat "${XDG_RUNTIME_DIR}/airplane-state" 2>/dev/null | head -1)"
+#+end_src
+Expected: no "still stopped" lines, and the state reads mode=off. A service
+that was already stopped before you engaged is correctly left alone, so check
+it was running first if one shows up.
+
+*** Hyprland Lua config: does the real desktop come up, and does Super+drag track?
+What we're verifying: that the Lua port drives a real Hyprland session the way
+the .conf did, and specifically whether the one known delta — the three =bindm=
+binds losing their =mouse= flag — actually costs anything. A nested compositor
+proved 38 config keys, 103 binds and the host-override chain identical, but it
+cannot test real input devices or a real DRM display.
+
+PRECONDITION: run this *only after* redeploying the port per
+=working/hyprland-lua-port/README.org=. As of 2026-08-24 the port is rolled back
+and the live config is the original =hyprland.conf=, so running this now just
+confirms the old config — which is not what it is for. Run it on velox; the stub
+check in the last block is velox-specific.
+- Restart Hyprland (log out and back in, or =hyprctl dispatch exit= from a TTY).
+- Confirm the desktop comes up: waybar present and not off-screen, wallpaper
+ restored, dunst notifications working.
+#+begin_src sh :results output
+# Which config did it actually load, and did anything fail to parse? The log is
+# per-instance under the runtime dir, not in ~/.local/share. Resolve the newest
+# instance dir rather than reading $HYPRLAND_INSTANCE_SIGNATURE: Emacs runs as a
+# daemon that survives the logout in step 1, so a block run from it can still be
+# carrying the PREVIOUS session's signature. That path is gone after the restart,
+# grep prints nothing, and an empty result under "Expected: names hyprland.lua"
+# reads as "the port failed" when it in fact succeeded -- the worst possible
+# wrong answer at exactly the wrong moment.
+log="$(\ls -td "$XDG_RUNTIME_DIR"/hypr/*/ | head -1)hyprland.log"
+echo "reading: $log"
+grep -iE '\[cfg\].*(lua|legacy)' "$log" | tail -3
+hyprctl configerrors
+#+end_src
+Expected: the log names hyprland.lua, and configerrors is empty.
+- Hold Super and drag a window with the left mouse button.
+Expected: the window tracks the pointer continuously while Super is held. If it
+jumps once and stops, the =bindm= =mouse= flag gap is real and costs the drag —
+write that here, promote to a top-level TODO, and report upstream.
+- Hold Super and drag with the right mouse button (resize), same check.
+- Walk the keymap: the launcher, terminal, browser, screenshot chords, the panel
+ family (Super+Shift+B for bluetooth), workspace switching, layout cycling.
+Expected: every chord does what it did before the port.
+#+begin_src sh :results output
+# The stub .conf should stay gone now that Hyprland started from the .lua.
+# Refuse to touch a symlink: on a host that has not been through this port yet,
+# ~/.config/hypr/hyprland.conf is still the stow link to the real config, and
+# deleting it would report "stays gone" as a pass while having broken the desktop.
+f=~/.config/hypr/hyprland.conf
+if [ -L "$f" ]; then
+ echo "REFUSING: $f is a symlink (a live stowed config), not the stub."
+elif [ -f "$f" ]; then
+ rm -f "$f"; sleep 2
+ [ -e "$f" ] && echo "REGENERATED — still stubbing" || echo "stays gone"
+else
+ echo "already absent — nothing to do"
+fi
+#+end_src
+Expected: "stays gone". If it regenerates, Hyprland is still resolving its config
+to the .conf path and the port is not actually live — stop and investigate.
+
+*** 2026-09-13 Sun @ 07:57:32 -0500 Retired the lock-screen clock check: fixed, per Craig
+Craig reported on 2026-09-13 Sun that the stale clock after a real sleep no longer
+happens on velox, so the three-outcome check never needed running. The
+parent bug task is closed with the same note.
+
+*** Lock keybind has no crash or hang recovery
+What we're verifying: that a hand-lock is as recoverable as an idle lock.
+=hyprland.conf:495= is =bind = $mod, ESCAPE, exec, hyprlock=, which runs the
+binary directly. hypridle's =lock_cmd= routes through =screen-lock=, which
+relaunches a hyprlock that exits non-zero; the keybind bypasses that entirely.
+=~/.local/var/log/screen-lock.log= does not exist on velox, so the watchdog has
+never recorded a relaunch — consistent with it rarely being in the path at all.
+
+- Lock with Super+Escape.
+- From another tty (ctrl+alt+F3), log in and run: =pkill -x hyprlock=
+- Return to the graphical tty.
+Expected: with the keybind as written, the session is left locked with no client
+and Hyprland draws its "lockscreen app died" screen. If instead a fresh password
+prompt appears, something is already relaunching it and the gap is closed.
+
+*** Clock/DNS deadlock: does the next abrupt power loss strand velox again?
+What we're verifying: that the machine survives an RTC reset unattended. Not the
+coin cell, which is new with the 2026-08-13 mainboard and is ruled out. The RTC
+did not drift on 2026-08-19, it was reset to exactly 2025-01-01T00:00:16 by an
+abrupt power loss at 01:33:18 that left no shutdown sequence in the journal.
+
+This one can't be scheduled. Run the block the next time velox comes up after an
+unexpected power loss, before touching the clock.
+#+begin_src sh :results output
+echo "--- what did the RTC read at this boot? ---"
+journalctl -b 0 | grep -m1 'rtc_cmos.*setting system clock'
+echo "--- did systemd have to advance the clock to its build epoch? ---"
+journalctl --list-boots | tail -3
+echo "--- sources: is an IP-addressed one selected? ---"
+chronyc -n sources
+echo "--- clock + DNS ---"
+timedatectl | grep -iE 'Local time|RTC time|synchronized'
+getent hosts gnu.org || echo "DNS DEAD"
+#+end_src
+Expected: even if the RTC came up at 2025-01-01 and systemd advanced the clock
+to 2026-07-23, chrony reached 162.159.200.1 without DNS, stepped the clock to
+now, and names resolve. You did nothing.
+
+If instead the clock is still wrong or DNS is dead, the fix did not hold in the
+field despite holding under a simulated skew. Capture that whole block and
+promote this to a top-level TODO.
+
*** Floating layout: freeze positions, border flash, glyph, exit to master
What we're verifying: the rebuilt floating mode (Super+Shift+F) floats every window on the workspace via per-window setfloating (the old workspaceopt allfloat was deprecated and no-op'd, which is why nothing floated), freezes each in place, flashes the border gold on entry and exit, flips the waybar glyph to the floating icon, and exits to master. Live-verified on a headless output already (windows floated in place, dragged to overlap, glyph read Floating, toggled back clean); this is the on-your-own-monitor confirmation.
- Go to a workspace with 2-3 tiled windows in master.
@@ -1687,9 +4218,1071 @@ NOTE (2026-07-04 audit): the "four-tab panel" framing predates the instrument-co
- Expected: ↓/↑ Mbps + ping + server shown inline.
- Byte-rate→Mbps unit: VERIFIED 2026-06-30 (velox). Raw =speedtest-go --json= dl_speed read ~3.66M, unambiguously bytes/s (29 down / 80 up Mbps); =net speedtest= reported 33.62 / 77.99 through the wired path. =BYTES_PER_SEC = True= + =* 8 / 1e6= are correct, no flip needed. Remaining here is only that the panel renders the inline result.
+*** Hibernate entry freeze: five observed hibernate cycles on AC
+What we're verifying: whether velox hard-freezes on hibernate entry (black
+screen, power LED on, never powers off), the documented Framework 13 AMD
+failure that fits both dead-battery events. The journal cannot show it; a
+person watching the LED can.
+- Plug in AC, lid open, nothing important unsaved.
+- Note the cycle number, then hibernate from a terminal:
+#+begin_src sh :results output
+date; systemctl hibernate
+#+end_src
+- Watch: the screen goes black; within about two minutes the power LED goes
+ off and the fans stop.
+- Press power, enter the ZBM passphrase, and confirm the same session comes
+ back (windows still open).
+- Check that the cycle was a real hibernate and not a fallback:
+#+begin_src sh :results output
+journalctl -b -o short-iso | grep -E "systemd-sleep|hibernation (entry|exit)|Image allocation|Failed to put" | tail -6
+#+end_src
+- Check bluetooth came back. A hibernate re-probes the controller, and the
+ resume hook's btusb reload is what un-wedges it:
+#+begin_src sh :results output
+rfkill list bluetooth | grep Soft; bluetoothctl show | grep Powered; journalctl -b -o short-iso | grep -E "btusb|unblock set for type bluetooth|Failed to set mode" | tail -6
+#+end_src
+- Repeat until five cycles are logged.
+Expected: all five cycles power off within two minutes and resume into the
+same session, with "hibernation exit" and no "Image allocation … short" line.
+After each cycle bluetooth reads "Soft blocked: no" and "Powered: yes", the
+journal shows a btusb reload and "unblock set for type bluetooth", and no
+"Failed to set mode" line.
+A cycle where the screen stays black with the power LED on for more than five
+minutes is the entry freeze: hold power for 10 s, and write down the cycle
+number and whether the keyboard backlight was lit. A cycle that instead comes
+straight back with "Cannot allocate memory" is the ARC task, not a freeze.
+
+*** M-split-console: an everyday run with no compositor defers only kernel-kind entries
+What we're verifying: with Hyprland not running, =upgrade-guarded
+--no-topgrade= lands the pending GPU/compositor packages on real pacman and
+leaves only kernel-kind entries deferred (AC3's held-set claim). It's the
+first test of the VM gate.
+- Make sure Phase 1's commits are in HEAD (=make test-keep= bundles
+ archsetup from HEAD).
+#+begin_src sh :results output
+# VM helpers; every later block in this test sources them. Root logs in by
+# key only after the install, so vmroot uses the key the harness left with
+# the newest test results.
+cat > /tmp/ug-vm.sh <<'EOF'
+cd ~/code/archsetup || exit 1
+o="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -p 2222"
+key=$(ls -td test-results/*/root_key 2>/dev/null | head -1)
+vm() { sshpass -p archsetup ssh $o cjennings@localhost "$@"; }
+vmroot() { ssh $o -i "$key" root@localhost "$@"; }
+mon() { echo "$*" | socat - UNIX-CONNECT:vm-images/qemu-monitor-zfs.sock; }
+shot() { mon screendump /tmp/ug-tty1.ppm >/dev/null; sleep 1
+ magick /tmp/ug-tty1.ppm /tmp/ug-tty1.png && echo /tmp/ug-tty1.png; }
+shots() { d=/tmp/ug-shots; rm -rf "$d"; mkdir -p "$d"
+ for i in $(seq -w 1 "${1:-90}"); do mon "screendump $d/$i.ppm" >/dev/null; sleep 2; done
+ for f in "$d"/*.ppm; do magick "$f" "${f%.ppm}.png" && rm -f "$f"; done
+ echo "$(ls "$d" | wc -l) frames, 2 s apart, in $d"; }
+EOF
+echo "helpers written to /tmp/ug-vm.sh"
+#+end_src
+#+begin_src sh :results output
+# The full install runs first; this takes a while, and Emacs waits on it.
+cd ~/code/archsetup && make test-keep FS_PROFILE=zfs 2>&1 | tail -6
+#+end_src
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+vm 'pacman -Q wayland libdrm $(pacman -Qqo /usr/lib/modules/*/vmlinuz)'
+#+end_src
+- On https://archive.archlinux.org/packages/, find the release before the
+ installed one for =wayland= or =libdrm= (whichever downgrades without
+ breaking a dependency), and for the VM's kernel and its headers.
+- Paste the three URLs into the next block.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+gpu_url='PASTE-URL'; kernel_url='PASTE-URL'; headers_url='PASTE-URL'
+vmroot "pacman -U --noconfirm $gpu_url $kernel_url $headers_url" | tail -3
+vm 'LC_ALL=C pacman -Qu'
+#+end_src
+- Confirm Hyprland isn't running in the VM. If the block prints LIVE, stop
+ and record it under this test: the VM procedure's premise has failed.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+vm 'pgrep -x Hyprland >/dev/null && echo "Hyprland LIVE: the test premise fails" || echo "Hyprland not running"'
+#+end_src
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+vm 'upgrade-guarded --no-topgrade > /tmp/ug-console.log 2>&1; echo "exit: $?"
+tail -3 /tmp/ug-console.log
+jq -r ".data.packages[] | \"\(.kind) \(.name)\"" ~/.local/state/maint/upgrade_deferred.json
+echo "-- pacman -Qu:"; LC_ALL=C pacman -Qu'
+#+end_src
+Expected: Hyprland was not running; the run printed exit 0; the GPU-pattern
+package is back at its current version; every record line reads kernel;
+and =pacman -Qu= lists only the kernel and its headers, plus any
+=[ignored]= row.
+
+*** M-split-live on ratio: the live everyday run leaves the guard silent
+What we're verifying: the everyday split transaction on real pacman with
+Hyprland live, before any lever calls it. It should exit 0, print no
+BLOCKED banner, and defer exactly what =--dry-run= predicted (AC1). It's
+the last item of step (a) on ratio.
+- Run it in ratio's live Hyprland session, right after the rest of step
+ (a).
+#+begin_src sh :results output
+upgrade-guarded --dry-run > /tmp/ug-dry.txt 2>&1; echo "dry-run exit: $?"
+cat /tmp/ug-dry.txt
+#+end_src
+- Note the predicted deferred set, and whether it lists a GPU-kind entry.
+#+begin_src sh :results output
+# The real run: refresh, -Su with the held set ignored, then yay -Sua. It
+# takes minutes, and Emacs waits on it.
+upgrade-guarded --no-topgrade > /tmp/ug-live.log 2>&1; echo "exit: $?"
+echo "BLOCKED lines: $(grep -c BLOCKED /tmp/ug-live.log)"
+jq -r '.data.packages[] | "\(.kind) \(.name)"' ~/.local/state/maint/upgrade_deferred.json | sort
+echo "-- pacman -Qu:"; LC_ALL=C pacman -Qu
+#+end_src
+- If the dry-run listed no GPU-kind entry, leave this test open and repeat
+ it on the first day one is pending. The hook-silence half needs one.
+Expected: exit 0; zero BLOCKED lines; the record's names equal the
+dry-run's predicted deferred set; =pacman -Qu= lists only those names plus
+the =[ignored]= bridge-utils row; and on a run with a GPU-kind entry
+pending, that entry is in the record and the guard never printed its
+banner.
+
+*** M-split-live on velox: the live everyday run leaves the guard silent
+What we're verifying: the same everyday split transaction on velox's ZFS
+root with Hyprland live, before any lever calls it. It should exit 0, print
+no BLOCKED banner, and defer exactly what =--dry-run= predicted, with the
+kernel and zfs-dkms among the deferred entries when pending (AC1). It's the
+last item of step (a) on velox.
+- Run it in velox's live Hyprland session, right after the rest of step
+ (a).
+#+begin_src sh :results output
+uname -r
+upgrade-guarded --dry-run > /tmp/ug-dry.txt 2>&1; echo "dry-run exit: $?"
+cat /tmp/ug-dry.txt
+#+end_src
+- Note the predicted deferred set, and whether it lists a GPU-kind entry.
+#+begin_src sh :results output
+# The real run: refresh, -Su with the held set ignored, then yay -Sua. It
+# takes minutes, and Emacs waits on it.
+upgrade-guarded --no-topgrade > /tmp/ug-live.log 2>&1; echo "exit: $?"
+echo "BLOCKED lines: $(grep -c BLOCKED /tmp/ug-live.log)"
+jq -r '.data.packages[] | "\(.kind) \(.name)"' ~/.local/state/maint/upgrade_deferred.json | sort
+echo "-- pacman -Qu:"; LC_ALL=C pacman -Qu
+pacman -Q $(pacman -Qqo /usr/lib/modules/*/vmlinuz)
+#+end_src
+- If the dry-run listed no GPU-kind entry, leave this test open and repeat
+ it on the first day one is pending. The hook-silence half needs one.
+Expected: exit 0; zero BLOCKED lines; the record's names equal the
+dry-run's predicted deferred set; =pacman -Qu= lists only those names plus
+any =[ignored]= rows; the kernel package's version is unchanged; and on a
+run with a GPU-kind entry pending, that entry is in the record and the
+guard never printed its banner.
+
+*** M-boot-armed in the ZFS VM: the armed set lands from cache on tty1 before login, offline
+What we're verifying: the completion path on a ZFS root with no network at
+boot. =--complete= should pass the real gate (=sudo -n lsinitcpio=
+included) and arm without firing the guard or informant's hook. The boot
+unit should then apply the armed set before getty@tty1 starts (AC5, and
+AC4's gate on a real initramfs). Second test of the VM gate.
+- Make sure Phase 1's and Phase 3's commits are in HEAD.
+#+begin_src sh :results output
+# VM helpers; every later block in this test sources them. Root logs in by
+# key only after the install, so vmroot uses the key the harness left with
+# the newest test results.
+cat > /tmp/ug-vm.sh <<'EOF'
+cd ~/code/archsetup || exit 1
+o="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -p 2222"
+key=$(ls -td test-results/*/root_key 2>/dev/null | head -1)
+vm() { sshpass -p archsetup ssh $o cjennings@localhost "$@"; }
+vmroot() { ssh $o -i "$key" root@localhost "$@"; }
+mon() { echo "$*" | socat - UNIX-CONNECT:vm-images/qemu-monitor-zfs.sock; }
+shot() { mon screendump /tmp/ug-tty1.ppm >/dev/null; sleep 1
+ magick /tmp/ug-tty1.ppm /tmp/ug-tty1.png && echo /tmp/ug-tty1.png; }
+shots() { d=/tmp/ug-shots; rm -rf "$d"; mkdir -p "$d"
+ for i in $(seq -w 1 "${1:-90}"); do mon "screendump $d/$i.ppm" >/dev/null; sleep 2; done
+ for f in "$d"/*.ppm; do magick "$f" "${f%.ppm}.png" && rm -f "$f"; done
+ echo "$(ls "$d" | wc -l) frames, 2 s apart, in $d"; }
+EOF
+echo "helpers written to /tmp/ug-vm.sh"
+#+end_src
+#+begin_src sh :results output
+# The full install runs first; this takes a while, and Emacs waits on it.
+cd ~/code/archsetup && make test-keep FS_PROFILE=zfs 2>&1 | tail -6
+#+end_src
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+vm 'pacman -Q wayland libdrm $(pacman -Qqo /usr/lib/modules/*/vmlinuz)'
+#+end_src
+- On https://archive.archlinux.org/packages/, find the release before the
+ installed one for =wayland= or =libdrm=, and for the VM's kernel and its
+ headers.
+- Paste the three URLs into the next block.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+gpu_url='PASTE-URL'; kernel_url='PASTE-URL'; headers_url='PASTE-URL'
+vmroot "pacman -U --noconfirm $gpu_url $kernel_url $headers_url" | tail -3
+vm 'LC_ALL=C pacman -Qu'
+#+end_src
+- Arm over SSH with the live branch forced, since Hyprland never runs in
+ the VM.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+vm 'UPGRADE_GUARDED_HYPR_RUNNING=1 upgrade-guarded --complete > /tmp/ug-arm.log 2>&1; echo "exit: $?"
+echo "pre-transaction hook runs: $(grep -c "Running pre-transaction hooks" /tmp/ug-arm.log)"
+echo "BLOCKED lines: $(grep -c BLOCKED /tmp/ug-arm.log)"
+echo "-- flag:"; cat /var/lib/archsetup/apply-upgrade-on-boot
+echo "-- record:"; jq -c ".data | {result, gate, held_snapshot, n: (.packages | length)}" ~/.local/state/maint/upgrade_deferred.json'
+#+end_src
+- Reboot the guest and take its network link down straight away.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+vm 'sudo systemctl reboot'; sleep 3; mon set_link net0 off >/dev/null; echo "link off"
+#+end_src
+- Capture tty1 for three minutes while it boots.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+shots 90
+#+end_src
+- Open the frames in order (=imv /tmp/ug-shots/=).
+- Bring the link back.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+mon set_link net0 on >/dev/null; sleep 20; echo "link on"
+#+end_src
+- Read the boot's outcome.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+vm 'journalctl -b -t archsetup-boot-upgrade --no-pager | head -2
+systemctl show archsetup-boot-upgrade -p Result -p ExecMainExitTimestampMonotonic
+systemctl show getty@tty1 -p ExecMainStartTimestampMonotonic
+ls /var/lib/archsetup/apply-upgrade-on-boot 2>&1
+jq -c ".data | {result, failed_step, n: (.packages | length)}" ~/.local/state/maint/upgrade_deferred.json
+echo "stamp age: $(( $(date +%s) - $(jq -r ".written_at | floor" ~/.local/state/maint/topgrade_run.json) )) s"
+echo "uptime: $(cut -d. -f1 /proc/uptime) s"'
+#+end_src
+Expected: the arm exited 0 with exactly one pre-transaction hook run (stage
+1's; the arm is forced live, so no stage 2 runs, and the arm's =-Sw= ran no
+hooks) and zero BLOCKED lines. The flag listed the GPU entries as
+=name=version= lines, and the record had gate null with held_snapshot
+naming a pre-pacman snapshot. A frame shows 'archsetup: applying N deferred
+GPU/compositor upgrades — do not power off' on tty1 before any frame shows
+the login, and the journal's first line is that banner. Result=success, and
+the unit's exit timestamp is below getty@tty1's start timestamp. Afterwards
+the flag is gone, the record reads result ok with n 0, and the stamp's age
+is below the uptime.
+
+*** M-boot-midtx in the ZFS VM: a timeout inside a real transaction leaves no db.lck
+What we're verifying: when the start timeout's SIGINT lands in the middle
+of a real transaction, pacman should stop at a package boundary and
+release =db.lck=. The record should name the interruption, and
+=--complete= from a console should finish the set (AC6). Third test of the
+VM gate, on the VM M-boot-armed leaves.
+- Use the VM M-boot-armed just left. Nothing reinstalls between the two
+ tests, so its journal still times a real boot transaction.
+#+begin_src sh :results output
+# VM helpers; every later block in this test sources them. Root logs in by
+# key only after the install, so vmroot uses the key the harness left with
+# the newest test results.
+cat > /tmp/ug-vm.sh <<'EOF'
+cd ~/code/archsetup || exit 1
+o="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -p 2222"
+key=$(ls -td test-results/*/root_key 2>/dev/null | head -1)
+vm() { sshpass -p archsetup ssh $o cjennings@localhost "$@"; }
+vmroot() { ssh $o -i "$key" root@localhost "$@"; }
+mon() { echo "$*" | socat - UNIX-CONNECT:vm-images/qemu-monitor-zfs.sock; }
+shot() { mon screendump /tmp/ug-tty1.ppm >/dev/null; sleep 1
+ magick /tmp/ug-tty1.ppm /tmp/ug-tty1.png && echo /tmp/ug-tty1.png; }
+shots() { d=/tmp/ug-shots; rm -rf "$d"; mkdir -p "$d"
+ for i in $(seq -w 1 "${1:-90}"); do mon "screendump $d/$i.ppm" >/dev/null; sleep 2; done
+ for f in "$d"/*.ppm; do magick "$f" "${f%.ppm}.png" && rm -f "$f"; done
+ echo "$(ls "$d" | wc -l) frames, 2 s apart, in $d"; }
+EOF
+echo "helpers written to /tmp/ug-vm.sh"
+#+end_src
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+vm 'journalctl -t archsetup-boot-upgrade -o short-monotonic --no-pager | grep -E "applying|upgrading|installing|transaction" | tail -12'
+#+end_src
+- On https://archive.archlinux.org/packages/, find the previous releases of
+ two or more GPU-pattern packages, mesa among them, so the transaction is
+ long enough to cut.
+- Paste their URLs into the next block.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+gpu_urls='PASTE-URL PASTE-URL'
+vmroot "pacman -U --noconfirm $gpu_urls" | tail -2; vm 'LC_ALL=C pacman -Qu'
+#+end_src
+- Arm over SSH with the live branch forced.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+vm 'UPGRADE_GUARDED_HYPR_RUNNING=1 upgrade-guarded --complete > /tmp/ug-arm.log 2>&1; echo "exit: $?"
+cat /var/lib/archsetup/apply-upgrade-on-boot'
+#+end_src
+- Work out a start timeout that lands inside the transaction: the seconds
+ from the banner to about halfway through the 'upgrading' lines above,
+ scaled up for the larger set.
+- Put it in the next block.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+secs=PASTE-SECONDS
+vmroot "install -d /etc/systemd/system/archsetup-boot-upgrade.service.d
+printf '[Service]\nTimeoutStartSec=%ss\n' $secs > /etc/systemd/system/archsetup-boot-upgrade.service.d/test.conf
+systemctl daemon-reload && cat /etc/systemd/system/archsetup-boot-upgrade.service.d/test.conf"
+#+end_src
+- Reboot the guest.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+vm 'sudo systemctl reboot'; echo rebooting
+#+end_src
+- Capture tty1 for three minutes.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+shots 90
+#+end_src
+- Open the frames in order (=imv /tmp/ug-shots/=).
+- Read the outcome once the login shows.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+vm 'ls /var/lib/pacman/db.lck 2>&1
+jq -c ".data | {result, failed_step, detail, n: (.packages | length)}" ~/.local/state/maint/upgrade_deferred.json
+journalctl -b -t archsetup-boot-upgrade --no-pager | tail -4'
+#+end_src
+- If the journal shows the transaction finished, or never started, change
+ the seconds and repeat from the downgrade block.
+- Confirm Hyprland isn't running, so the next =--complete= applies the set
+ rather than arming it. If the block prints LIVE, record it under this
+ test.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+vm 'pgrep -x Hyprland >/dev/null && echo "Hyprland LIVE: the test premise fails" || echo "Hyprland not running"'
+#+end_src
+- Finish the set from a console.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+vm 'upgrade-guarded --complete > /tmp/ug-finish.log 2>&1; echo "exit: $?"
+LC_ALL=C pacman -Qu
+jq -c ".data | {result, n: (.packages | length)}" ~/.local/state/maint/upgrade_deferred.json'
+#+end_src
+- Remove the drop-in.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+vmroot 'rm -rf /etc/systemd/system/archsetup-boot-upgrade.service.d && systemctl daemon-reload && echo removed'
+#+end_src
+Expected: no =db.lck= after the cut-off boot, and the journal stopped
+between packages. The record read result interrupted with failed_step
+boot-transaction and the =--complete= remedy. Then the console
+=--complete= exited 0, =pacman -Qu= no longer lists the armed packages, and
+the record reads result ok with n 0.
+
+*** M-boot-timeout in the ZFS VM: a hung boot run times out to the login and disarms
+What we're verifying: a boot transaction that never ends is cut off by the
+start timeout (shortened here to 90 s). The login should still appear, the
+flag should be gone, the failure should be visible in the record and in
+maint, and the next boot should skip the unit (AC6). Fourth test of the VM
+gate.
+#+begin_src sh :results output
+# VM helpers; every later block in this test sources them. Root logs in by
+# key only after the install, so vmroot uses the key the harness left with
+# the newest test results.
+cat > /tmp/ug-vm.sh <<'EOF'
+cd ~/code/archsetup || exit 1
+o="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -p 2222"
+key=$(ls -td test-results/*/root_key 2>/dev/null | head -1)
+vm() { sshpass -p archsetup ssh $o cjennings@localhost "$@"; }
+vmroot() { ssh $o -i "$key" root@localhost "$@"; }
+mon() { echo "$*" | socat - UNIX-CONNECT:vm-images/qemu-monitor-zfs.sock; }
+shot() { mon screendump /tmp/ug-tty1.ppm >/dev/null; sleep 1
+ magick /tmp/ug-tty1.ppm /tmp/ug-tty1.png && echo /tmp/ug-tty1.png; }
+shots() { d=/tmp/ug-shots; rm -rf "$d"; mkdir -p "$d"
+ for i in $(seq -w 1 "${1:-90}"); do mon "screendump $d/$i.ppm" >/dev/null; sleep 2; done
+ for f in "$d"/*.ppm; do magick "$f" "${f%.ppm}.png" && rm -f "$f"; done
+ echo "$(ls "$d" | wc -l) frames, 2 s apart, in $d"; }
+EOF
+echo "helpers written to /tmp/ug-vm.sh"
+#+end_src
+- Run the next block for a fresh VM, or skip it to reuse the VM
+ M-boot-midtx left (the block reinstalls from clean).
+#+begin_src sh :results output
+# The full install runs first; this takes a while, and Emacs waits on it.
+cd ~/code/archsetup && make test-keep FS_PROFILE=zfs 2>&1 | tail -6
+#+end_src
+- On https://archive.archlinux.org/packages/, find the release before the
+ installed =wayland= or =libdrm=.
+- Paste its URL into the next block.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+gpu_url='PASTE-URL'
+vmroot "pacman -U --noconfirm $gpu_url" | tail -2; vm 'LC_ALL=C pacman -Qu'
+#+end_src
+- Arm over SSH with the live branch forced.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+vm 'UPGRADE_GUARDED_HYPR_RUNNING=1 upgrade-guarded --complete > /tmp/ug-arm.log 2>&1; echo "exit: $?"
+cat /var/lib/archsetup/apply-upgrade-on-boot'
+#+end_src
+- Install the stand-in fakes and the drop-in. The fake pacman hands every
+ query to the real one and sleeps on the transaction, and the fake sudo
+ drops =-n=. They do the job the Phase 1 fakes do.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+vmroot 'set -e
+d=/usr/local/lib/ug-fakes
+install -d "$d" /etc/systemd/system/archsetup-boot-upgrade.service.d
+cat > "$d/sudo" <<"EOF"
+#!/bin/sh
+# Drop -n and run the command as the caller.
+[ "$1" = -n ] && shift
+exec "$@"
+EOF
+cat > "$d/pacman" <<"EOF"
+#!/bin/sh
+# Real pacman for every query; the transaction (-S) sleeps or fails.
+if [ "$1" = -S ]; then
+ [ "$UG_FAKE_MODE" = fail ] && { echo "error: failed to commit transaction (fake)" >&2; exit 1; }
+ exec sleep 3600
+fi
+exec /usr/bin/pacman "$@"
+EOF
+chmod 755 "$d/sudo" "$d/pacman"
+cat > /etc/systemd/system/archsetup-boot-upgrade.service.d/test.conf <<"EOF"
+[Service]
+TimeoutStartSec=90s
+Environment=PATH=/usr/local/lib/ug-fakes:/usr/local/bin:/usr/bin
+Environment=UG_FAKE_MODE=sleep
+EOF
+systemctl daemon-reload && echo installed'
+#+end_src
+- Reboot the guest.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+vm 'sudo systemctl reboot'; echo rebooting
+#+end_src
+- Capture tty1 for four minutes.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+shots 120
+#+end_src
+- Open the frames in order (=imv /tmp/ug-shots/=).
+- Read the outcome once the login shows.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+vm 'ls /var/lib/archsetup/apply-upgrade-on-boot 2>&1
+systemctl is-failed archsetup-boot-upgrade.service
+systemctl show archsetup-boot-upgrade -p ExecMainStartTimestampMonotonic
+systemctl show getty@tty1 -p ExecMainStartTimestampMonotonic
+jq -c ".data | {result, failed_step, detail, n: (.packages | length)}" ~/.local/state/maint/upgrade_deferred.json
+ls /var/lib/pacman/db.lck 2>&1
+~/.local/bin/maint status 2>&1 | grep -i -E "failed|deferred"'
+#+end_src
+- Reboot the guest once more.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+vm 'sudo systemctl reboot'; echo rebooting
+#+end_src
+- About a minute later, read whether the unit ran.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+vm 'systemctl show archsetup-boot-upgrade -p ConditionResult -p ActiveState'
+#+end_src
+- Remove the drop-in and the fakes.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+vmroot 'rm -rf /etc/systemd/system/archsetup-boot-upgrade.service.d /usr/local/lib/ug-fakes && systemctl daemon-reload && echo removed'
+#+end_src
+Expected: the login appeared about 90 s into the unit: getty@tty1's start
+timestamp is about 90,000,000 µs above the unit's
+ExecMainStartTimestampMonotonic. After it, the flag was gone, =is-failed=
+printed failed, and no =db.lck= existed. The record read result
+interrupted with failed_step boot-transaction, a detail naming
+=upgrade-guarded --complete=, and n equal to the armed count. maint's
+failed-units row names archsetup-boot-upgrade.service, and the deferred row
+lists the armed set at WARN. The next boot shows ConditionResult=no.
+
+*** M-boot-fail in the ZFS VM: a failed boot transaction still reaches the login and disarms
+What we're verifying: a boot transaction that fails outright should still
+end at the tty1 login. The flag should be gone, the record should name the
+failed step, maint should show the failed unit and the deferred set, and
+the unit should be left failed (AC6). Last test of the VM gate.
+#+begin_src sh :results output
+# VM helpers; every later block in this test sources them. Root logs in by
+# key only after the install, so vmroot uses the key the harness left with
+# the newest test results.
+cat > /tmp/ug-vm.sh <<'EOF'
+cd ~/code/archsetup || exit 1
+o="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -p 2222"
+key=$(ls -td test-results/*/root_key 2>/dev/null | head -1)
+vm() { sshpass -p archsetup ssh $o cjennings@localhost "$@"; }
+vmroot() { ssh $o -i "$key" root@localhost "$@"; }
+mon() { echo "$*" | socat - UNIX-CONNECT:vm-images/qemu-monitor-zfs.sock; }
+shot() { mon screendump /tmp/ug-tty1.ppm >/dev/null; sleep 1
+ magick /tmp/ug-tty1.ppm /tmp/ug-tty1.png && echo /tmp/ug-tty1.png; }
+shots() { d=/tmp/ug-shots; rm -rf "$d"; mkdir -p "$d"
+ for i in $(seq -w 1 "${1:-90}"); do mon "screendump $d/$i.ppm" >/dev/null; sleep 2; done
+ for f in "$d"/*.ppm; do magick "$f" "${f%.ppm}.png" && rm -f "$f"; done
+ echo "$(ls "$d" | wc -l) frames, 2 s apart, in $d"; }
+EOF
+echo "helpers written to /tmp/ug-vm.sh"
+#+end_src
+- Run the next block for a fresh VM, or skip it to reuse the VM the
+ previous M-boot test left (the block reinstalls from clean).
+#+begin_src sh :results output
+# The full install runs first; this takes a while, and Emacs waits on it.
+cd ~/code/archsetup && make test-keep FS_PROFILE=zfs 2>&1 | tail -6
+#+end_src
+- On https://archive.archlinux.org/packages/, find the release before the
+ installed =wayland= or =libdrm=.
+- Paste its URL into the next block.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+gpu_url='PASTE-URL'
+vmroot "pacman -U --noconfirm $gpu_url" | tail -2; vm 'LC_ALL=C pacman -Qu'
+#+end_src
+- Arm over SSH with the live branch forced.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+vm 'UPGRADE_GUARDED_HYPR_RUNNING=1 upgrade-guarded --complete > /tmp/ug-arm.log 2>&1; echo "exit: $?"
+cat /var/lib/archsetup/apply-upgrade-on-boot'
+#+end_src
+- Install the stand-in fakes and the drop-in, with the fake pacman set to
+ answer =-Q= and =-Sp= normally and exit 1 on =-S=.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+vmroot 'set -e
+d=/usr/local/lib/ug-fakes
+install -d "$d" /etc/systemd/system/archsetup-boot-upgrade.service.d
+cat > "$d/sudo" <<"EOF"
+#!/bin/sh
+# Drop -n and run the command as the caller.
+[ "$1" = -n ] && shift
+exec "$@"
+EOF
+cat > "$d/pacman" <<"EOF"
+#!/bin/sh
+# Real pacman for every query; the transaction (-S) sleeps or fails.
+if [ "$1" = -S ]; then
+ [ "$UG_FAKE_MODE" = fail ] && { echo "error: failed to commit transaction (fake)" >&2; exit 1; }
+ exec sleep 3600
+fi
+exec /usr/bin/pacman "$@"
+EOF
+chmod 755 "$d/sudo" "$d/pacman"
+cat > /etc/systemd/system/archsetup-boot-upgrade.service.d/test.conf <<"EOF"
+[Service]
+TimeoutStartSec=90s
+Environment=PATH=/usr/local/lib/ug-fakes:/usr/local/bin:/usr/bin
+Environment=UG_FAKE_MODE=fail
+EOF
+systemctl daemon-reload && echo installed'
+#+end_src
+- Reboot the guest.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+vm 'sudo systemctl reboot'; echo rebooting
+#+end_src
+- Capture tty1 for two minutes.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+shots 60
+#+end_src
+- Open the frames in order (=imv /tmp/ug-shots/=).
+- Read the outcome once the login shows.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+vm 'ls /var/lib/archsetup/apply-upgrade-on-boot 2>&1
+systemctl is-failed archsetup-boot-upgrade.service
+jq -c ".data | {result, failed_step, detail, n: (.packages | length)}" ~/.local/state/maint/upgrade_deferred.json
+~/.local/bin/maint status 2>&1 | grep -i -E "failed|deferred"'
+#+end_src
+- Remove the drop-in and the fakes.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+vmroot 'rm -rf /etc/systemd/system/archsetup-boot-upgrade.service.d /usr/local/lib/ug-fakes && systemctl daemon-reload && echo removed'
+#+end_src
+Expected: the login appeared without waiting out the timeout, the flag is
+gone, and =is-failed= prints failed. The record reads result failed with
+failed_step boot-transaction, a detail whose last line is the fake's
+'error: failed to commit transaction (fake)', and n equal to the armed
+count. maint's failed-units row names archsetup-boot-upgrade.service, and
+the deferred row lists the armed set at WARN.
+
+*** M-boot-armed on ratio: APPLY arms, the row shows it, and the offline reboot applies the set
+What we're verifying: the panel path the VM can't cover. APPLY should open
+=upgrade-guarded --complete= in a detached foot terminal, the row should
+read armed with REBOOT shown, and an offline armed boot should apply the
+set before the login (AC4's APPLY clause, AC5).
+- Run it after step (c) on ratio, on a day the dry-run below lists a
+ GPU-kind entry.
+#+begin_src sh :results output
+upgrade-guarded --dry-run 2>&1 | tail -20
+#+end_src
+- Open the maint panel.
+- Find the deferred row.
+- Press APPLY on the deferred row. The first press arms.
+- Press APPLY again to fire it.
+- Watch the panel's wall and the foot terminal that opens, until the
+ terminal shows its 'Reboot now?' prompt.
+- Press Enter at the prompt, which answers no.
+- Wait up to 30 s for the panel's next probe.
+- Read the deferred row and the REBOOT key.
+- Read the flag.
+#+begin_src sh :results output
+cat /var/lib/archsetup/apply-upgrade-on-boot
+#+end_src
+- Take networking down.
+#+begin_src sh :results output
+nmcli networking off && echo "networking off"
+#+end_src
+- Press REBOOT on the panel.
+- Confirm the reboot the way the panel asks.
+- Watch the monitor through the boot until the tty1 password prompt.
+- Log in.
+- Bring networking back.
+#+begin_src sh :results output
+nmcli networking on && echo "networking on"
+#+end_src
+#+begin_src sh :results output
+ls /var/lib/archsetup/apply-upgrade-on-boot 2>&1
+jq -c '.data | {result, failed_step, n: (.packages | length)}' ~/.local/state/maint/upgrade_deferred.json
+maint status 2>&1 | grep -i -E 'topgrade|deferred'
+#+end_src
+Expected: the second APPLY press opened a foot terminal running
+=upgrade-guarded --complete=, and the panel's wall streamed nothing. The
+terminal ended at the prompt with no BLOCKED banner. Before the reboot, the
+row read 'armed — A apply at next boot' (with ' · <N−A> more deferred' if
+anything else stayed deferred) and REBOOT was shown. The banner showed on
+the monitor before the password prompt. Afterwards the flag is gone, the
+record reads result ok with n 0, and =maint status= shows topgrade_age
+fresh when nothing else is deferred.
+
+*** M-boot-ratio: the banner and progress show on ratio's monitor, and REBOOT shows while armed
+What we're verifying: ratio's own boot path. Its =/dev/console= is ttyS0,
+so the unit's tty1 output has to reach the monitor. Its tty1 shows a
+password prompt that the =Before=getty@tty1.service= ordering holds. And
+REBOOT shows from the flag alone, since reboot_required stays false there
+(AC5, and Phase 4's ratio-path confirmation).
+- Run it on the boot M-boot-armed on ratio arms, doing the steps up to the
+ REBOOT press before that test's own REBOOT press; or arm the same way.
+#+begin_src sh :results output
+ls -l /var/lib/archsetup/apply-upgrade-on-boot
+maint status 2>&1 | grep -i reboot
+#+end_src
+- Read the panel's REBOOT key.
+- Press REBOOT on the panel.
+- Confirm the reboot the way the panel asks.
+- Watch the monitor from the firmware screen to the tty1 password prompt.
+- Log in.
+#+begin_src sh :results output
+systemctl show archsetup-boot-upgrade -p ExecMainExitTimestampMonotonic
+systemctl show getty@tty1 -p ExecMainStartTimestampMonotonic
+#+end_src
+Expected: before the reboot, reboot_required read clear and REBOOT was
+shown anyway. The banner and pacman's per-package progress stayed on the
+monitor for the whole transaction, and the password prompt appeared only
+after it. The unit's exit timestamp is below getty@tty1's start timestamp.
+
+*** ratio's linux-lts-strix is never moved or gated
+What we're verifying: the foreign, headerless GRUB-default kernel stays out
+of the pending set and the gate entry on a real =--complete= (Phase 4's
+ratio-path confirmation).
+- Run it on ratio the first time a =--complete= lands linux or linux-lts.
+#+begin_src sh :results output
+pacman -Q linux-lts-strix | tee /tmp/ug-strix-before.txt
+LC_ALL=C pacman -Qu | grep strix || echo "strix not pending"
+#+end_src
+- Start =upgrade-guarded --complete= with APPLY, or in a terminal.
+- While stage 1 runs, read the gate entry from a second terminal.
+#+begin_src sh :results output
+jq -c '.data.gate' ~/.local/state/maint/upgrade_deferred.json
+#+end_src
+- Let the session finish.
+#+begin_src sh :results output
+pacman -Q linux-lts-strix | diff /tmp/ug-strix-before.txt - && echo "strix unchanged"
+grep "$(date +%Y-%m-%d)" /var/log/pacman.log | grep linux-lts-strix || echo "no strix lines today"
+#+end_src
+Expected: strix wasn't pending; gate.pkgbases during stage 1 listed linux
+and/or linux-lts and never linux-lts-strix; strix's version is unchanged;
+and no pacman.log line from today names it.
+
+*** M-boot-armed on velox: APPLY arms, the row shows it, and the offline reboot applies the set
+What we're verifying: the same panel path on velox's ZFS root with
+autologin. APPLY's detached terminal should run the real gate when a
+kernel is pending, the row should read armed with REBOOT shown, and an
+offline armed boot should finish before autologin starts Hyprland (AC4's
+APPLY clause, AC5).
+- Run it after step (c) on velox, on a day the dry-run below lists a
+ GPU-kind entry.
+#+begin_src sh :results output
+upgrade-guarded --dry-run 2>&1 | tail -20
+#+end_src
+- Open the maint panel.
+- Find the deferred row.
+- Press APPLY on the deferred row. The first press arms.
+- Press APPLY again to fire it.
+- Watch the panel's wall and the foot terminal that opens, until the
+ terminal shows its 'Reboot now?' prompt.
+- Press Enter at the prompt, which answers no.
+- Wait up to 30 s for the panel's next probe.
+- Read the deferred row and the REBOOT key.
+- Read the flag.
+#+begin_src sh :results output
+cat /var/lib/archsetup/apply-upgrade-on-boot
+#+end_src
+- Take networking down.
+#+begin_src sh :results output
+nmcli networking off && echo "networking off"
+#+end_src
+- Press REBOOT on the panel.
+- Confirm the reboot the way the panel asks.
+- Watch tty1 from the ZFSBootMenu countdown until autologin starts
+ Hyprland.
+- Bring networking back.
+#+begin_src sh :results output
+nmcli networking on && echo "networking on"
+#+end_src
+#+begin_src sh :results output
+ls /var/lib/archsetup/apply-upgrade-on-boot 2>&1
+jq -c '.data | {result, failed_step, gate, n: (.packages | length)}' ~/.local/state/maint/upgrade_deferred.json
+journalctl -b -t archsetup-boot-upgrade --no-pager | head -2
+maint status 2>&1 | grep -i -E 'topgrade|deferred'
+#+end_src
+Expected: the second APPLY press opened a foot terminal running
+=upgrade-guarded --complete=, and the panel's wall streamed nothing. If a
+kernel was pending, the terminal showed the gate passing before the arm.
+Before the reboot the row read 'armed — A apply at next boot' and REBOOT
+was shown. The banner showed on tty1 before Hyprland started, and the
+journal's first line is that banner. Afterwards the flag is gone, the
+record reads result ok with gate null and n 0, and =maint status= shows
+topgrade_age fresh when nothing else is deferred.
+
+*** M-boot-news on velox: unread Arch news doesn't stall the boot run
+What we're verifying: with two or more unread news items at boot, the boot
+form's informant clear should keep the transaction from waiting on stdin
+(AC7 at boot).
+- Run it after step (c) on velox, on a day =upgrade-guarded --dry-run=
+ lists a GPU-kind entry.
+- Open the maint panel at its deferred row.
+- Press APPLY on the deferred row. The first press arms.
+- Press APPLY again to fire it.
+- Press Enter at the terminal's 'Reboot now?' prompt, which answers no.
+ Arming cleared the news, so the news has to become unread after this.
+#+begin_src sh :results output
+informant list --unread 2>&1 | head
+#+end_src
+- If fewer than two items are unread, move informant's state aside so the
+ whole feed reads unread. The last step puts it back, and no pacman
+ transaction should run before the reboot.
+#+begin_src sh :results output
+sudo mv /var/lib/informant.dat /var/lib/informant.dat.bak && informant list --unread 2>&1 | head -3
+#+end_src
+- Press REBOOT on the panel.
+- Confirm the reboot the way the panel asks.
+- Watch tty1 until autologin starts Hyprland.
+#+begin_src sh :results output
+systemctl show archsetup-boot-upgrade -p Result -p ExecMainStatus
+journalctl -b -t archsetup-boot-upgrade --no-pager | tail -5
+jq -c '.data | {result, failed_step, n: (.packages | length)}' ~/.local/state/maint/upgrade_deferred.json
+#+end_src
+- If you moved informant's state aside, restore it.
+#+begin_src sh :results output
+[ -e /var/lib/informant.dat.bak ] && sudo mv -f /var/lib/informant.dat.bak /var/lib/informant.dat && echo restored
+#+end_src
+Expected: the boot run ended with Result=success well inside its timeout,
+the journal runs straight from the banner to the end of the transaction
+with nothing waiting on input, and the record reads result ok.
+
+*** D-zbm: recover from a failed zfs-dkms build by rolling back to the held snapshot
+What we're verifying: the README's recovery steps work end to end on a ZFS
+root. A =--complete= whose zfs build fails should exit 4 with its snapshot
+held and named. A ZFSBootMenu rollback (MOD+R) plus the pacman-log
+reconcile should then leave a consistent system. This is non-gating; no
+criterion waits on it.
+- Follow the recovery steps in =maint/README.md= (Phase 4), which carry the
+ first build task's MOD+R and version fixes.
+#+begin_src sh :results output
+# VM helpers; every later block in this test sources them. Root logs in by
+# key only after the install, so vmroot uses the key the harness left with
+# the newest test results.
+cat > /tmp/ug-vm.sh <<'EOF'
+cd ~/code/archsetup || exit 1
+o="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -p 2222"
+key=$(ls -td test-results/*/root_key 2>/dev/null | head -1)
+vm() { sshpass -p archsetup ssh $o cjennings@localhost "$@"; }
+vmroot() { ssh $o -i "$key" root@localhost "$@"; }
+mon() { echo "$*" | socat - UNIX-CONNECT:vm-images/qemu-monitor-zfs.sock; }
+shot() { mon screendump /tmp/ug-tty1.ppm >/dev/null; sleep 1
+ magick /tmp/ug-tty1.ppm /tmp/ug-tty1.png && echo /tmp/ug-tty1.png; }
+shots() { d=/tmp/ug-shots; rm -rf "$d"; mkdir -p "$d"
+ for i in $(seq -w 1 "${1:-90}"); do mon "screendump $d/$i.ppm" >/dev/null; sleep 2; done
+ for f in "$d"/*.ppm; do magick "$f" "${f%.ppm}.png" && rm -f "$f"; done
+ echo "$(ls "$d" | wc -l) frames, 2 s apart, in $d"; }
+EOF
+echo "helpers written to /tmp/ug-vm.sh"
+#+end_src
+#+begin_src sh :results output
+# The full install runs first; this takes a while, and Emacs waits on it.
+cd ~/code/archsetup && make test-keep FS_PROFILE=zfs 2>&1 | tail -6
+#+end_src
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+vm 'pacman -Q less $(pacman -Qqo /usr/lib/modules/*/vmlinuz)'
+#+end_src
+- On https://archive.archlinux.org/packages/, find the previous releases of
+ the VM's kernel, its headers and one small leaf package (=less=).
+- Paste the three URLs into the next block.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+kernel_url='PASTE-URL'; headers_url='PASTE-URL'; leaf_url='PASTE-URL'
+vmroot "pacman -U --noconfirm $kernel_url $headers_url $leaf_url" | tail -3
+vm 'LC_ALL=C pacman -Qu'
+#+end_src
+- Make the zfs build fail for any kernel built from now on. The VM is
+ disposable after this drill.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+vmroot 'for f in /usr/src/zfs-*/dkms.conf; do echo "MAKE[0]=\"false\"" >> "$f"; tail -1 "$f"; done'
+#+end_src
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+vm 'upgrade-guarded --complete > /tmp/ug-zbm.log 2>&1; echo "exit: $?"; tail -2 /tmp/ug-zbm.log
+ls /var/lib/archsetup/apply-upgrade-on-boot 2>&1
+snap=$(jq -r .data.held_snapshot ~/.local/state/maint/upgrade_deferred.json); echo "held: $snap"
+zfs holds "$snap"'
+#+end_src
+- Write down the held snapshot's name; a rolled-back root may not keep it
+ in the record.
+- Reboot the guest and catch ZFSBootMenu inside its 3 s countdown by
+ sending Escape every half second (the VM takes no keyboard input any
+ other way).
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+vm 'sudo systemctl reboot'; sleep 5
+for i in $(seq 60); do mon sendkey esc >/dev/null; sleep 0.5; done
+shot
+#+end_src
+- If the shot shows anything but the boot-environment list, change the
+ =sleep= before the loop and run the block again; if it shows the
+ firmware's setup screen, which also answers Escape, run
+ =mon system_reset= first.
+- Open the snapshot list with MOD+S (Ctrl+S).
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+mon sendkey ctrl-s >/dev/null; sleep 1; shot
+#+end_src
+- If ZFSBootMenu asks to import the pool read/write, press MOD+W.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+mon sendkey ctrl-w >/dev/null; sleep 1; shot
+#+end_src
+- Move the selection onto the held snapshot one row at a time, checking
+ each shot.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+mon sendkey down >/dev/null; sleep 1; shot
+#+end_src
+- Roll back with MOD+R. Never press ENTER (duplicate), MOD+X (clone and
+ promote) or MOD+C (clone) here.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+mon sendkey ctrl-r >/dev/null; sleep 1; shot
+#+end_src
+- Answer the rollback confirmation the screen shows; for a y/N prompt, the
+ next block sends y.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+mon sendkey y >/dev/null; sleep 2; shot
+#+end_src
+- Go back to the boot-environment list.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+mon sendkey esc >/dev/null; sleep 1; shot
+#+end_src
+- Boot =zroot/ROOT/default=.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+mon sendkey ret >/dev/null; sleep 40; shot
+#+end_src
+- List the pacman.log lines to undo, newest first, with the held name
+ pasted in.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+snap='PASTE-HELD-SNAPSHOT'
+vmroot "ls /var/lib/pacman/db.lck 2>&1
+t=\$(date -d @\$(zfs get -Hp -o value creation $snap) +%Y-%m-%dT%H:%M:%S); echo \"since \$t\"
+awk -v t=\"\$t\" 'substr(\$1, 2, 19) >= t && \$2 == \"[ALPM]\" && \$3 ~ /^(upgraded|downgraded|installed|removed)\$/' /var/log/pacman.log | tac"
+#+end_src
+- Undo each listed line, newest first, as the README's recovery steps say.
+- Paste the reconciled names into the check block and run it.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+names='PASTE-RECONCILED-NAMES'
+vmroot "pacman -Q $names; pacman -Dk && echo 'Dk clean'; pacman -Qkk $names 2>&1 | tail -4"
+#+end_src
+Expected: =--complete= exited 4 with the zfs module item as its last line,
+left no flag, and named a held snapshot that =zfs holds= shows with the
+=upgrade-guarded= tag. After the rollback and the reconcile, =pacman -Q=
+shows the old kernel, headers and =less= versions, =pacman -Dk= is clean,
+and =pacman -Qkk= over the reconciled names reports no mismatch.
+
+*** D-zbm power-cut variant: a reset during stage 1 leaves the right snapshot held and a reconcilable db
+What we're verifying: a hard reset during =--complete='s stage 1, on a VM
+with no open gate, should leave the snapshot step 3 took held and named.
+Recovery's power-cut steps, including the half-registered package, should
+then reconcile the db. This is non-gating.
+- Use a fresh =make test-keep FS_PROFILE=zfs= VM, not the one D-zbm left
+ with its gate open.
+#+begin_src sh :results output
+# VM helpers; every later block in this test sources them. Root logs in by
+# key only after the install, so vmroot uses the key the harness left with
+# the newest test results.
+cat > /tmp/ug-vm.sh <<'EOF'
+cd ~/code/archsetup || exit 1
+o="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -p 2222"
+key=$(ls -td test-results/*/root_key 2>/dev/null | head -1)
+vm() { sshpass -p archsetup ssh $o cjennings@localhost "$@"; }
+vmroot() { ssh $o -i "$key" root@localhost "$@"; }
+mon() { echo "$*" | socat - UNIX-CONNECT:vm-images/qemu-monitor-zfs.sock; }
+shot() { mon screendump /tmp/ug-tty1.ppm >/dev/null; sleep 1
+ magick /tmp/ug-tty1.ppm /tmp/ug-tty1.png && echo /tmp/ug-tty1.png; }
+shots() { d=/tmp/ug-shots; rm -rf "$d"; mkdir -p "$d"
+ for i in $(seq -w 1 "${1:-90}"); do mon "screendump $d/$i.ppm" >/dev/null; sleep 2; done
+ for f in "$d"/*.ppm; do magick "$f" "${f%.ppm}.png" && rm -f "$f"; done
+ echo "$(ls "$d" | wc -l) frames, 2 s apart, in $d"; }
+EOF
+echo "helpers written to /tmp/ug-vm.sh"
+#+end_src
+#+begin_src sh :results output
+# The full install runs first; this takes a while, and Emacs waits on it.
+cd ~/code/archsetup && make test-keep FS_PROFILE=zfs 2>&1 | tail -6
+#+end_src
+- On https://archive.archlinux.org/packages/, find the previous releases of
+ the VM's kernel and its headers.
+- Paste the two URLs into the next block.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+kernel_url='PASTE-URL'; headers_url='PASTE-URL'
+vmroot "pacman -U --noconfirm $kernel_url $headers_url" | tail -3
+vm 'LC_ALL=C pacman -Qu'
+#+end_src
+- In a separate terminal, start the session with a tty. pacman prints
+ '(n/m) upgrading <pkg>' only on a tty; without one it prints 'upgrading
+ <pkg>...'.
+#+begin_src sh :eval no
+sshpass -p archsetup ssh -t -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2222 cjennings@localhost upgrade-guarded --complete
+#+end_src
+- As soon as stage 1's first upgrading line appears, read the held
+ snapshot's name.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+vm 'jq -r .data.held_snapshot ~/.local/state/maint/upgrade_deferred.json'
+#+end_src
+- As the kernel headers package's upgrading line appears, run the next
+ block. It hard-resets the guest, then sends Escape every half second to
+ catch ZFSBootMenu's 3 s countdown. Never use =system_powerdown= or
+ =quit=: both end QEMU, and =make test-keep= then restores the clean
+ install.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+mon system_reset >/dev/null; sleep 3
+for i in $(seq 60); do mon sendkey esc >/dev/null; sleep 0.5; done
+shot
+#+end_src
+- If the shot shows anything but the boot-environment list, run
+ =mon system_reset= and the Escape loop again with a changed delay.
+- Open the snapshot list with MOD+S (Ctrl+S).
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+mon sendkey ctrl-s >/dev/null; sleep 1; shot
+#+end_src
+- If ZFSBootMenu asks to import the pool read/write, press MOD+W.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+mon sendkey ctrl-w >/dev/null; sleep 1; shot
+#+end_src
+- Move the selection onto the held snapshot one row at a time, checking
+ each shot. If its name wasn't read in time, it's the
+ =zroot/ROOT/default@pre-pacman_*= row taken just before stage 1.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+mon sendkey down >/dev/null; sleep 1; shot
+#+end_src
+- Roll back with MOD+R. Never press ENTER (duplicate), MOD+X (clone and
+ promote) or MOD+C (clone) here.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+mon sendkey ctrl-r >/dev/null; sleep 1; shot
+#+end_src
+- Answer the rollback confirmation the screen shows; for a y/N prompt, the
+ next block sends y.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+mon sendkey y >/dev/null; sleep 2; shot
+#+end_src
+- Go back to the boot-environment list.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+mon sendkey esc >/dev/null; sleep 1; shot
+#+end_src
+- Boot =zroot/ROOT/default=.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+mon sendkey ret >/dev/null; sleep 40; shot
+#+end_src
+- Read the hold, the lock and the db's state, with the held name pasted in.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+snap='PASTE-HELD-SNAPSHOT'
+vmroot "zfs holds $snap; ls /var/lib/pacman/db.lck 2>&1; pacman -Dk 2>&1 | tail -3; tail -8 /var/log/pacman.log"
+#+end_src
+- If pacman.log shows the reset landed outside extraction (every target
+ has an =[ALPM]= line, or 'running post-transaction hooks' is logged),
+ stop and repeat on a fresh VM.
+- Remove =db.lck= if the read above showed it.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+vmroot 'rm -fv /var/lib/pacman/db.lck'
+#+end_src
+- Delete the local db entry =pacman -Dk= reported as 'description file is
+ missing', with its directory name pasted in.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+entry='PASTE-ENTRY-DIR'
+vmroot "rm -rv /var/lib/pacman/local/$entry"
+#+end_src
+- Find the version the booted root holds for that package: the last
+ =[ALPM]= line for it before the snapshot's creation (on an upgraded or
+ downgraded line, the version after =->=; on a removed line, or with no
+ line, register nothing and skip the next step).
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+snap='PASTE-HELD-SNAPSHOT'; pkg='PASTE-PACKAGE'
+vmroot "t=\$(date -d @\$(zfs get -Hp -o value creation $snap) +%Y-%m-%dT%H:%M:%S)
+awk -v t=\"\$t\" -v p=$pkg 'substr(\$1, 2, 19) < t && \$2 == \"[ALPM]\" && \$4 == p' /var/log/pacman.log | tail -1"
+#+end_src
+- Register that version from the cache, with its package file pasted in.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+pkgfile='PASTE-CACHE-FILE'
+vmroot "pacman -U --dbonly --noconfirm /var/cache/pacman/pkg/$pkgfile"
+#+end_src
+- List the later =[ALPM]= lines to undo, newest first.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+snap='PASTE-HELD-SNAPSHOT'
+vmroot "t=\$(date -d @\$(zfs get -Hp -o value creation $snap) +%Y-%m-%dT%H:%M:%S); echo \"since \$t\"
+awk -v t=\"\$t\" 'substr(\$1, 2, 19) >= t && \$2 == \"[ALPM]\" && \$3 ~ /^(upgraded|downgraded|installed|removed)\$/' /var/log/pacman.log | tac"
+#+end_src
+- Undo each listed line, newest first, as the README's recovery steps say.
+- Paste the reconciled names into the check block and run it.
+#+begin_src sh :results output
+. /tmp/ug-vm.sh
+names='PASTE-RECONCILED-NAMES'
+vmroot "pacman -Q $names; pacman -Dk && echo 'Dk clean'; pacman -Qkk $names 2>&1 | tail -4"
+#+end_src
+Expected: =zfs holds= showed the =upgrade-guarded= tag on the snapshot step
+3 took before stage 1, and held_snapshot named it. After the reconcile,
+=pacman -Q= shows the old kernel set, =pacman -Dk= is clean, and =pacman
+-Qkk= over the reconciled names reports no mismatch, including the package
+cut off mid-extraction.
+
** DOING [#B] Prepare for GitHub open-source release
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-09
+:LAST_REVIEWED: 2026-08-17
:END:
Remove personal info, credentials, and code quality issues before publishing.
*** 2026-07-21 Tue @ 08:00:00 -0500 Audit reconcile: the four assets/ "& Claude" author lines are fixed
@@ -1746,6 +5339,19 @@ Recommend: fresh repo for GitHub (keep cjennings.net remote with full history).
History is now 589 commits (the 2026-05-11 note's "275" is stale). Only the calendar-feed file has been filter-repo'd so far (2026-05-20). The five credential files remain in history at their pre-=b10cba5= paths: =.tidal-dl.token.json= (5 commits), =calibre/smtp.py.json= (6), =transmission/settings.json= (5), =.msmtprc= (8), =.mbsyncrc= (9). None are tracked in the current tree. The scrub-or-fresh-repo decision still stands.
***** 2026-07-04 Sat @ 11:48:24 -0500 Count refresh — history now 565 commits; re-verify the 5-file claim before scrubbing
The 2026-07-04 audit found the history is now 565 commits, down from the 589 recorded above. Because the count dropped, re-verify that the five credential files are still present in history (re-run the per-file =git log --all -- <path>= check) before relying on the scrub scope — the earlier count is stale and the file set may have moved.
+***** 2026-08-17 Mon @ 10:20:00 -0700 Corrected the paths — every prior check has been querying paths that never existed
+The five filenames recorded above are not the paths these files live at, and =git log -- <path>= answers "no commits" for a path it has never seen rather than erroring. So the checks return a clean result and mean nothing. The real paths, from =git log --all --name-only --diff-filter=A= over the full history, all sit under the pre-migration =dotfiles/= tree:
+
+- =dotfiles/system/.msmtprc= (3 commits)
+- =dotfiles/system/.mbsyncrc= (2)
+- =dotfiles/system/.config/calibre/smtp.py.json= (2)
+- =dotfiles/system/.config/transmission/settings.json= (2)
+- =dotfiles/system/.config/.tidal-dl.token.json= (2)
+- =dotfiles/system/.config/.tidal-dl.json= (2) — a *sixth* file, never recorded here
+
+Use those paths for any future check, not the bare filenames. History is 891 commits; none of the six are in the current tree. The scrub-or-fresh-repo decision still stands and its scope is six files, not five.
+
+This surfaced while re-verifying on velox, where the check ALSO returned a false clean for a second, unrelated reason: the clone was shallow (7 commits), so it could not see the history either way. Both failures produce the same confident zero. Filed as =[#A] The installer shallow-clones the two repos I develop in=.
***** 2026-07-21 Tue @ 08:00:00 -0500 Re-verified: history now 851 commits; five files still present, per-file counts dropped
2026-07-21 audit re-verification. History is now 851 commits (=git rev-list --all --count=). The five credential files are still in history but at fewer commits each than the 2026-06-28 record: =.tidal-dl.token.json= 3 (was 5), =calibre/smtp.py.json= 4 (was 6), =transmission/settings.json= 3 (was 5), =.msmtprc= 5 (was 8), =.mbsyncrc= 6 (was 9). None are in the current tree. The scrub-or-fresh-repo decision still stands; the scope is smaller than recorded.
@@ -1804,9 +5410,14 @@ Rewrote the bare =if $var= boolean conditionals (=show_status_only=, =fresh_inst
*** 2026-05-26 Tue @ 15:27:09 -0500 eval task moot — the line-434 eval is gone, the survivor is deliberate
Verified: the only =eval= left in =archsetup= is line 578 in =retry_install=, and it's intentional and documented — it captures =$?= directly from =eval "$cmd"= to dodge the if-compound-swallows-exit-code trap. Replacing it with an array would reintroduce that bug. The line-434 eval this task pointed at no longer exists. Nothing to change.
-** TODO [#B] The audio doctor never checks the microphone :bug:audio:
+*** 2026-10-06 Tue @ 06:08:06 -0600 One secret scrubbed from history; the full pre-release scan is still owed
+The Signal pager number was rewritten out of every commit on 10-06 (details
+on the cgit audit task). That was one known string; before the public push,
+run a full-history secrets scan rather than trusting the tree alone.
+
+** TODO [#C] The audio doctor never checks the microphone :bug:audio:
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-13
+:LAST_REVIEWED: 2026-08-25
:END:
The classifier is output-only. =diag.probe_semantic= already collects =default_source= and =default_source_present=, and =classify.py= reads neither: the word "source" appears once in the whole module, in the graph row that counts them. So a muted mic, a default source naming an unplugged device, or a mic at zero volume all classify as =healthy=, and the verdict prints "the default output is present and audible" while the input side goes unexamined. Found 2026-07-10 while asking whether the doctor would have caught Chrome losing the mic. It would not have.
@@ -1816,17 +5427,15 @@ Work: mirror the sink rules onto the source. =probe_semantic= gains =default_sou
Two things not to get wrong. An absent microphone is legitimate on a desktop, so "no input devices" must never be a fault the way =no-output-devices= is. And a monitor source is a legitimate default source (recording desktop audio), which is why =probe_semantic= passes =include_monitors=True= — inheriting the panel's display filter here would call a working setup broken.
-Specced 2026-07-10 after discussion with Craig, and the design grew past the original gap: [[file:docs/specs/2026-07-10-audio-doctor-input-side-spec.org][docs/specs/2026-07-10-audio-doctor-input-side-spec.org]] (DRAFT, four decisions open). A doctor key per direction, a kernel-level capture probe below PipeWire, PTT-aware muting, and a direction-aware guard. The precedence question the build would have faced is gone: a doctor per direction means the user's press says which side they came to fix.
+Specced 2026-07-10 after discussion with Craig, and the design grew past the original gap: [[file:docs/specs/2026-07-10-audio-doctor-input-side-spec.org][docs/specs/2026-07-10-audio-doctor-input-side-spec.org]] (DRAFT, three decisions open as of 2026-08-25). A doctor key per direction, a kernel-level capture probe below PipeWire, PTT-aware muting, and a direction-aware guard. The precedence question the build would have faced is gone: a doctor per direction means the user's press says which side they came to fix.
Parent spec: [[file:docs/specs/2026-07-09-audio-doctor-spec.org][docs/specs/2026-07-09-audio-doctor-spec.org]] (IMPLEMENTED). This is a v1 gap found after the fact, not a phase of it.
-** TODO [#C] Waybar modules run together — need subtle separators :bug:dotfiles:waybar:
-Craig misreads where one module ends and the next begins — the wind (weather) value runs straight into the date with no visual stop, so he reads the wind figure as the start of the date. Add a light, subtle separator or spacing between adjacent Waybar modules.
-Grading: Minor severity (legibility, nothing broken) x frequent (every glance at the bar) = P3 = [#C].
-Not fully :solo: — needs Craig's eye on the result (separator style is a taste call, plus a live visual check). Prior work added a date-facing divider (dotfiles 103cccb); evidently not enough, so revisit the whole inter-module treatment rather than just the weather/date seam. From .emacs.d handoff 2026-07-20-1114 (roam capture; waybar is archsetup-owned per the dotfiles standing rule).
+Grading (2026-08-25 review): Major severity — the doctor's verdict is silently wrong for a whole direction, workaround is checking the mic by hand — × "some users, sometimes" (mic faults are occasional) = P3 = [#C]. Was held at [#B] ungraded; regraded by the matrix.
+
** TODO [#C] Weather chip color signals unclear + unenforced :bug:dotfiles:waybar:weather:
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-21
+:LAST_REVIEWED: 2026-08-26
:END:
From the roam inbox (2026-07-20): the shipped Waybar weather chip's comfort coloring reads as noise — it shows amber for no clear reason, and some items are bolded, which isn't a legible signal. Craig's intended scheme (every item except the arrow key colored by whether the weather is comfortable; NO bold or italic anywhere):
- Normal — all text white: temp in 60-85; condition sunny/clear/etc.
@@ -1835,50 +5444,75 @@ From the roam inbox (2026-07-20): the shipped Waybar weather chip's comfort colo
Open design question (Craig): which other conditions/combinations to signal, and how a forecasted severe event (e.g. an upcoming hurricane) should display.
Grading: Minor severity (legibility on a shipped chip, nothing broken) × frequent (every glance at the bar) = P3 = [#C]. Mostly buildable from the rules above; the severe-event display needs Craig's call. Waybar is archsetup-owned per the dotfiles standing rule.
** TODO [#C] Add a time selector to the timer panel :feature:timer:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-08-08
+:END:
Offer a period-appropriate selector for timer duration, likely drawing on the
tape-counter idiom, while preserving the existing direct-entry path.
-** TODO [#C] Order network-panel connections by availability :feature:network:
+** TODO [#C] Order network-panel connections by availability :feature:network:solo:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-08-09
+:END:
Present saved and currently available networks in this order: available saved
profiles, available unsaved networks, then saved profiles that are unavailable.
-** TODO [#C] Indicate hotspot or metered WiFi in amber :feature:network:waybar:
+*** 2026-08-09 Sun @ 11:13:15 -0500 Design conflict resolved; now :solo:
+Craig, 2026-08-09: sort available-first within the Saved group (available
+saved MRU-first, unavailable saved below), keeping the spec's three labelled
+groups intact — no merged list. Escalate to a merge later only if it still
+reads wrong in use.
+
+** TODO [#C] Indicate hotspot or metered WiFi in amber :feature:network:waybar:solo:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-08-09
+:END:
Detect hotspot/metered connectivity and render the WiFi icon plus SSID amber,
while ordinary WiFi stays white.
-** TODO [#C] Add a whole-display dim mode :feature:hyprland:
-Extend auto-dim with an explicit “dim everything” setting for bright
-non-dark-mode contexts, with a security/usability review of its scope.
+*** 2026-08-09 Sun @ 11:13:15 -0500 Both design calls answered; now :solo:
+Craig, 2026-08-08/09: amber means connected to a phone's hotspot (this
+machine running an AP does not trigger it), and the one-nmcli-call fast-path
+contract stays intact. Build shape: NetworkManager's metered flag is the
+detector (Android tethering auto-flags via the DHCP vendor hint; NM's
+GENERAL.METERED yes/guessed-yes on the active wifi device), read by the
+slow-path probe and written into the connectivity cache the fast path
+already consumes, so the indicator pays nothing new. Where NM can't guess
+(some iPhones), the per-connection metered flag is the manual override; a
+panel affordance for it can come later. Live phone-hotspot check is Craig's
+manual-testing entry; everything else verifies with fakes.
** TODO [#C] Net panel speedtest history :feature:dotfiles:network:
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-14
+:LAST_REVIEWED: 2026-08-25
:END:
From the roam inbox (routed 2026-07-13): the networking panel should track speedtests over time with appropriate info. Shape: persist each SPEED TEST result (timestamp, down/up, latency, server) to a small local store and surface history in the net panel. Design questions for work time: retention window, which fields matter, and presentation within the panel's ~400px width (recent-results list vs trend readout). Point-in-time results exist today; the gap is comparison across days and venues.
** TODO [#C] zfs base VM image build failure: ZFS DKMS module missing :bug:zfs:
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-09
+:LAST_REVIEWED: 2026-08-17
:END:
=FS_PROFILE=zfs make test-vm-base= fails inside the VM at initramfs time: archangel reports "ZFS module not found! DKMS build may have failed" against the installed kernel (linux-lts 6.18.38 at the 2026-07-08 attempt). Consequences: the maint scenario harness's zfs lane (Phase 12) is filtered but unexercised, and a real zfs bare-metal install via archangel would plausibly hit the same wall. Priority per the bug matrix: Major severity (zfs install path broken) × some-users-sometimes = P3. When fixed, run =FS_PROFILE=zfs bash scripts/testing/run-maint-scenarios.sh --list= and add zfs scenario files (zpool scrub / autotrim / snapshot destroy) to the harness.
+*** 2026-08-17 Mon @ 10:08:51 -0700 Rechecked: archzfs still on 2.3.3, still blocked
+Ran the unblock check from the diagnosis below: archzfs' x86_64 index still serves only =zfs-dkms-2.3.3=. The first release supporting 6.18 is 2.4.0, so the blocking condition is unchanged and there is still nothing on our side to fix. Recheck again with the same one-liner.
*** 2026-07-14 Tue @ 01:40:48 -0500 Diagnosed: OpenZFS/kernel version skew, blocked on archzfs
Reproduced in ~1 minute of install: =dkms install zfs/2.3.3 -k 6.18.38-2-lts= exits 1 during pacstrap. Root cause confirmed: OpenZFS 2.3.3's META declares Linux-Maximum 6.15, and the VM installs linux-lts 6.18.38. The first release supporting 6.18 is 2.4.0 (2.4.1 covers 6.19), and archzfs currently serves only zfs-dkms 2.3.3-1 — nothing on our side to fix. Unblock condition: archzfs publishes zfs-dkms ≥2.4.0; recheck with =curl -s https://archzfs.com/archzfs/x86_64/ | grep -o 'zfs-dkms-[0-9.]*'=, then rerun =FS_PROFILE=zfs make test-vm-base=.
** TODO [#C] Waybar collapse control: replace the triangle glyph :feature:waybar:
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-14
+:LAST_REVIEWED: 2026-08-26
:END:
From the 2026-07-04 roam capture. The waybar collapse mechanism (click the triangle, the bar sections redisplay shortened) works, but the triangle glyph doesn't match the instrument-console aesthetic the panels now use. Replace it with something in keeping with the console look. Aesthetic decision — bring Craig two or three concrete glyph/style options (a machined chevron, a console-key style expander, an engraved caret) before wiring. Dotfiles waybar config (handled per the archsetup-owns-dotfiles rule). Raised alongside the net-panel/audio speedrun; deferred from it because the glyph choice is a taste call.
** TODO [#C] Net panel: driver-health diagnostic tier :feature:network:
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-14
+:LAST_REVIEWED: 2026-08-26
:END:
Follow-up from the 2026-07-04 net-panel hardening speedrun (Craig's cj question on the no-WiFi item). The shipped no-wifi-hardware verdict covers "no adapter at all." This tier covers "adapter present but the driver is wedged": read-only health signals — =ip link= (device present but no-carrier / down), =dmesg= / =journalctl -k= for firmware-load failures, =rfkill= for a hard block, =modinfo= / =lsmod= for the driver module — classified before a generic reset. Remedy actions: a privileged =modprobe -r <mod> && modprobe <mod>= reload of the wifi driver, and a firmware-package pointer when the failure is a missing/failed firmware load. Dotfiles net-package work (handled per the archsetup-owns-dotfiles rule). Design pass first to decide whether it's worth a repair tier vs a needs-user-action pointer.
** TODO [#C] Voice dictation / speech-to-text input :feature:tooling:solo:
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-06
+:LAST_REVIEWED: 2026-08-08
:END:
Push-to-talk dictation that types transcribed speech into the focused Wayland window — usable at any text field, including the Claude Code terminal prompt and Emacs buffers. Claude Code has no built-in voice input; dictation has to happen at the OS level and inject text. Raised 2026-07-03.
@@ -1886,44 +5520,28 @@ Tool choice is the open decision (needs Craig): =nerd-dictation= (Vosk, lighter,
*** 2026-07-21 Tue @ 08:40:00 -0500 Decided (Craig): whisper.cpp + wtype, system-wide
STT engine = =whisper.cpp= (accurate offline, optional GPU on ratio's Radeon). Typing backend = =wtype= (Wayland-native virtual-keyboard injection into the focused window, no root/daemon), with =ydotool= (uinput) held as a fallback only if a specific app — some XWayland/Electron surface — won't accept wtype's synthetic input. One system-wide path that also covers Emacs buffers and the Claude Code prompt; the Emacs-native =whisper.el= route was NOT chosen. Build scope: whisper.cpp + a model (start with a mid-size English model, tune later), a Hyprland push-to-talk keybind driving a record→transcribe→wtype pipeline, and an autostart/service entry, folded into archsetup so it lands on ratio + velox. Now unblocked (agent-buildable; verification includes a live dictation check).
-** TODO [#C] Fix install errors surfaced by the 2026-05-11 VM test run
+** TODO [#C] Osbot camera configuration :chore:quick:
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-06
+:LAST_REVIEWED: 2026-09-17
:END:
-*** 2026-06-28 Sun @ 13:29:29 -0400 Audit reconcile: 2026-06-28 btrfs+zfs runs reproduce the same residual set
-Newer full runs landed since the 2026-06-11 reconcile below: the 2026-06-25 zfs run (Testinfra 96/0) and the 2026-06-28 btrfs+zfs runs (97/0, "zero attributed issues"). The residual four were NOT fixed and reproduce unchanged: =enabling firewall= (archsetup:1496-1498, carries a VM-kernel note), =enabling gamemode for user= (archsetup:2221, non-critical), and =tidaler (AUR)=. Zero archsetup-attributed Testinfra issues across both profiles confirms these are environment / non-critical, not archsetup bugs. Bare-metal confirmation of the firewall pair is still the open thread.
-
-*** 2026-06-15 Mon @ 23:53:21 -0500 Audit reconcile: latest VM run (2026-06-11) confirms the surviving error set
-The most recent VM run (=test-results/20260611-113904/=) carries four error-summary entries: =enabling firewall= + =verifying firewall is active= (the iptables/nf_tables "Could not fetch rule set generation id" pair, still unconfirmed on bare metal), =enabling gamemode for user= (non-critical), and =tidaler (AUR)=. The earlier fontconfig/dconf fixes held — none reappear. So the count is down from the 7→6 anchor below to four, all of them the known-residual items already itemized.
-Errors logged during the VM install. Status as of the 2026-05-11 18:36 run (=test-results/20260511-183643/archsetup-output.log=) after the =48c9439= fontconfig/dconf fix: 7 → 6.
-- refreshing font cache — RESOLVED in =48c9439= (now installs =fontconfig= before calling =fc-cache=).
-- configuring GTK file chooser — RESOLVED in =ecab29f= (switched to a system-wide dconf db at =/etc/dconf/db/site.d/=; needs no session bus during install).
-- configuring GNOME interface settings in dconf — RESOLVED in =ecab29f= (same fix as the GTK file chooser above).
-- enabling firewall — exit 1: =iptables v1.8.13 (nf_tables): Could not fetch rule set generation id: Invalid argument=. Still present in the 18:36 run; likely a VM-kernel/nf_tables artifact — confirm on bare metal before treating as an archsetup bug.
-- verifying firewall is active — exit 1 (follow-on from the firewall-enable error).
-- enabling gamemode for user — exit 1 → step "gaming" FAILED — non-critical.
-- tidaler (AUR) — logged in the error summary with exit code 0 (odd; logging quirk or transient AUR build noise?).
-Also seen in the 18:36 run's log-diff (post-install systemd noise, probably VM-environment): =pam_systemd … CreateSession failed= / =logind: Failed to start session scope … Permission denied=, and =Failed to start Proton VPN Daemon= (no VPN config in the test VM).
-
-*** 2026-05-19 Tue @ 13:18:56 -0500 Fixed AUR exit-0 logging bug at the root
-Root cause was in =retry_install=: =last_exit_code=$?= ran AFTER =if eval ...; then return 0; fi=. Bash defines an if-compound's exit status as zero when no condition tested true, so a failing eval's exit code got overwritten with 0 before reaching =error_warn=. Fix in =8221c54=: capture =$?= from =eval= directly into a local var, then compare against the captured value in the if. VM-verified in =test-results/20260519-115318/=: =mkinitcpio-firmware (AUR)= and =tidaler (AUR)= now report =error code: 1= (yay's actual exit) instead of the misleading =error code: 0=. The same packages still appear in the summary because yay returns non-zero when sub-deps fail to build (e.g. =aic94xx-firmware=), but the codes are accurate now. If the underlying sub-dep failures stay noisy, that's a separate concern — open a new task.
-
-*** 2026-05-16 Sat @ 09:00:41 -0500 AI Response: Surfaced the expanded AUR-exit-0 pattern
-2026-05-16 07:40 VM run passed (52/0/5) with the same warning profile as the 2026-05-11 18:36 run. Error count went 7 → 13: 5 fixed/unchanged, +5 new AUR-exit-0 entries (broadens the existing tidaler item into the dedicated =[#B]= subtask above), +1 genuinely new error in =setting up emacs configuration files= (=git pull= ran in =~/.emacs.d= which existed from stow but had no =.git=). Patched =archsetup:1932-1945= with a three-branch check: clone if missing/empty, pull if =.git= exists, =git init=/=fetch=/=checkout= in place if the dir came from stow.
-
-*** 2026-05-19 Tue @ 01:25:26 -0500 Verified the b9907c7 emacs-stow fix end-to-end
-=make test= 21:44 → 22:29 (42 min), =test-results/20260518-214516/=. 52/0/5, =ArchSetup Exit Code: 0=. The third-branch path fired correctly — install log =archsetup-2026-05-18-21-45-46.log:14358-14365= shows =From https://git.cjennings.net/dotemacs= → =[new branch] main -> origin/main= → =Reset branch 'main'= → =branch 'main' set up to track 'origin/main'=. No exit-128, no =fatal: not a git repository=. Error Summary down to 7 (was 13 on 2026-05-16); the emacs entry is gone. AUR exit-0 logging triggered for 2 packages this run (mkinitcpio-firmware, tidaler) vs 6 on 2026-05-16 — same bug class, fewer triggers, still tracked under =[#B] AUR exit-0 logged as error=. Issue Attribution: 1 ARCHSETUP entry (Proton VPN Daemon failed — known VM-no-VPN-config artifact). Cleanup ran clean via the normal path.
-
-** TODO [#C] Osbot camera configuration :chore:
+Re-graded [#C] → [#B] and scheduled at the 2026-08-08 review: Craig leaves on
+vacation in a week (~2026-08-15), so this needs to land before then. Blocked
+only on the camera being physically plugged in (no /dev/video node as of
+2026-08-08). Likely same sitting as the camera-passthrough udev rule task.
Craig's roam capture 2026-07-20, routed via .emacs.d sentry inbox-zero as archsetup-owned device setup: "configure osbot camera." Scope to define at pickup (device model, what "configure" covers — kernel module, v4l settings, default framing).
*** 2026-07-21 Tue @ 08:10:00 -0500 Scoped (Craig): tiny — it works, just needs configuring via a panel
Craig: the camera works (bought for being Linux-friendly), it just needs configuring. There IS a config panel — =cameractrls= 0.6.10 is installed (a GTK GUI for camera controls: exposure, white balance, PTZ, focus, framing) plus =v4l-utils= for the CLI path. Caveat found 2026-07-21: no =/dev/video*= device is present right now, so the camera isn't currently plugged in / its UVC node isn't enumerated. Task: with the camera connected, confirm it enumerates as a /dev/video node, then set defaults in cameractrls. Small, mostly a live-hardware step.
+*** 2026-09-17 Thu @ 08:59:59 -0400 Re-graded [#B] → [#C] and unscheduled
+The 08-08 raise was only for the vacation deadline, which has passed, and the
+camera is at home. Pick it up the next time the camera is plugged in.
** TODO [#C] Re-check python-lyricsgenius --skipinteg workaround :chore:solo:
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-09
+:LAST_REVIEWED: 2026-08-17
:END:
archsetup installs =python-lyricsgenius= with =--mflags --skipinteg=, skipping makepkg integrity + PGP checks — a workaround originally for an expired-signature issue upstream (surfaced by the 2026-06-23 --noconfirm audit). Periodically test whether the cause has cleared: if a plain =aur_install python-lyricsgenius= builds without complaint, drop the =--skipinteg= workaround. Removal needs a real AUR build to confirm, so it isn't a blind change.
+*** 2026-08-17 Mon @ 10:08:51 -0700 Rechecked: still needed, cause unchanged
+Fresh AUR clone, =makepkg --verifysource= on 3.7.0-1: the PyPI tarball passes, =LICENSE.txt= still FAILS its b2sum. The PKGBUILD still pins the license at github master, so its checksum drifts whenever upstream touches the file. =--skipinteg= stays.
*** 2026-07-23 Thu @ 02:20:00 -0500 Rechecked: still needed, unchanged
Fresh AUR clone, =makepkg --verifysource= on 3.7.0-1 (PKGBUILD still unchanged since the last check): the PyPI tarball passes, =LICENSE.txt= still FAILS its b2sum. Same structural cause — the source pins the license at github master, so its checksum drifts whenever upstream touches the file. =--skipinteg= stays. Nothing to change in the installer.
@@ -1940,7 +5558,7 @@ Ran =makepkg --verifysource= on the current AUR PKGBUILD (3.7.0-1). The package
** TODO [#C] Review theme config architecture for dunst/fuzzel
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-06
+:LAST_REVIEWED: 2026-08-08
:END:
The active dunst config is stowed from dotfiles/common/ but theme templates
live in dotfiles/hyprland/.config/themes/. set-theme copies the templates to
@@ -1954,12 +5572,6 @@ The goal is a single place to edit each config, not two.
*** 2026-07-21 Tue @ 08:00:00 -0500 Audit reconcile: single-theme now, so the switch-revert pressure is lower
The theme system went single-theme — Hudson was retired (dotfiles e03436e; documented archsetup 98142bb, 2026-07-18), leaving Dupre as the only theme. So "edits get overwritten on theme switch" now bites only rarely (switches are effectively nonexistent). The structural two-places-to-edit problem still stands and is worth fixing, but the urgency the original grading implied has dropped.
-** TODO [#C] Review current tool pain points annually
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-06
-:END:
-Once-yearly systematic inventory of known deficiencies and friction points in current toolset
-
** TODO [#D] Installer + scripts refactor opportunities :refactor:solo:
Grading: no behavior change; parking lot. 10 refactors remain from the sentry audit — duplicated GPU-modalias scan, triple hand-rolled retry loop, stow x4, display_server/window_manager dispatch dup, Maia ELO range x3, per-script log helpers, GRUB/snapper/fsck sed clusters, waybar-battery positional sed. Full list with line numbers in [[file:docs/design/2026-07-19-sentry-code-findings.org][sentry code findings]] (High/Medium/Low tagged). Pull individual ones out as their own tasks when tackled. The system-mutation sed clusters (snapper/fsck/GRUB/waybar) want characterization coverage before any rewrite.
*** 2026-07-20 Mon @ 16:35:00 -0500 Extracted validate_yesno and the NVIDIA_MIN_DRIVER constant
@@ -2012,11 +5624,28 @@ Parse yay errors and provide specific, actionable fixes instead of generic error
** TODO [#D] Improve progress indicators throughout install
Enhance existing indicators to show what's happening in real-time
-** TODO [#C] Telega coredump recurrence tell :bug:maint:
+** TODO [#D] Telega coredump recurrence tell :bug:maint:
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-21
+:LAST_REVIEWED: 2026-09-17
:END:
+*** 2026-09-17 Thu @ 08:59:59 -0400 Re-graded C → D: the tell has been quiet on both machines since at least 08-28
+Grading: Minor severity (a crashed telega-server restarts; the cost is coredump
+noise and a chat client that blinks) x rare edge case (no occurrence on either
+machine in the ~3 weeks the records cover) = P4 = [#D].
+
+This moves the letter by moving an input, not by overruling the 2026-07-21 raise
+below. That raise was correct on its own frequency row: the tell had just fired,
+repeatedly, across five days. It has since stopped, so the row moved from
+most-users-frequently to rare-edge-case and the letter follows it. If the
+assertion reappears the row moves back and so does the grade.
+
+Checked at the 2026-09-17 review. ratio: =coredumpctl list telega-server= finds
+nothing, and its coredump records reach back to 2026-08-28 (the oldest is an
+unrelated Hyprland SIGABRT); =~/.telega/telega-server.log= (3.0 MB, last written
+09-13) holds zero =tdat_plist_value= assertions. velox: no telega-server
+coredumps either. Back to a watch item; the tell and the durable escape are
+unchanged.
*** 2026-07-21 Tue @ 08:10:00 -0500 Re-graded D → C (Craig): treat as the actionable version-skew recurrence
Craig's call: the fired tell counts as the version-skew recurrence this task predicts (zevlg =:latest= outran the installed elisp again), not just benign host noise — so it bumps [#D] → [#C]. Action: re-pin / upgrade the TDLib side (upgrade the elisp telega package on ratio+velox, or move to a host-native pinned TDLib build) so the server's plist parser and the installed elisp agree again. The durable escape remains the host-native pinned TDLib build.
@@ -2030,1167 +5659,226 @@ Verified rather than assumed: =~/.telega/telega-server.log= carries zero =tdat_p
Re-graded =[#C]= → =[#D]= per the bug matrix. There is no defect to fix here; it is a watch item with a named tell, and the severity × frequency read is cosmetic (host coredump noise on a metric we own) × rare edge case → P4 → =[#D]=. It stays on the list only so the tell isn't lost.
The maintenance console's coredump metric flagged telega-server on ratio (8 coredumps) and velox (18). Root cause was a version skew: the Dockerized =zevlg/telega-server:latest= is frozen at the 2026-06-05 build while the installed elisp lagged at 20260513, so the newer server's plist parser choked on the older elisp's output. .emacs.d fixed it by upgrading telega to 20260706 on both machines (docker kept, =docker pull= is a no-op against the frozen image). Host-coredump pollution should stop. If zevlg later pushes a =:latest= that outruns the installed elisp, the skew and the coredumps recur — the tell is a fresh =tdat_plist_value:500= assertion in =~/.telega/telega-server.log=. The durable escape is a host-native pinned TDLib build, at the cost of an AUR source build.
-* Archsetup Resolved
-
-** DONE [#C] Net panel: Enterprise error never dismisses :bug:dotfiles:network:
-CLOSED: [2026-07-12 Sun]
-Fixed in dotfiles =a157bed=. Root cause: error toasts are sticky by design (so background refreshes can't wipe an unread error), but the enterprise join hint's flow posts no follow-up status and row clicks post none either, so nothing ever replaced it. Fix: a window-wide capture-phase click gesture dismisses a sticky toast on the user's next interaction; policy in =viewmodel.toast_action_plan= (unit-tested), timed toasts and background clears unchanged. Panel smoke run confirms launch/doctor/close with the gesture installed. Pointer-level dismiss is a manual-testing child (AT-SPI can't drive pointer gestures). Repro screenshot: =~/pictures/screenshots/2026-07-10_195911.png=.
-** DONE [#C] Net diagnostics leak connection names + SSIDs into copyable report and --json :bug:dotfiles:network:solo:
-CLOSED: [2026-07-12 Sun]
-Resolved in dotfiles =df1543a=: the =redact_ssid= toggle now scrubs saved profile names, active SSIDs, envelope-carried names, and =.nmconnection= keyfile basenames from the copyable report and the diag/doctor =--json= envelopes (one systemic pass in =redact.py=; MAC/IP scrub applies to those envelopes too). On-screen output and functional envelopes (status/list) unchanged. 15 new tests; live-verified on ratio (toggle on removes the active connection name from =diagnose --json=, default unchanged).
-The net doctor's copyable report (=report.py=, =scrub_text=) scrubs only MAC/IP, and =net diag/doctor --json= (=cli.py=) dumps the raw dict with no redaction. SSID redaction lives only in the event log (=redact_event=, gated on =redact_ssid=, default off). So a connection name (usually the SSID) appears in the clear in the link-step evidence and in every =--json= consumer — the copyable report is exactly the text a user pastes into a bug report. Secrets (PSK/password/token/portal URL) are already stripped, so this is names, not credentials: Minor severity, graded on severity alone per the privacy carve-out.
-
-Split out of the 2026-07-11 net-doctor-expansion spec review: that spec's new rival-manager/keyfile-perms verdicts keep parity with this pre-existing behavior rather than half-solve it. Fix shape: extend redaction to cover the connection name + keyfile basename across the copyable report and =--json= (one systemic pass, not per-verdict), with a redaction test. Engine-wide, so it wants one coherent change rather than being bolted onto the expansion work.
-** DONE [#B] Bt doctor expansion v1 — build the READY spec :feature:dotfiles:bluetooth:
-CLOSED: [2026-07-12 Sun]
-:PROPERTIES:
-:SPEC_ID: 3d4d61c4-e5df-44e9-b8e0-40b31452c3f7
-:END:
-Build the [[file:docs/specs/2026-07-11-bt-doctor-expansion-spec.org][bt doctor expansion]] (IMPLEMENTED). Adds a dmesg firmware-hint probe (names the missing blob on a no-adapter fault) and a boot-enablement probe (catches an adapter disabled at boot) to the shipped bt doctor (=~/.dotfiles/bluetooth/=). Archsetup owns the dotfiles work end to end. All phases shipped and fake-verified (d19fdca, f05a9b4, d7d859f); the live reboot-persistence half is on the manual-testing checklist.
-*** 2026-07-11 Sat @ 03:06:32 -0500 Built the two read-only probes
-New module =bluetooth/src/bt/probes.py= plus =doctor.py= wiring, on dotfiles main (=d19fdca=, pushed). Two reads the diagnose chain never did: =firmware_hint()= scans the current boot's kernel log for per-vendor firmware-load failures (Intel ibt-*.sfi, MediaTek BT_RAM_CODE, Realtek rtl_bt, Broadcom .hcd, Qualcomm QCA), returning the named blob via a bounded =cmd.run(journalctl -k)= that reuses the =doctor.py:84= precedent; =boot_enablement()= reads three boot-persistence signals (bluez AutoEnable from main.conf [Policy], =systemctl is-enabled bluetooth=, whether TLP lists bluetooth in =DEVICES_TO_DISABLE_ON_STARTUP=). =diagnose()= gates the firmware read to the no-adapter branch and the boot read to the soft-blocked/powered-off branch, so a healthy run reads neither; the raw signals ride a new =probes= key that =doctor()= carries into =--json=. Detection only: no verdict, formatter, or repair change (that's Phase 1). Every read degrades to None on an unreadable tool/file, so a probe that can't see never invents a fault. AutoEnable absent/unset reads None, not false, matching bluez's compiled default of true, so only an explicit =AutoEnable=false= is the fault. New env roots for tests (=BT_MAIN_CONF=, =BT_TLP_CONF=, defaulting to absent temp paths in the Sandbox base so no test reads real /etc); =fake-journalctl= branches on =-k=, =fake-systemctl= answers =is-enabled bluetooth=. 125 bt tests, full =make test= green; =/review-code= approved (no Critical/Important; one Minor noting the firmware read also covers the btctl-unavailable branch, harmless). Inbox note sent to dotfiles.
-*** 2026-07-11 Sat @ 03:16:59 -0500 Built the firmware-hint Guide verdict
-On dotfiles main (=f05a9b4=, pushed). The no-adapter step now names the blob: a new =_no_adapter_step= consults =probes.firmware_hint()= on a genuine no-adapter fault and, on a per-vendor signature match, sets =evidence= to "no Bluetooth adapter found — <Vendor> firmware <blob> failed to load" and =next_action= to "update linux-firmware and reboot", tagged with a new =code="no-adapter-firmware"= so a =--json= consumer can branch without string-matching. A clean log keeps the generic hardware/driver verdict. A Guide, not a repair: the step carries no =repair= action, so =--fix= never touches it, and it needs no privilege model (so Phase 1 lands independently of the shared cross-panel model). A missing =bluetoothctl= (=BtctlError=) short-circuits before the firmware read, so the verdict fires only on a real no-adapter fault, not a broken install — this also tightened Phase 0 (which read the log on both None branches) to the genuine no-adapter case. =_mk= gained a uniform =code= key (default None) added to every diagnose step, mirroring the existing =repair= key; no test asserts an exact step key-set, verified. =format_doctor_human= already renders =evidence=/=next_action=, so no formatter change. 133 bt tests (+8), full =make test= green; =/review-code= clean. Inbox note sent to dotfiles.
-*** 2026-07-11 Sat @ 06:48:21 -0500 Built the persistent-power verdict + fix
-On dotfiles main (=d7d859f=, pushed). =_powered_step= consumes the bt Phase 0 boot-enablement probe: AutoEnable explicitly false, service disabled at boot, or TLP listing bluetooth → =powered-off-persistent= (code + evidence naming the cause) carrying the =persist-power= repair; absent/unset config reads as auto-enable-on (bluez default) → the plain =power-on=, so healthy machines can't false-positive. The =persist-power= repair fixes only the causes set (AutoEnable=true, =systemctl enable bluetooth=, drop bluetooth from the TLP list), powers the adapter on now, and verifies each cause cleared. Config edits are pure idempotent text transforms in a new =bootconf= module (comments preserved), staged and installed via a fixed-destination =cp= verb so the write needs root but the mutation is unit-testable. =priv.py= gained three narrow verbs (=enable-bluetooth=, =write-main-conf=, =write-tlp-conf=). The repair is Privileged and resolves through =panelkit= before running: can't-elevate degrades to the guide. The bt shim gained =panelkit= on its path. 287 bt tests + 65 make-test suites green, review-code Approve, voice. Inbox note sent. Live half (real reboot persistence) is the VM/manual checklist.
-*** 2026-07-12 Sun @ 09:14:00 -0500 Flipped the bt spec to IMPLEMENTED and logged the vNext items
-Spec status heading now IMPLEMENTED (dated history line + Status mirror); all four phase headings DONE. vNext items (stale-bond signature, connection-parameter hints, bt-audio-profile expansion) logged as the "Bt doctor vNext" task. The live half (real reboot persistence) remains on the manual-testing checklist — findings there come back as bugs.
-** DONE [#B] One copy + close control pair on every output wall :feature:dotfiles:solo:
-CLOSED: [2026-07-12 Sun]
-Resolved in dotfiles =dccd744=: every wall carries the o-copy/o-clear overlay pair. bluetooth gained the copy key (transcript via the new =viewmodel.step_copy_line=, CLI-shaped); maint traded its header COPY key for the overlay pair, kept HIDE, and its ✕ clears the session log via =PanelModel.wall_clear=; the four hand-rolled wl-copy calls collapsed into =panelkit.clipboard.copy_text= (PANELKIT_WLCOPY test seam), moving maint off the GTK clipboard so its copies survive the panel closing too. 15 new tests (8 clipboard, 4 step_copy_line, 3 wall_clear); full suite 66 green; all four panel smokes run live off-workspace — maint + audio fully pass, net + bt fail only the pre-existing state-word startup race.
-
-Converge all four instrument-console output walls on the net panel's well controls: a copy glyph and a ✕ close, as an overlay at the top right, hidden until content lands. Craig's call, 2026-07-10, while reviewing the audio doctor's wall: "network panels as standard across all others, make it consistent."
-
-Where they stand today, no two alike. net has copy + ✕ (=net/src/net/gui.py=, the =o-copy= / =o-clear= overlay). bluetooth has ✕ but no copy. maint has COPY + HIDE as keys in a header row, and no ✕. audio just gained copy + ✕ (dotfiles =bd33440=).
-
-Work: give bluetooth a copy key, give maint the overlay pair, and lift the four hand-rolled =_copy_output= implementations into one shared helper rather than a fifth copy. maint keeps HIDE alongside close, because its wall is a persistent session action log you collapse and keep, where net's, bt's, and audio's are per-run results you dismiss.
-
-Copy text is per panel but one rule: it pastes as that panel's CLI prints, so the paste lines up with the terminal a user is already looking at. audio's =viewmodel.wall_copy_text()= is the worked example.
-
-Consistent with the 2026-07-07 scope note on the sibling task above: net's compact glyph overlay is what standardizes, not maint's wide COPY-key header row.
-** DONE [#C] Org-capture float popup grows too large :bug:hyprland:quick:solo:
-CLOSED: [2026-07-14 Tue]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-13
-:END:
-Craig answered the pre-flight (2026-07-14): cap at 120 wide, height proportional. Applied as 120 Emacs columns (11 px/col measured from the live daemon) = 1320 px wide, height 653 px from the old rule's aspect. Ratio's size rule shrank from the 1892x936 scratchpad match and both hosts gained a max_size growth cap (the field is max_size — bare "maxsize" is invalid and hyprctl reload won't say so; check hyprctl configerrors). Verified live: config clean, a probe window floats at exactly 1320x653. Dotfiles 9c4dc2f.
-** DONE [#C] Panel smoke: faceplate state-word assertion fails on the live compositor :bug:dotfiles:test:
-CLOSED: [2026-07-14 Tue]
-Diagnosed and fixed within the session: not a race — test drift. Dotfiles b581d5d (2026-07-05) made the faceplate word the static subsystem identity (NETWORKING / BLUETOOTH / AUDIO) and updated the audio smoke, but the net and bt smokes kept asserting the retired live-state words and had failed on every run since. Both now assert the identity word like audio's (dotfiles 32cd99f); both smokes run RESULT: OK end to end, which also green-gates the doctor-streaming change.
-** DONE [#C] Realtime lamp output for the net + bt doctors :feature:solo:
-CLOSED: [2026-07-14 Tue]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-09
-:END:
-Shipped in dotfiles 0318a91. Both doctors stream: diagnose() emits each step as it completes (bt streams the first diagnosis only — the fix loop's re-diagnoses would replay the chain), and a repair's row goes up amber at attempt start and settles green/red with narration + evidence at completion, so the lamp blinks for the repair's real duration. The 3.5-entry height cap turned out to already be in both wells (it landed with the doctor expansions), so only the streaming half needed building. 5 new tests across net + bt; both suites green; AT-SPI smokes at parity with HEAD (one pre-existing state-word failure, filed separately).
-Retrofit the net doctor (=~/.dotfiles/net/src/net/doctor.py=) and bluetooth doctor (=~/.dotfiles/bluetooth/src/bt/doctor.py=) to stream results as a live output wall — one lamp per escalation step, amber while running, green on success, red on failure — instead of a final summary. Matches the maintenance-console doctor design (see [[file:docs/design/maintenance-console-design-ideas.org][maintenance-console-design-ideas.org]], "Doctor = live output wall"). Goal: every doctor in the system reads the same way. Both doctors already step through an escalation chain re-probing after each, so the steps are natural lamp boundaries.
-
-Scope note (Craig, 2026-07-07): realtime lamp *behavior* only. The maintenance console's wider results-wall layout (date+time stamp column, COPY, persistent history) does NOT backport — the net/bt panels are ~400px wide and lack the horizontal real estate. Their existing output wells keep their compact layout; this task just makes them stream live.
-
-Addendum (Craig, 2026-07-07): DO backport the 3.5-entry height convention — every panel's output well caps at 3.5 visible entries, the half-visible entry being the scroll cue, with the dark slate-on-black scrollbar. Layout stays compact per above; only the height cap + scroll affordance carries over.
-** DONE [#B] Absorb the clock-panel project into the dotfiles :feature:waybar:dotfiles:
-CLOSED: [2026-07-18 Sat]
-Absorbed into =~/.dotfiles= (commit 3fab11d): package =clock/src/clock/= (renamed from clock_panel), the six PNG watchface layers packaged inside the module at =clock/src/clock/assets/=, a stowed =clock-panel= shell shim (LD_PRELOADs gtk4-layer-shell), waybar left-click now =clock-panel toggle= with the absolute path dropped, tests converted pytest→unittest into =tests/clock/= plus an asset-load guard. Kept the layer-shell overlay and the socket toggle. The standalone repo is archived (ARCHIVED.md), kept for its design history. Verified live: the bar click renders the polished watchface.
-** DONE [#A] Velox boot recovery — no kernel in BE :bug:velox:zfs:
-CLOSED: [2026-07-19 Sun]
-Recovered. Velox boots linux-lts 6.18.38 and is back on the tailnet (up 1d+, /boot holds initramfs-linux-lts.img). The pre-pacman ZFS snapshot rollback restored the kernel from the ZBM recovery shell.
-Velox won't boot: ZBM prompts for the passphrase, unlocks, then reports no bootable environment with a kernel. Cause: an interrupted kernel =-Syu= removed the old kernel and never installed the new one — /mnt/be/boot (from zroot/ROOT/default) holds ONLY intel-ucode.img; vmlinuz-linux + both initramfs are gone. /boot lives inside zroot/ROOT/default (no separate boot dataset), so root-dataset snapshots capture it.
-
-Status 2026-07-15: a first rollback attempt did NOT fix it (square zero after reboot) — suspected typo in the snapshot name, so the rollback likely errored and did nothing. NOT verified. Next session: verify state in the ZBM recovery shell BEFORE any reboot.
-
-Recovery lever: the pre-pacman ZFS snapshot hook (live on velox since 2026-06-29) snapshots zroot/ROOT/default@pre-pacman_<ts> before every pacman transaction. The newest =pre-pacman_<ts>= predating the failed upgrade holds the intact old kernel — roll back to it.
-
-Morning steps (Craig at velox ZBM → recovery shell, Ctrl+R):
-#+begin_src sh
-# 1. pool writable + key loaded
-zpool get readonly zroot
-zfs get -H -o value keystatus zroot/ROOT/default
-# if readonly=on: zpool export zroot && zpool import -f -N zroot
-# if keystatus=unavailable: zfs load-key zroot
-
-# 2. list snapshots — COPY THE EXACT NAME (the typo bit here last time)
-zfs list -t snapshot -o name,creation zroot/ROOT/default | grep pre-pacman
-
-# 3. see current /boot state (read-only mount)
-umount /mnt/be 2>/dev/null; mkdir -p /mnt/be
-mount -t zfs -o zfsutil,ro zroot/ROOT/default /mnt/be
-ls -la /mnt/be/boot
-
-# 4. if /boot still shows only intel-ucode.img: redo rollback with the exact name
-umount /mnt/be 2>/dev/null
-zfs rollback -r zroot/ROOT/default@pre-pacman_<EXACT-TS> # -r, NOT -R
-
-# 5. VERIFY before reboot — remount RO, confirm the kernel is back
-mount -t zfs -o zfsutil,ro zroot/ROOT/default /mnt/be
-ls -la /mnt/be/boot # MUST show vmlinuz-linux + initramfs-linux.img
-umount /mnt/be
-
-# 6. only once /boot shows a kernel:
-zpool export zroot && reboot
-#+end_src
-Scope: only zroot/ROOT/default reverts; /home, /var, /media are separate datasets, untouched. After boot: =pacman -Syu= attended, confirm /boot holds vmlinuz-linux + initramfs before any shutdown. Full diagnosis: =inbox/PROCESSED-2026-07-15-0002-from-.emacs.d-velox-boot-failure-handoff.org=; ZBM photo: =inbox/PROCESSED-2026-07-15-0002-from-.emacs.d-PXL_20260715_043758976.jpg= (local on ratio; inbox is gitignored).
-** DONE [#C] Restore date-format scrolling on the waybar date module :feature:waybar:dotfiles:quick:
-CLOSED: [2026-07-19 Sun]
-Shipped dotfiles 9dfe082: date-only ring (ordinal/full/longdate), on-scroll rewired, layout guard flipped. UTC/time stay on the time module.
-Date and time are separate fixed-position controls. The time display cycles its
-own formats, including UTC; the date/calendar control cycles date-only formats
-and never displays a second time. Implement the dedicated format rings,
-tooltip behavior, and tests together in the dotfiles Waybar configuration.
-Reference material for the compact clock/chronograph treatment is filed in
-[[file:working/clock-display-references/][working/clock-display-references/]].
-
-*** 2026-07-19 Sun @ 04:36:26 -0500 Folded clock-panel interaction direction
-The clock-panel handoff settled the prior open question: UTC belongs only to
-the time ring, while the date ring is date-only. The existing task is therefore
-a focused follow-up, not a two-line restoration of the old combined ring.
-** DONE [#C] Notification sound loudness :chore:audio:quick:solo:
-CLOSED: [2026-07-19 Sun]
-Shipped dotfiles 808ca23: NOTIFY_VOLUME default 65536->39322 (0.6 gain) in both notify copies.
-Reduce notification-sound playback loudness by 40% (0.6 gain, approximately
--4.4 dB). Change the =NOTIFY_VOLUME= playback control rather than re-encoding
-the normalized sound files; verify each notification type still plays clearly.
-** DONE [#C] Show the active wired interface in the Waybar network module :feature:waybar:network:
-CLOSED: [2026-07-19 Sun]
-Shipped dotfiles 22867f9: select_device prefers connected wifi -> connected ethernet -> wifi fallback, so a live cable shows the wired glyph+iface instead of Offline.
-When Ethernet is active, replace the offline-WiFi presentation with the wired
-interface glyph and interface name.
-** DONE [#C] Let the clock panel dismiss itself on right click :feature:clock:waybar:
-CLOSED: [2026-07-19 Sun]
-Shipped dotfiles fc9a2b7: secondary-button gesture -> ClockApplication._dismiss hides the open panel. Live-verified with Craig 2026-07-19.
-Make a right click inside the open clock panel toggle it closed. Preserve left
-click for its established interaction; the Waybar time module remains the
-explicit way to reopen the panel.
-** DONE [#C] Make the WiFi toggle connect the best available profile :feature:network:
-CLOSED: [2026-07-19 Sun]
-Shipped dotfiles 9105361: manage.wifi_radio -> _connect_best_saved activates the strongest in-range saved profile on enable; nothing in range falls back to NM autoconnect.
-When enabling WiFi, automatically connect to the highest-priority available
-saved network instead of requiring a panel selection first.
-** DONE [#A] Tracked WireGuard private keys in repo — public leak, resolved :bug:security:network:
-CLOSED: [2026-07-20 Mon]
-Confirmed a live public leak, not just at-risk: git.cjennings.net runs cgit (scan-path=/var/git), so archsetup.git was anonymously cloneable over https. An unauthenticated clone pulled the configs with intact PrivateKeys. Exposed 2026-07-05 (c7b7d16) to 2026-07-20. Regraded to P1/[#A] (public credential exposure, severity-alone carve-out) from the initial [#B].
-Scope was wider than first found: the current 3 configs (assets/wireguard-config/wg-*.conf) plus 7 older ones at the pre-reorg path assets/wireguard/ (switzerland x2, USCALA/USCASF/USDC/USGAAT/USNY) — 10 config files, all with real keys.
-Resolution: Craig expired all the Proton WireGuard configs (keys dead). Purged all 10 from every commit with git filter-repo, force-pushed main + v0.5, and ran git gc --prune=now on the server bare repo. Verified via anonymous clone: zero real-key blobs reachable, all old exposed commits gone. Stopped tracking plaintext (gitignore + README, out-of-band configs only).
-Follow-ups filed below: harden cgit exposure; installer no longer ships configs.
-** DONE [#C] Installer chpasswd unguarded — unloggable primary user :bug:solo:quick:
-CLOSED: [2026-07-20 Mon]
-Fixed (fa3135a): extracted set_user_password, which guards the chpasswd with error_fatal so a failure aborts loudly instead of silently leaving no password. Fake-chpasswd test pins the guard fires on failure and stays quiet on success.
-Grading: Major severity (fresh system's primary user can't log in) x rare edge case (chpasswd seldom fails) = P3 = [#C].
-archsetup:1168 runs =echo "$user:$pass" | chpasswd= with no guard, then unsets the password next line; set -e is off (line 21), so a silent failure leaves no password and no log entry. Fix: guard with error_fatal (report + "set it by hand: passwd $user") before unsetting. See findings doc (S2).
-** DONE [#C] Installer nvme early module never built into initramfs :bug:solo:
-CLOSED: [2026-07-20 Mon]
-Fixed in e0d22bd: extracted ensure_nvme_early_module, which rebuilds the initramfs whenever it changed the conf (regardless of ZFS root) and scopes the presence check to the MODULES line. TDD via tests/installer-steps/test_ensure_nvme_early_module.py.
-Grading: Minor severity (module autoload still boots the system) x most-machines (all Craig's ZFS-root boxes) = P3 = [#C].
-archsetup:2910 writes MODULES=(nvme) but the only mkinitcpio -P in boot_ux runs =if ! is_zfs_root=, so on ZFS-root non-Framework machines the early-load hardening is never compiled in. Also archsetup:2918 greps the whole file for "nvme" (not the MODULES line). Fix: rebuild initramfs after the MODULES edit regardless of ZFS; scope the presence grep to =^MODULES=(=. See findings doc (S3).
-** DONE [#C] Installer disk-space pre-flight check is fragile :bug:solo:quick:
-CLOSED: [2026-07-20 Mon]
-Fixed in aef074f: extracted check_disk_space using df -P (wrap-safe) and a KB comparison (no truncation bias); non-numeric df output falls back to zero so a malformed read aborts loudly. TDD via tests/installer-steps/test_check_disk_space.py.
-Grading: Major severity (aborts a valid install) x some (df wraps long device names on a live ISO / device-mapper root) = P3 = [#C].
-archsetup:487 parses =df / | awk 'NR==2'=, which reads the device-name line (empty $4 -> 0 GB) when df wraps; archsetup:488 also integer-truncates the GB compare against the 20 GB floor. Fix: =df -P /= (single-line) or =df --output=avail=; compare in KB to avoid the rounding bias. See findings doc (S1).
-** DONE [#C] Installer run_step state + exit-code handling :bug:solo:
-CLOSED: [2026-07-20 Mon]
-Fixed in 6de55d2: run_step records the state marker whenever the step function returns (a return past error_fatal's exit means only a non-fatal warning is left), added local to run_step/show_status, and captured pacman's real exit in the refresh loop. TDD via tests/installer-steps/test_run_step.py.
-Grading: Major severity (resume re-runs steps and can abort on a survivable warning) x some (a step whose last action is a non-fatal failure) = P3 = [#C].
-archsetup:298 marks a step complete only when its function returns 0, but error_warn/run_task return 1, so a non-fatal-failing step never writes its marker and re-runs on resume. Also archsetup:1034 reports =$?= of the =false= test, not pacman's real exit code; and run_step locals (290/318) leak to global scope. Fix: step functions =return 0= explicitly (or gate run_step on a per-step error flag); capture the real exit code; add =local=. See findings doc (S1).
-** DONE [#C] cmail password decrypted world-readable before chmod :bug:security:solo:quick:cmail:
-CLOSED: [2026-07-20 Mon]
-Already fixed in dffecf5 (before this session): decrypt_to_secure wraps the gpg decrypt in a 0077-umask subshell so the file is 0600 from creation, with tests/cmail/ verifying the umask at write time. The task was stale; verified green and closed.
-Grading: security carve-out — brief local plaintext exposure of the mail password, requires a concurrent local shell during install; narrow window = low severity = P3 = [#C].
-scripts/cmail-setup-finish.sh:52 gpg-decrypts to ~/.config/.cmailpass at the process umask (often 0644), then chmod 600 on the next line. Fix: =(umask 077; gpg ... --output ...)= or decrypt to a mktemp 0600 file and mv into place (mirror the import-wireguard mktemp -d 0700 pattern). See findings doc (S4).
-** DONE [#C] Installer sudoers.pacnew blind copy risks lockout :bug:solo:quick:
-CLOSED: [2026-07-20 Mon]
-Fixed in c80e855: extracted replace_sudoers_pacnew, which runs visudo -cf on the pacnew and only copies a validated file (warns and keeps the working sudoers otherwise). TDD via tests/installer-steps/test_replace_sudoers_pacnew.py.
-Grading: Major severity (a malformed sudoers locks out privilege escalation) x rare edge case = P3 = [#C].
-archsetup:1146 does =[ -f /etc/sudoers.pacnew ] && cp /etc/sudoers.pacnew /etc/sudoers= with no validation, right before the NOPASSWD rule at 1183. Fix: =visudo -cf /etc/sudoers.pacnew && cp ... || error_warn=. See findings doc (S2).
-** DONE [#C] WireGuard import leaves full-tunnel VPN live on failure :bug:solo:network:
-CLOSED: [2026-07-20 Mon]
-Fixed in 36daf76: the down now runs before the rename modify (targets the stable UUID), so a failed modify under set -e can't leave a live full-tunnel VPN. Added a connection-down case to fake-nmcli and two ordering tests.
-Grading: Major severity (all traffic silently routed through Proton until manual cleanup) x rare (nmcli modify failure) = P3 = [#C].
-scripts/import-wireguard-configs.sh:51-62 imports (which brings the 0.0.0.0/0 tunnel up), renames, then deactivates; under set -e a failed modify aborts before the down, leaving the tunnel live. Fix: bring the connection down right after parsing the UUID, before the rename. See findings doc (S4).
-** DONE [#C] net-scenarios diagnose failure exits green :bug:test:solo:
-CLOSED: [2026-07-20 Mon]
-Fixed in cf211cd: a diagnose miss sets a per-scenario rc carried to the subshell exit, so the run fails honestly while still running fix + assert. New harness at tests/net-scenarios/ drives the real script with stubbed ssh/rsync/jq.
-Grading: Major severity (a net-doctor diagnosis regression is reported as a passing run — false green on a diagnostic tool) x rare edge case (only when a diagnosis regresses and this first-draft harness is relied on) = P3 = [#C].
-scripts/testing/run-net-scenarios.sh:103 — the scenario_diagnose_expect else-branch prints fail "...diagnose did NOT name it" but never forces a non-zero subshell exit, so ( ... ) || fails=... leaves fails unincremented and the script prints "all scenarios passed" + exit 0. Fix: exit 1 in that branch like the other two checks. See findings doc (S5).
-** DONE [#C] pacman-hook-order test is a tautology :test:solo:quick:
-CLOSED: [2026-07-20 Mon]
-Fixed in 1b7236b: the test now extracts the hook filenames the installer writes and compares them against the stock 60-mkinitcpio-remove name (pacman's filename ordering is the real invariant, not source position). Mutation-verified: a 05->70 rename fails the new compare where the old literal compare stayed true.
-Grading: Major severity (guards boot-critical hook ordering — a reorder that removes the current initramfs without a rebuild is unbootable, and this test would ship it green) x rare (hook order rarely changes) = P3 = [#C].
-tests/installer-steps/test_pacman_hook_order.py:20 — the two assertLess calls compare string literals ("05..." < "60..."), a constant ASCII fact always true regardless of file content; the ordering the test exists to protect is never measured. Only the assertIn presence checks do real work. Fix: assert on positions — text.index("05-zfs-snapshot.hook") < text.index("60-mkinitcpio-remove.hook") (and the guard hook). See findings doc (S6).
-** DONE [#C] Add inetutils to install base :feature:solo:quick:network:
-CLOSED: [2026-07-20 Mon]
-Already done in 1115543 (earlier today): inetutils sits in install_required_software, with tests/installer-steps/test_required_software.py pinning it (test_installs_inetutils_for_ftp, green). The task was stale; verified and closed. The next full VM run covers the install-path verification.
-Original context: TRAMP's /ftp: method needs =/usr/bin/ftp= (GNU inetutils); dirvish has an FTP quick-access entry. Installed manually on ratio 2026-07-14. From .emacs.d handoff 2026-07-14-1751.
-** DONE [#D] Installer resume-idempotency cluster :bug:solo:
-CLOSED: [2026-07-20 Mon]
-Fixed in 8917f2f: extracted crontab_append_once (dedup guard), zfs_scrub_timer_units (one timer per pool, warn on none instead of @.timer), and enable_user_service (wants-symlink; gamemode now uses it and syncthing folds into the shared helper). TDD via tests/installer-steps/test_idempotency_cluster.py.
-Grading: Minor severity x rare edge case (re-run after a mid-step failure) = P4 = [#D]. Group of small non-idempotent / wrong-target spots.
-crontab log-cleanup line duplicates on resume (archsetup:1713 — guard on absence); zfs scrub timer picks an arbitrary pool via =head -1= and yields =@.timer= when empty (archsetup:1857); gamemode enabled via =systemctl --user= which the script itself documents fails at install time (archsetup:2419 — use the manual wants-symlink like syncthing). See findings doc (S2, S3).
-** DONE [#D] Installer unguarded chmod/cp after non-fatal ops :bug:solo:quick:
-CLOSED: [2026-07-20 Mon]
-Fixed in dd41036: extracted install_executable (guarded cp + chmod +x) for the two zfs scripts; guarded the two hypr-live-update-guard chmods inline with error_warn. TDD via tests/installer-steps/test_install_executable.py.
-Grading: Minor severity x rare edge case (only when a preceding non-fatal cp/clone failed) = P4 = [#D].
-With set -e off, unguarded chmod/cp hit missing/partial files silently: hypr-live-update-guard chmods (archsetup:2108/2144), zfs-replicate cp (archsetup:1820) leaving a service with a dead ExecStart, zfs-pre-snapshot cp (archsetup:1943) leaving a broken pacman hook. Fix: wrap each in =(...) >> log 2>&1 || error_warn=. See findings doc (S2, S3).
-** DONE [#D] normalize-notify-sounds temp/atomicity can corrupt tracked file :bug:solo:quick:
-CLOSED: [2026-07-20 Mon]
-Fixed in a29769e: resolves the real target via readlink -f, stages the temp beside it, guards on a non-empty encode, and atomically mv's into place (preserving the stow symlink); an EXIT trap cleans a leaked temp. TDD via tests/normalize-notify/ with fake ffmpeg.
-Grading: Minor severity (corrupts a repo-tracked sound file, recoverable via git) x rare (ffmpeg failure/interrupt) = P4 = [#D].
-scripts/normalize-notify-sounds.sh:39-46 has no EXIT trap on the mktemp and does =cat "$tmp" > "$f"= (truncate-first) where $f is a stow symlink into the repo; a zero-byte/failed encode writes a corrupt file. Fix: EXIT trap; =[ -s "$tmp" ]= guard; write $f.tmp and overwrite on success. See findings doc (S4).
-** DONE [#D] VM test-framework robustness cluster :bug:test:solo:
-CLOSED: [2026-07-20 Mon]
-Fixed in 866d327: profile-suffixed PID/monitor/serial paths, kill_qemu reaps-or-polls to death before the snapshot restore, debug-vm uses DISK_PATH, and both runners report an honest ARCHSETUP_COMPLETED marker instead of a fake exit code. TDD via tests/vm-framework/test_vm_utils.py (suffix red->green; kill_qemu as a contract pin).
-Grading: Minor severity x rare edge case (each fires only in a narrow test-harness path) = P4 = [#D]. Group of four small framework bugs from the S5 audit.
-scripts/testing/debug-vm.sh:49 hardcodes the btrfs base disk, ignoring the profile-correct DISK_PATH from init_vm_paths (FS_PROFILE=zfs boots the wrong base or fatals); lib/vm-utils.sh:284 kill_qemu -9's and deletes the PID file without waiting, so a force-kill restore races the dying qemu's qcow2 lock and silently leaves the base image dirty (fix: wait for the PID); lib/vm-utils.sh:69 leaves PID_FILE/MONITOR_SOCK/SERIAL_LOG un-suffixed so parallel btrfs+zfs runs collide (fix: suffix by FS_PROFILE like DISK_PATH); run-test.sh:287 (and run-test-baremetal.sh:234) reports a completion-marker grep as ARCHSETUP_EXIT_CODE, not the installer's real exit — misleading since the installer runs set -e off and can error then still write the marker (fix: rename + capture the true status). Testinfra remains the real pass/fail backstop. See findings doc (S5).
-** DONE [#D] Gallery-widget prototype elisp bugs :bug:design:solo:quick:
-CLOSED: [2026-07-20 Mon]
-Fixed in 552736e: shared clamp feeds needle + readout (150 renders 100%), explicit cl-lib require, and gallery-widget--source-dir with a default-directory fallback. TDD: 3 new ERT tests (clamp red->green; the other two land as pins since svg.el transitively loads cl-lib).
-Grading: Minor severity x rare edge case (out-of-range input / cold byte-compile / interactive re-eval) = P4 = [#D]. Prototype code, all three Minor.
-docs/prototypes/gallery-widget.el:139 renders the readout from the unclamped value while the needle clamps 0-100, so at value 150 the needle pins at +60 degrees but the text reads "150%" (fix: clamp once, format both from it); :69 calls cl-loop without (require 'cl-lib) — works only via the autoload cookie, bites on a cold byte-compile (fix: add the require); :29 computes its dir from (or load-file-name buffer-file-name), both nil on interactive re-eval outside a load/file buffer (fix: fall back to default-directory). See findings doc (S7).
-** DONE [#D] Audit test-quality cluster (Python + elisp) :test:solo:
-CLOSED: [2026-07-20 Mon]
-Fixed in 179fbd5 (plus 552736e for the gauge-level clamp test): socket check via find -type s, gen_tokens degenerate case pinned exactly as characterization, tick count as direct occurrences, and write-svg covered. All five items dispositioned.
-Grading: no runtime behavior change; test-suite quality. Group of five weak/missing tests from the S6/S7 audit.
-scripts/testing/tests/test_desktop.py:96 passes a shell glob to `test -S`, which breaks on zero or multiple sockets (masked today because the test always skips); tests/gallery-tokens/test_gen_tokens.py:181 asserts properties too weak to notice the marker output is garbled (impossible input, so low); tests/gallery-widgets/test-gallery-widget.el:77 counts ticks via split-string + cl-count-if :start 1 (a coincidence of split semantics, not a match count); :47 tests the needle-angle helper's clamp but never the rendered readout at an out-of-range value (exactly why the S7 readout/needle bug ships green — add a gauge-level boundary case); :159 leaves gallery-widget-write-svg uncovered (add a Normal write-to-temp case). See findings doc (S6, S7).
-** DONE [#B] Installer GRUB_CMDLINE overwrite drops boot params :bug:solo:
-CLOSED: [2026-07-21 Tue]
-Fixed in f9da097: update_grub_cmdline merges the current value with archsetup's tokens (existing tokens survive, same-key conflicts resolve to archsetup's value) behind a refuse-to-write safety check, via awk + mv with a backup_system_file first. TDD via tests/installer-steps/test_grub_cmdline.py (8 cases incl. cryptdevice/resume/zfs survival and idempotence).
-Grading: Critical severity (unbootable) x some-users-sometimes (machines whose base install set a cryptdevice=/resume=/zfs= cmdline param) = P2 = [#B].
-archsetup:3054 rewrites the whole GRUB_CMDLINE_LINUX_DEFAULT line with a fixed string; nothing re-adds a pre-existing cryptdevice/resume/zfs token, so grub-mkconfig (3059) can bake an unbootable config. Fix: read the current value and append only the missing tokens; assert any pre-existing boot-critical token survives before grub-mkconfig. See [[file:docs/design/2026-07-19-sentry-code-findings.org][sentry code findings]] (S3).
-** DONE [#C] Maint status wall copy buttons :feature:maint:dotfiles:
-CLOSED: [2026-07-21 Tue]
-Shipped in dotfiles 8bc79ba per Craig's calls (one global button, rendered text): COPY on the doctor row serializes every category band via the same card_spec the GUI renders, through panelkit clipboard. TDD tests/maint/test_status_copy.py, full dotfiles make test green, inbox note sent. Live check pending: open the maint panel, press COPY, paste.
-Craig's roam capture 2026-07-20, routed via .emacs.d sentry inbox-zero as archsetup-owned UI work. Dotfiles maint panel work; archsetup drives it end-to-end per the standing rule.
-** DONE [#B] Build: desktop-settings panel :feature:hyprland:dotfiles:
-CLOSED: [2026-07-22 Wed]
-:PROPERTIES:
-:SPEC_ID: d6bb1e73-ec90-4327-85ee-bfa762da5bce
-:END:
-The GTK build of the desktop-settings panel per the spec (docs/specs/2026-07-02-desktop-settings-panel-spec.org, DOING; normative reference: prototype 37). Work happens in dotfiles settings/ — archsetup drives the lifecycle. Two non-blocking build-time picks live in the spec's Review findings (wallpaper setter tool; store location/format) — decide in phase 1 and record there.
-*** 2026-07-22 Wed @ 13:14:01 -0500 Built the backings engine (phase 1) — dotfiles 7a15237
-Landed as dotfiles settings/src/settings (10 modules) + tests/settings (118 tests against fake binaries, auto-discovered by make test — 81 suites green). Covers brightness/kbd (5% floor, x10 drum), toggles (dim, pointer cycle via toggle-touchpad, caffeine), DND class-split (dunst pause level 60, close-all before unpause, alarms punch through live), powerprofilesctl, nightlight (resident gammastep), hypridle.conf renderer + symlink-safe write + caffeine-respecting reload + hyprlock grace, suntimes (pure NOAA math), and the wallpaper engine (awww/mpvpaper/projector adapters, galleries, random draw, atomic JSON store). All three build-time picks recorded as DONE findings in the spec (setter=awww, store=state.json, nightlight=gammastep). Handoff note in ~/.dotfiles/inbox/.
-*** 2026-07-22 Wed @ 15:26:44 -0500 Built the presenters (phase 2) — dotfiles 5172289
-Three GTK-free models per prototype 37, all at 100% line coverage (tests/settings/test_presenters.py, 100 tests; full repo suite green before and after). programs.py: the matrix — eight complete programs (Craig's four factory scenes drafted here per the pre-flight pick, slots 1-4 first-class), pin rows + power radio row, activate returns the full sets, member writes return apply/updated with active-is-live surviving. bench.py: drum mapping (screen never reads 0, floor 5%; kbd floors at 0), idle rail order clamping between enabled neighbors, park/unpark with re-clamp, caffeine bypass, view-state builder tolerant of no-backlight None. channels.py: the eight-channel bank, per-mode sources visibility, alpha/recency sort (unlabeled last), the shared mint/edit/delete grammar for pairs/sets/colors (press arm-cycle, two-picture set minimum, dup rejection, selection clamping), sources guardrails, interval wheel, previews. Handoff note in ~/.dotfiles/inbox/.
-*** 2026-07-22 Wed @ 16:03:52 -0500 Ported prototype 37's instruments to GTK (phase 3) — dotfiles 33d82eb
-The panel renders P37 end to end. New instruments.py carries the three Cairo instruments as clock-free humble objects: ProgramMatrix (glyph/numbered heads over jewel pins + CPU POWER paper letter wheels), DrumRoller (paper drums, drag-to-set, dimmed n/a on no-backlight machines), TripDial (sqrt 300° scale, colored stage tabs, OFF-notch parking, exact-minutes drag counter, BYPASSED · CAFFEINE stamp, bottom legend). gui.py rebuilt to P37's layout with the wallpaper sub-view: channel bank with drawn faces, minted pair/color/set trays (alpha/time sort, edit/delete chip feet), the three presses (pair arm-cycle, color picker, set press + interval wheel), sources with a folder picker. New GTK-free glue all unit-tested (test_panel_glue.py, 33 tests): dial geometry in bench, matrix/idle/wallpaper wiring in panel, presenter-vocabulary channels (pair/solid/random-from-set) in wallpaper.apply. AT-SPI smoke (make test-panel-settings) drives the real wiring against faked backings + a sandboxed store, pinned to its own child pid so it can never fire a live panel's backings. Visually verified on a headless output against P37 captures (main + pair/single/solid/random). Adaptations recorded in the handoff: five-stage dial (WATCH gets its own green — the engine runs watch separately, P37 merged the label), DESKTOP_SETTINGS_START_VIEW test seam. Full suite 84 suites green; window rule widened for the 540px panel. Handoff note in ~/.dotfiles/inbox/.
-*** 2026-07-22 Wed @ 16:47:54 -0500 Integrated phase 4 — dotfiles 680b50d
-Bar consolidation had landed early (74f723e); this pass shipped the rest. settings-project hosts the watch/clock/world channels as HTML faces (settings/faces/) on a gtk-layer-shell background window over WebKit2 — all three visually verified on a headless output, world reading the waybar worldclock roster via query param. settings-watch is the hypridle watch-stage host: throwaway-profile chrome kiosk that reveals only after its window maps behind the lock and relocks before teardown — a failed face degrades to the plain lock, never a bare desktop (unlocked lifecycle verified live; the locked swap goes to the manual checklist). Sun-pair location reads whereami live per transition with last-good cache in state.json (verified live: 9.5s first beat, New Orleans coords, Gogh day side applied); desktop-settings-tick.timer (2 min, enabled on ratio, added to the installer) drives flips and random draws — 23ms no-op beats. dunstrc history_length 100 protects held alarms (full DND cycle verified against live dunst; wtimer alarms already CRITICAL via the notify wrapper, no promotion rule needed). Live hypridle rewrite verified — five-stage regime rendered through the stow symlink, caffeine respected (found engaged, daemon correctly left stopped). Refresh signals needed no rewiring (touchpad signals itself via toggle-touchpad). 45 new tests; suite 84 suites green; smoke 13/13. Handoff note in ~/.dotfiles/inbox/.
-Velox one-time steps (sync doesn't carry): mpvpaper (AUR), optionally power-profiles-daemon (service off), and systemctl --user enable --now desktop-settings-tick.timer.
-*** 2026-07-22 Wed @ 17:05:58 -0500 Landed the 17-point end-to-end pass — dotfiles 9038eee
-Prototype 37's 17-point suite re-derived against the real panel (the original Playwright script wasn't preserved; the functional surface in the spec's Final prototype section is the source). tests/settings/panel_e2e.py + run-panel-e2e.sh + =make test-panel-e2e=: points 1-14 drive the running panel over AT-SPI (program recall with per-backing verification across FOCUS/BATTERY/slot1, pointer console keys, all eight wallpaper channels including projected watch/world stop/start ordering, close); points 15-17 cover the Cairo instruments (drums, tripper dial clamp/park/render/reload, matrix pins + letter wheels with active-is-live) at the backing layer, since AT-SPI can't reach a DrawingArea's hit-tests. Same safety posture as the smoke: sandboxed store, faked backings, pid-pinned a11y node. 17/17 green on ratio's live compositor; full suite 85 green; smoke 13/13; ruff clean. The drag gestures go to the manual checklist below. Handoff note in ~/.dotfiles/inbox/.
-*** 2026-07-22 Wed @ 17:05:58 -0500 Flipped the spec to IMPLEMENTED
-docs/specs/2026-07-02-desktop-settings-panel-spec.org DOING → IMPLEMENTED with a dated history line naming the shipping commits (dotfiles 7a15237 / 74f723e / 5172289 / 33d82eb / 680b50d / 9038eee) and the verification evidence (85 suites, smoke 13/13, e2e 17/17). The four panel drag-gesture checks and the locked-path night-watch swap live under "Manual testing and validation" — human-eye checks, not implementation blockers.
-** CANCELLED [#B] Hyprland layoutmsg crash — bad_variant_access (upstream) :bug:hyprland:
-CLOSED: [2026-07-21 Tue]
-Dropped 2026-07-21 (Craig's call) — not tracking the upstream report. The crash evidence (both reports + tmpfs log excerpts) and the voice-passed issue draft stay preserved in [[file:working/hyprland-layoutmsg-crash/][working/hyprland-layoutmsg-crash/]] if it recurs and is worth reviving.
-Grading: Critical severity (SIGSEGV kills the whole desktop session; every GUI app's unsaved state lost) x rare edge case (twice in ~4.5 months: 2026-03-07 on v0.54.1, 2026-07-20 on v0.55.4) = P2 = [#B]. Upstream Hyprland bug, not this repo's code — the task tracks reporting it and picking up the fix.
-A layoutmsg mfact dispatch (layout-resize, mod+H/L) throws std::bad_variant_access inside Layout::CAlgorithm::layoutMsg, uncaught, SIGSEGV. Both crashes fired from the layout-resize mfact path (keycode 104 shrink today, 108 grow in March). Layout at crash was master and the identical mfact had worked seconds earlier; the pre-crash window held monocle<->master toggles, two window closes dropping focus to "[Window nullptr]", and togglefloating x2. Monocle is a registered v0.55 layout (log shows graceful "Unknown monocle layoutmsg" rejects), so the config is not at fault; related edges are guarded ("mfact -> no window") while this path misses its variant guard. Repo has no newer build (0.55.4-1 installed and repo).
-Evidence preserved in [[file:working/hyprland-layoutmsg-crash/][working/hyprland-layoutmsg-crash/]] (both crash reports + excerpts from the tmpfs session log, extracted before reboot loses it).
-Next: Craig posts the issue himself (2026-07-20 decision) — the voice-passed draft is [[file:working/hyprland-layoutmsg-crash/issue-draft.md][issue-draft.md]], with both crash reports and the log excerpts beside it for attaching. Watch the repo for a fixed release and close on confirmation. The layout-resize script guard was declined (a script can't observe the internal desync).
-** DONE [#C] WireGuard import is now config-less — decide feature fate :feature:network:
-CLOSED: [2026-07-21 Tue]
-Decided 2026-07-21 (Craig): KEEP the import feature. The out-of-band flow is already in place — =assets/wireguard-config/= carries a README documenting "drop plaintext =*.conf= locally at install time (gitignored); ship encrypted =*.conf.gpg= to track", and its =.gitignore= enforces it (=*.conf= blocked, =!*.conf.gpg= allowed). The script also already no-ops gracefully on an empty dir (=shopt -s nullglob= + a =found= flag), so nothing ships and nothing errors when no configs are present. Nothing to build; the fate decision was the whole task.
-scripts/import-wireguard-configs.sh reads assets/wireguard-config/*.conf, but no configs ship in the repo anymore (removed as a public-leak fix; .gitignore blocks plaintext).
-** DONE [#C] Dupre theme waybar.css drifted from live style.css :bug:dotfiles:waybar:
-CLOSED: [2026-07-21 Tue]
-Fixed in dotfiles 3e4e7ff (2026-07-20, "fix(theme): sync dupre waybar.css with the live weather rules") — dupre/waybar.css is byte-identical to live again, restoring the =#custom-weather= selectors/hover/gold divider, so =tests/theme-css= is green.
-Grading: Minor severity (cosmetic, reverts only on a theme switch) × rare edge case (dupre is already the active theme) = P4 = [#D] on user impact, bumped to [#C] because the dotfiles =make test= stays RED until synced, poisoning the green baseline for every future commit.
-The weather-kit work added =#custom-weather= selectors to =hyprland/.config/waybar/style.css= but never mirrored them into =hyprland/.config/themes/dupre/waybar.css=. =tests/theme-css= asserts the two files are identical (set-theme copies the theme file over the live one), so switching to dupre would silently revert the weather chip styling. Fix: sync the theme file to live. Pre-existing; found 2026-07-19 during an unrelated commit's green-baseline run.
-** DONE [#A] Hyprlock lockout: AMD-iGPU DPMS invalidates the lock, session wedges :bug:hyprland:installer:solo:
-CLOSED: [2026-07-24 Fri]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-24
-:END:
-Fixed as archsetup =a9391c9= + dotfiles =3046c9c=, both pushed; applied live to ratio and velox. Reboot ratio to activate the root fix (=amdgpu.runpm=0=); the watchdog covers until then.
-
-WHAT HAPPENED. Ratio's screen idle-locked, then wedged: hyprlock gone, the compositor still holding the ext-session-lock, no password prompt, recoverable only from a console. Recovered live with =hyprctl dispatch exec hyprlock= (=allow_session_lock_restore=true= was already set, so a replacement client adopted the dead lock).
-
-ROOT CAUSE (evidence, not the first guess). My first read was "hyprlock crashed on its screenshot buffer" — WRONG. Coredumps are captured here (two telega SIGSEGVs the same afternoon) and there is NO hyprlock coredump, so it did not segfault; memory was fine, so not OOM. The hyprland log shows the real chain: =Modesetting DP-4= / =Restoring crtc 86= (a display modeset) → =color management protocol is enabled and outputs changed= → =SessionLock.cpp:50 SessionLockSurface object remains but surface is being destroyed=. A display power cycle tore down the lock surface. Online research confirms it's a documented AMD-integrated-Radeon issue (hyprlock#953, Hyprland#5822): the GPU resources the lock client holds become invalid when the display powers down and back up. Ratio is a Strix Halo Radeon 8060S — exactly that hardware, and its cmdline already carried =amdgpu.dcdebugmask=0x10= + =no_vpe_idle_pg=1= display workarounds, a history of the same fragility.
-
-THE FIX, four layers, research-validated:
-1. Root cause: =amdgpu.runpm=0= on the kernel cmdline (AMD only, added in =update_grub_cmdline= behind =detect_gpu_vendors=). Keeps GPU runtime PM from invalidating the resources on a display cycle. Live in ratio's grub.cfg; effective next boot.
-2. Separate crash cause: =configure_hyprlock_pam= writes a complete =/etc/pam.d/hyprlock= (auth/account/session). The package default is =auth include login= only, so pam_end() crashes on uninitialised handles. Applied live to both machines.
-3. Recovery net: the =screen-lock= watchdog (dotfiles) relaunches hyprlock on a non-zero exit; hypridle's =lock_cmd= routes through it. Independently the same shape as the community's watchdog layer.
-4. NOT done, deliberately: the =dpms off= listener stays in the committed hypridle — =runpm=0= makes it safe on AMD, and it's wanted on Intel/velox for idle display-off. Ratio's test rail already removed it as a local choice.
-
-REVERTED a wrong turn: I'd first built a screenshot-to-file change (grim the desktop, point hyprlock at the file) on the theory the live screencopy buffer crashed. The research showed the cause is GPU runtime PM, not the background source, so I dropped it and reverted hyprlock.conf to =path = screenshot=.
-
-PROCESS NOTE — I hit the pathspec-commit trap AGAIN (the one the =Two agent sessions sharing one repo= VERIFY documents). After surgically staging only the =lock_cmd= line via =git update-index=, I ran =git commit <path> -m ...=, which commits the WORKING TREE of that path, not the index — so it committed ratio's test rail (dpms-off removed, timeout 450) with a message claiming dpms-off stays. Caught it before push, =git reset --soft=, re-verified. The rule: after =update-index=, commit with =git commit= (no pathspec), never =git commit <path>=.
-
-Tests: archsetup 372 (test_grub_cmdline AMD-runpm cases + test_hyprlock_pam, both call sites in CALL_SITES); dotfiles 3687 incl. tests/screen-lock. Each guard proven by deletion.
-
-Grading: Critical severity (full session lockout, console-only recovery) x rare edge case (needs an idle lock plus a display modeset on the AMD iGPU) = P2 = [#B by the matrix]. Raised to [#A] here because it stranded a live machine and the root fix needs a reboot to arm — worth Craig seeing at the top until he reboots ratio.
-** DONE [#B] Adversarial review of the sentry run — six fixes reworked :bug:test:tooling:solo:
-CLOSED: [2026-07-24 Fri]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-24
-:END:
-Craig asked for a skeptical review of every sentry change. Eight agents covered all 23 code commits, each told to disbelieve by default and to answer three questions per commit: does the problem exist and is it reachable, is the fix correct or is there a better one, would each test fail with the fix reverted. Every finding below was re-verified by hand before acting on it.
-
-SIX COMMITS NEEDED WORK, now fixed: archsetup =1207ca5= (wipedisk), =96e12b5= (firmware trim), =560e1dd= (autologin), =3c2155d= (initramfs tabs); dotfiles =ec7229b= (tunnel import), =a81aa0e= (thumbnail sweep), =56807e5= (three residual guards), =c90ee34= (event-log isolation). Both suites green: archsetup 341, dotfiles 3687 on both gates.
-
-THE ONE THAT MATTERED MOST. =wipedisk= ran =blkdiscard -f= BEFORE the busy check. =-f= disables the exclusive open util-linux has used since 2.36, so on the exact case the round-11 commit reasoned about — the user picked the wrong disk — it discarded a live filesystem and only then let sgdisk fail, printing "could not clear the partition table ... run this again". Data gone, user told nothing happened. The ordering predates the sentry commit, but round 11 wrote reasoning about the busy-disk case into the comment and error text while leaving the discard first, which made the misreport worse in the one direction that costs something. Dropping =-f= makes the kernel's own O_EXCL the gate.
-
-THREE PATTERNS WORTH MORE THAN THE INDIVIDUAL FIXES:
-
-1. CALL SITES WENT UNTESTED IN FIVE SUITES. Every helper had thorough tests; not one proved it was called. Deleting the call left everything green — including the guard on a =pacman -Rdd= of twelve firmware packages, whose removal would have run the trim on ratio. Closed with =CALL_SITES= in =test_orchestrators= (nine pairs, static) and a wiring assertion in the settings suite. Static on purpose: the behavioural harness runs un-stubbed bodies for real, which is fine for an orchestrator and not for a leaf that removes packages.
-
-2. A NEW OUTCOME VALUE NEEDS EVERY CONSUMER WALKED, EVERY TIME. Done for the portal enum in round 3, skipped for the tunnel-import one in round 4 — where =import_configs= folded a disarm failure into "none imported (N failed)", the opposite of what happened, in the multi-select flow the GUI actually uses.
-
-3. MY FIXTURES TWICE CLAIMED A FIDELITY THEY DID NOT HAVE. The wipedisk fixture used this machine's real disk names, so five of six tests passed with the seam removed. The mkplaylist fake does a full =cat > /dev/null= drain while its docstring says it "drains stdin exactly when the real one would" — which is what let the wrong failure mode survive.
-
-AND ONE FINDING WAS DISPROVED OUTRIGHT: round 1's =a57c443= claimed ffmpeg drains the read loop so only the first track is processed. Measured under strace and driven end to end with real ffmpeg (three runs of three, four 120s mp3s), the loop never truncates. The hazard is real and =-nostdin= is right; the symptom was reasoned from shellcheck SC2095 and never run. Corrected in =a30741a=, along with the OpenVPN autoconnect claim and the "four consumers" undercount.
-
-ALL THREE NOW CLOSED, in dotfiles =c7cb40d= (pushed). =_restore_dot='s =noop= split into =already-on= and =not-managed=, so the step stops claiming a restore that never happened. =_disable_dot= checks its restart as well as its move, since moving the drop-in aside does nothing until resolved reloads.
-
-The thumbnail one could not be built as described, and that is worth recording. The cache name is a SHA-1 of realpath plus mtime, so no filename says which source it came from; per-source sweeping would mean changing the key format and invalidating every cached thumbnail. Bounding the growth gets the same result for less: a deferred sweep now trims to a 500-file ceiling, oldest first, because eviction is safe exactly where sweeping is not (an evicted thumbnail is rebuilt on the next warm pass, costing one decode and never a file). WHEN A FIX CANNOT BE BUILT AS SPECIFIED, SAY SO AND SOLVE THE ACTUAL HAZARD — the hazard here was unbounded growth, not imprecise attribution.
-** DONE [#D] Repair tiers call an unverifiable service restart a failed one :bug:network:bluetooth:solo:
-CLOSED: [2026-07-24 Fri]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-24
-:END:
-Fixed as dotfiles =041d6b9= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). 7 new tests across =tests/bt/test_bt.py= and =tests/net/test_net.py=; dotfiles suite 3665 -> 3672, =make test= exit 0 on both gates. Each of the three guards proven a real gate by deleting it and watching the suite go red.
-
-Found in the 2026-07-24 sentry bug-hunt, round 14, on the cross-package =repair.py= diff that rounds 5-13 had left unspent.
-
-=cmd.service_active= is tri-state in both the net and bt packages, and its docstring says so outright: True, False, or None when systemctl itself can't answer (absent binary, or a timeout). Six callers. Three rule on it correctly — =bt/doctor._service_step= branches on None with "systemctl unavailable — can't check the service", and =net/diag= compares =is False= at both its call sites. Three tested it with plain truthiness:
-
-- =bt/repair.py= =repair_service_restart=
-- =net/repair.py= =_service_restart= (the nm-restart and resolved-restart tiers)
-- =net/repair.py= =repair_unmask_nm=
-
-So an unanswerable systemctl was reported as "bluetooth.service is still not active" / "NetworkManager still isn't running after a restart" — a statement about the service made on no evidence at all. Each then pointed the user at =journalctl -u <unit>=, which is the same systemd client stack that had just failed to answer. That last part is round 10's read again: an error message advertising a remedy it cannot honour.
-
-All three now report =warn= on None, with evidence naming the verification rather than the service, and a next action of checking systemd is reachable and re-running the doctor. Control flow is unchanged: =warn= was already a status both packages emit, both CLIs already exit non-zero on anything but =pass=, and =net/doctor= only inspects a repair step's status for the =dns-test= tier — every consumer was checked before the change, not after. (An adversarial re-review counted twelve, not four; all twelve handle =warn= correctly, so the conclusion held while the claim understated the work.)
-
-THE SEAM FOR THE TESTS, worth reusing: both suites already carry an exec-failure harness that plants a non-executable file on an emptied PATH, which is exactly what makes =cmd.run= return None. So the None case is reachable through the real code path with no mocking at all. Each test class asserts that premise first (=service_active= really is None in the sandbox) rather than assuming it.
-
-Grading: Minor severity (the claim is wrong but errs pessimistic — it says a repair failed when it may have worked, rather than falsely reassuring; nothing is damaged) x rare edge case = P4 = [#D]. Fixed rather than filed because the change is three branches and it completes a class — leaving two of three sites collapsed is the failure mode the round-6 =c2eb3e1= commit exists to remember.
-
-NOT PART OF THIS CLASS, checked and left alone: =settings/toggles.dim_state= is the only other genuine True/False/None helper in the tree, and both its callers pass the value through to the viewmodel rather than collapsing it. Every other "or None" in the packages is two-state (a value or nothing), where falsy handling is correct.
-** DONE [#B] Firmware trim gated on a DMI field that never carries the vendor :bug:tooling:solo:
-CLOSED: [2026-07-24 Fri]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-24
-:END:
-Fixed as archsetup =2e228f7= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). New =tests/installer-steps/test_framework_firmware_trim.py=, 12 tests carrying the real DMI strings off both daily drivers. Each of the three conditions proven load-bearing by deleting it and watching the suite go red, and the old gate proven wrong by restoring it (4 failures).
-
-Found in the 2026-07-24 sentry bug-hunt, round 13, reading archsetup's remaining state-mutating steps. =trim_firmware= gated on =grep -qi "framework" /sys/class/dmi/id/product_name= and no Framework machine has "framework" in =product_name= — it lives in =sys_vendor=. Read live: velox is =Framework= / ="Laptop (13th Gen Intel Core)"=, ratio is =Framework= / ="Desktop (AMD Ryzen AI Max 300 Series)"=. The gate returns false on both, so the step has been a silent no-op on the exact hardware it was written for. velox IS trimmed today (=linux-firmware-{atheros,intel,realtek,whence}= and nothing else) but not by this code path.
-
-THE REPAIR IS WHERE THE DANGER IS, which is why this is worth reading twice. Swapping =product_name= for =sys_vendor= is the obvious one-word fix and it is wrong: ratio is a Framework Desktop, and =trim_firmware= runs =pacman -Rdd linux-firmware-amdgpu=, which takes the firmware its Ryzen AI Max iGPU needs to bring up a display. Today only the =grep -qi intel /proc/cpuinfo= second gate stands between ratio and that. So =is_framework_intel_laptop= wants three DMI facts — vendor Framework, and a model naming both Laptop and Intel — and the cpuinfo read stays as an independent second gate rather than the only one.
-
-Verified live after the change: velox TRIM=yes, ratio TRIM=no, where the old gate said no to both.
-
-Grading: Minor severity (the trim never happens; nothing breaks, the machine just carries ~550MB it was meant to shed) x every user, every time (every Framework Intel install, which is the whole population the step targets) = P2 = [#B]. The AMD-firmware removal is not graded separately because it never shipped — it is the hazard the fix is shaped to avoid.
-** DONE [#B] Fresh install leaves the dotfiles repo permanently dirty :bug:tooling:solo:
-CLOSED: [2026-07-24 Fri]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-24
-:END:
-Fixed as archsetup =c3b3617= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). New =tests/installer-steps/test_mark_volatile_configs.py=, 8 tests against a fixture git repo with =sudo= stubbed on PATH. Every guard proven a real gate by deletion. A note went to =~/.dotfiles/inbox/= because =skip-volatile= now has an outside caller.
-
-Found in the 2026-07-24 sentry bug-hunt, round 13, diffing archsetup's =stow_dotfiles= against the dotfiles Makefile's =stow= target — two implementations of one operation, which is round 5's read applied across repos rather than across packages.
-
-The Makefile's =stow= target ends with =$(MAKE) skip-volatile=, setting git's skip-worktree bit on the four configs their apps rewrite in place (=btop=, =qalculate=, =calibre=, =waypaper=; the list is =volatile-configs=). archsetup stows inline with raw =stow= calls and never ran that step. So a machine archsetup installed goes dirty the first time one of those apps writes its config, and every later =git pull --ff-only= trips over paths the user never edited. Confirmed by grep: archsetup contains no =skip-volatile=, no =volatile=, and no =make stow= — yet both daily drivers carry the bits, so they came from a hand-run =make stow=, not the installer. ratio in fact carries seven, three more than =volatile-configs= lists, which is evidence the churn is real and ongoing.
-
-The fix calls the dotfiles target rather than copying its logic, so the volatile list stays single-source. Two details that are load-bearing: it runs *after* =git restore .= so the bit lands on a pristine tree, and it runs as the user, because root writing =.git/index= leaves it root-owned and the user's next git command then cannot update the index at all. A checkout with no Makefile is a quiet no-op — nothing to delegate to is not an error.
-
-DELIBERATELY NOT DONE: replacing the whole inline stow with =make -C "$dotfiles_dir" stow "$desktop_env"=. The Makefile stows =--target=$(HOME)=, which during an install is root's home, and it carries interactive conflict handling; archsetup stows =--target=/home/$username --adopt= as root on purpose. =skip-volatile= is the one target with no such coupling — it works on the repo through =git -C= and never reads HOME.
-
-Grading: Minor severity (a repo that reads dirty forever and pulls that need a stash; the workaround is one command) x every user, every time (every fresh install that stows dotfiles) = P2 = [#B].
-** DONE [#C] Unattended install blocks on an interactive prompt :bug:tooling:solo:
-CLOSED: [2026-07-24 Fri]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-24
-:END:
-Fixed as archsetup =cbcb53f= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). New =tests/installer-steps/test_configure_autologin.py= (11) and =tests/installer-steps/test_select_locale.py= (11). Every guard proven a real gate by breaking it and watching the suite go red: dropping the autologin unattended branch fails 1 (on the leftover-stdin assertion, which is the real gate — the drop-in still gets written because the read swallows the sentinel and treats it as "yes"); dropping the locale unattended branch fails 1; breaking either precedence rule fails 2.
-
-Found in the 2026-07-24 sentry bug-hunt, round 12, continuing through archsetup's own installer. Two members of one class, which is the point: round 10 fixed the third member and left these.
-
-THE CLASS: an advisory prompt — one that carries its own default — still reading stdin under =--config-file=, the documented unattended mode. Round 10 ruled on it for =nvidia_preflight='s rc-10 prompt. Two sites never got the ruling.
-
-1. =configure_autologin=. When =enable_autologin= is unset (=AUTOLOGIN= is optional, and =archsetup.conf.example= line 31 ships it commented out) and the root is encrypted, it prompted =Enable automatic console login for $username? [Y/n]= on a bare =read=. It runs from =configure_encrypted_autologin=, inside =boot_ux=, the last entry in =STEPS= — so an unattended install of an encrypted machine works for 40-60 minutes and then sits at a prompt nobody is watching. Under =curl | bash= it is worse: stdin is the script itself, so the read eats a line of source.
-
-2. =select_locale= (extracted from =preflight_checks= by this commit). The =Choice [1]:= menu fired whenever =/etc/locale.conf= carried no =LANG== and =LOCALE= was unset — also commented out in the example config. archsetup does not require an archangel install, and =configure_build_environment='s own "no LANG=" branch is proof it expects that state.
-
-Both now take the prompt's own default under =--config-file= and print an =[OK] ... (unattended, --config-file)= line saying so. An explicit =AUTOLOGIN=yes/no= or =LOCALE== still wins; the default only answers a question nobody can.
-
-WHAT MADE THEM TESTABLE, which is round 10's read (d) applied again: =configure_autologin= hardcoded =/etc/systemd/system/getty@tty1.service.d= and =select_locale= hardcoded =/etc/locale.conf=, so neither could run against a fixture — while their siblings =replace_sudoers_pacnew= and =ensure_nvme_early_module= both take a defaulted path argument for exactly that reason. Both now do. Zero shellcheck delta against HEAD; =make test-unit= 276 -> 298, exit 0.
-
-Grading: Major severity (unattended installation, a documented feature, does not complete; recoverable by pressing a key, no data loss) x some users, sometimes (needs unattended mode plus an omitted key) = P3 = [#C].
-
-THE PROMPTS DELIBERATELY LEFT ALONE, because the class is "prompts with a default", not "all prompts": username (line 636) and password (648/650) have no default to take — there is no sane fallback for either, and =archsetup.conf.example= documents both as "If not set, you will be prompted". They also fire in =preflight_checks=, in the first second of the run, where a blocked prompt is visible rather than silent. The "Enter locale" sub-prompt is reachable only from menu choice 9, which unattended never picks.
-** DONE [#C] wipedisk says "Disk erased." when it erased nothing :bug:tooling:solo:
-CLOSED: [2026-07-24 Fri]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-24
-:END:
-Fixed as archsetup =HEAD= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). New =tests/wipedisk/test_wipedisk.py=, 6 tests running the real script against a fixture device directory with fake blkdiscard/sgdisk on PATH. All four guards proven real by deleting each and watching the suite go red.
-
-Found in the 2026-07-24 sentry bug-hunt, round 11, reading =scripts/= — 30 lines, no tests, and the most destructive script in the repo. Not installed by the installer; it is run by hand from the checkout, which is why the frequency axis stays low.
-
-Three defects, all of which make the script's final word untrue:
-
-1. =sgdisk --zap-all= had its result discarded, and "Disk erased." printed unconditionally. sgdisk refuses a busy device — a mounted filesystem or a live md/LVM/ZFS holder — which is exactly what a user hits after picking the wrong disk. So the tool announced an erase it had not performed and exited 0.
-
-2. "Disk erased." overstates what the tool does even on success. =sgdisk --zap-all= destroys partition tables, not data, and =blkdiscard -f ... || true= deliberately tolerates a device that cannot discard. On a disk without discard support the script cleared the partition table and left every byte readable, while telling the user the disk was erased. That is the one path where the wrong belief has a privacy consequence — someone trusting the message before disposing of a drive.
-
-3. The prompt says "Select the disk id to use" and then listed every entry in =/dev/disk/by-id=. On this machine that is 18 entries of which 12 are =-partN= partitions (verified by listing it). The menu promised disks and offered partitions.
-
-Fix: whole disks only (globbed rather than =ls | grep=, so a name with whitespace cannot split into two menu entries); the zap's result is checked and a failure exits 1 naming the busy-device cause; the closing message reports what actually happened, and when discard was unsupported it says the data is still recoverable and points at =nvme format= / =hdparm= for a disposal-grade wipe.
-
-Grading: Major severity (the tool reports an outcome it did not achieve; in the disposal case that is a data-exposure consequence) × rare edge case (a hand-run helper the installer does not install, and defect 1 additionally needs sgdisk to fail) = P3 = [#C].
-
-Worth recording about the tests rather than the code: two of the six passed against the unmodified script for the wrong reason. Without the =WIPEDISK_BY_ID= override the script read the real =/dev/disk/by-id=, so the harness was driving a menu of this machine's actual disks (harmless — the fake blkdiscard/sgdisk shadowed the real ones on PATH — but it was not testing the fixture). And =test_empty_by_id_directory= was not a gate at first: with the guard deleted the empty select menu still falls through to the confirm prompt, reads EOF and declines, so exit code and call log alone pass either way. It now asserts the message.
-** DONE [#B] zfs-replicate reports success when every backup failed :bug:backup:solo:
-CLOSED: [2026-07-24 Fri]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-24
-:END:
-Fixed as archsetup =HEAD= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). Diagnostics moved to stderr; the loop counts failures and exits 1 when any dataset failed. New =tests/zfs-replicate/test_zfs_replicate.py=, 9 tests driving the real script with a fake syncoid and a fake ping on PATH (the =tests/zfs-pre-snapshot/fake-zfs= pattern). Both fixes proven real gates by reverting them: dropping the counter fails 3, putting =error()= back on stdout fails 1.
-
-Found in the 2026-07-24 sentry bug-hunt, round 11, reading =scripts/= — 73 lines with no test file, installed by =configure_zfs_snapshots= as =/usr/local/bin/zfs-replicate= and run by =zfs-replicate.service=, a =Type=oneshot= on a nightly timer. Its exit code and its journal output are the only signals anyone ever sees.
-
-Two defects, both verified by running the script rather than argued:
-
-1. The full-replication loop caught each =syncoid= failure, warned, carried on, then printed "Replication complete." and exited 0 regardless. Driven with a fake syncoid failing all four datasets: four =[WARN] Failed= lines, then "Replication complete.", exit code 0. systemd records =Result=success=. A backup that has not run for months is indistinguishable from a working one — and the whole point of the tool is to have a copy when the primary is gone.
-
-2. =determine_host= runs inside a command substitution (=TRUENAS_HOST=$(determine_host)=) and its =error()= wrote to stdout. On an unreachable TrueNAS the message was captured into =TRUENAS_HOST= and discarded, and =set -e= then killed the script. Driven with both hosts unreachable: exit 1 and completely empty output. A nightly service failing with nothing in the journal to say why.
-
-Same class as three bugs already fixed this session — =_restore_dot= claiming "DNS-over-TLS restored" without checking, =portal_restore_watch= discarding its outcome, =import_config= returning ok on an unchecked modify. A mutating operation that reports a success it did not get.
-
-Grading: Critical severity (a backup system that reports success while backing nothing up; the failure surfaces only when the backup is needed — graded on the harm once in the failure state, not on how rarely it is entered) × rare edge case (needs a ZFS root, a reachable TrueNAS, and the user enabling the timer by hand — archsetup deliberately does not enable it, and =findmnt -n -o FSTYPE /= on this machine says btrfs, so it is latent here) = P2 = [#B].
-
-Left alone: =BACKUP_PATH="backups" # TODO: Configure actual path= is still an unresolved TODO in the destination, and single-dataset mode relies on =set -e= to propagate a syncoid failure rather than reporting it. Neither is a defect in the sense above; the TODO is Craig's call.
-** DONE [#D] Wireless regdom is silently unset for a three-letter-language locale :bug:installer:solo:
-CLOSED: [2026-07-24 Fri]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-24
-:END:
-Fixed as archsetup =249bb93=. =locale_country= matches the =_CC= group instead of counting characters, and =set_wireless_regdom= verifies the substitution landed rather than trusting sed's exit code. 16 tests.
-=configure_networking= derives the wireless regulatory domain by fixed offset: =wireless_region="${current_lang:3:2}"=, with a comment reading "extract country code (positions 3-4)". That is correct only for a two-letter language code.
-
-=validate_config= accepts =^[a-z]{2,3}(_[A-Z]{2})?...=, so a three-letter language is a legal =LOCALE=, and glibc ships 75 of them (=agr_PE=, =ast_ES=, =ber_DZ=, =ayc_PE=, ...). Verified by running the expansion: =ber_DZ.UTF-8= yields =_D=, =ayc_PE.UTF-8= yields =_P=, =C= yields the empty string, =POSIX= yields =IX=.
-
-The sed that follows only uncomments an existing =#WIRELESS_REGDOM="XX"= line in =/etc/conf.d/wireless-regdom= (176 of them, owned by wireless-regdb). A garbage region matches nothing, sed exits 0, and the =|| error_warn= never fires — so the regdom is never set and nothing says so. The task line does print the garbage region ("configuring wireless regulatory domain (_D)"), so it is visible in the log rather than fully silent.
-
-Confirmed the mechanism itself works for the normal case: line 168 of this machine's =/etc/conf.d/wireless-regdom= reads =WIRELESS_REGDOM="US"= uncommented, which is archsetup's own edit.
-
-Grading: Minor severity (WiFi falls back to the conservative "00" regdomain — fewer channels and lower tx power, but WiFi works) × rare edge case (one of 75 three-letter-language locales, or a =LOCALE= with no country) = P4 = [#D].
-
-Fix when it comes up: derive the country from the =_CC= group by pattern rather than by offset, and warn when it cannot be derived or when the sed changed nothing. Worth doing together with the sibling gap — nothing in the installer verifies that a =sed -i= uncomment actually matched, so a distro reshuffling one of these config files would fail the same silent way. All 22 =sed -i= sites share that stance, so it is a uniform design choice rather than an odd one out.
-** DONE [#B] Initramfs hook swap can leave a LUKS machine unbootable :bug:installer:solo:
-CLOSED: [2026-07-24 Fri]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-24
-:END:
-Fixed as archsetup =HEAD= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). The swap moved into =switch_udev_hook_to_systemd=, which declines when =hooks_need_busybox_init= sees a standalone =encrypt= token, and the caller now rebuilds the initramfs only when the conf actually changed. New =tests/installer-steps/test_switch_udev_hook.py=, 10 tests; both guards proven real by breaking them (removing the refusal: 4 failures; loosening the token match to a bare =encrypt= substring: 1 failure).
-
-Found in the 2026-07-24 sentry bug-hunt, round 10. =configure_initramfs_hook= ran =sed -i '/^HOOKS=/ s/\budev\b/systemd/'= on any non-ZFS root, then =mkinitcpio -P=. Its only guard was =is_zfs_root=.
-
-Why that breaks a LUKS machine, verified against the installed mkinitcpio rather than argued:
-- =/usr/lib/initcpio/install/systemd= line 70 is =add_symlink /init usr/lib/systemd/systemd=, so the systemd hook replaces the busybox init outright.
-- =/usr/lib/initcpio/hooks/encrypt= is an =#!/usr/bin/ash= script whose entire body is a =run_hook()= function — the busybox init's mechanism. Under systemd init nothing calls it.
-- =mkinitcpio= carries no conflict check for the pairing (grepped; nothing), so the rebuild succeeds and archsetup reports success.
-- This machine's own =/etc/mkinitcpio.conf= documents the two valid pairings as separate examples: =udev= + =encrypt= (line 45) and =systemd= + =sd-encrypt= (line 51). The sed converted half of the first pairing and produced neither.
-
-Effect: on a LUKS root using the standard busybox =encrypt= hook, archsetup rewrites HOOKS to =systemd= while leaving =encrypt= behind, rebuilds the initramfs, and exits cleanly. At the next boot the root is never unlocked. The machine needs live media and manual mkinitcpio surgery to recover.
-
-The sibling asymmetry: =is_encrypted_root()= already exists in this script and =configure_autologin= uses it to branch on exactly this condition. The initramfs step consulted neither it nor HOOKS. =merge_grub_cmdline='s own comment names =cryptdevice== as a boot-critical parameter to preserve — and =cryptdevice== is read only by the =encrypt= hook, so archsetup explicitly anticipates the configuration that another of its steps then breaks.
-
-Grading: Critical severity (the machine will not boot and recovery needs external media — graded on the harm once in the failure state, not on how rarely it is entered) × some users, sometimes (LUKS-encrypted non-ZFS root using the busybox =encrypt= hook; deterministic for those machines, absent everywhere else) = P2 = [#B].
-
-Deliberately not attempted: migrating =encrypt= to =sd-encrypt=. That means rewriting the kernel cmdline from =cryptdevice== to =rd.luks.name== against the volume's UUID, which is a real migration and not a mechanical edit. Refusing the cosmetic swap keeps a working machine working, which is the right trade against quieter fsck output.
-** DONE [#D] keymap and consolefont hooks are inert under the systemd initramfs :bug:installer:solo:
-CLOSED: [2026-07-24 Fri]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-24
-:END:
-Fixed as archsetup =249bb93=. The swap rewrites both to =sd-vconsole=, collapsing them into one entry and never duplicating an existing one. The open question is answered: this machine is KEYMAP=us with no encrypt hook, but the function runs on LUKS machines where a non-US layout at the passphrase prompt is exactly what sd-vconsole restores. 7 tests.
-Same class as the =encrypt= bug above, but cosmetic rather than boot-critical, so it was filed rather than bundled into that fix.
-
-Enumerating the busybox-only hooks on this machine (every hook under =/usr/lib/initcpio/hooks/= defining =run_hook=/=run_earlyhook=/=run_latehook=) gives: btrfs, consolefont, encrypt, grub-btrfs-overlayfs, keymap, memdisk, resume, sleep, udev, usr. All go inert once =/init= is systemd. Of those, =encrypt= is the only boot-critical one — =resume= is handled natively by systemd's hibernate-resume generator, and =btrfs= by udev rules (this machine runs =btrfs= alongside =systemd= and boots fine).
-
-=keymap= and =consolefont= are the live leftovers. Run =grep '^HOOKS=' /etc/mkinitcpio.conf= on this machine: the line carries =systemd= plus =keymap consolefont= and no =udev=, so archsetup's swap has already run here and both hooks are installed into the image and never executed. The systemd equivalent is the single =sd-vconsole= hook, which is what the distro's own systemd example on line 51 of =/etc/mkinitcpio.conf= uses.
-
-Effect: the early-boot console keeps the default font and keymap until =systemd-vconsole-setup= runs in the real root. =add_nvme_early_module= sets =FONT=ter-132n= in =/etc/vconsole.conf= expecting it to apply at that stage, so the configured font is briefly not what archsetup asked for.
-
-Grading: Cosmetic severity (a few seconds of default console font on a machine that boots normally) × some users, sometimes = P4 = [#D].
-
-Fix when it comes up: have =switch_udev_hook_to_systemd= also rewrite =keymap consolefont= to =sd-vconsole= when it performs the swap, and add the fixture cases to =tests/installer-steps/test_switch_udev_hook.py=. Worth confirming first whether a non-US keymap is ever needed at the initramfs prompt on a machine that reaches this path.
-** DONE [#B] NVIDIA Wayland preflight blocks dwm and headless installs :bug:installer:solo:
-CLOSED: [2026-07-24 Fri]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-24
-:END:
-Fixed as archsetup =HEAD= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). The NVIDIA block moved out of =preflight_checks= into a new =nvidia_preflight= function that returns early unless =desktop_env= is =hyprland= and archsetup is the one installing drivers. New =tests/nvidia-preflight/test_nvidia_preflight_gate.py=, 11 tests; each of the three guards was proven a real gate by deleting it and watching the suite go red (3, 1, and 1 failures respectively).
-
-Found in the 2026-07-24 sentry bug-hunt, round 10, reading archsetup's own installer. =preflight_checks= called =nvidia_preflight_report= unconditionally and exited 1 on rc 11 (repo driver below the 535 Wayland floor, or =pacman -Si nvidia-utils= unable to answer). The check is Wayland-specific — every line it prints names Wayland/Hyprland — but it ran before any =desktop_env= branch and consulted neither =desktop_env= nor =skip_gpu_drivers=.
-
-Effect, proven empirically rather than argued (three scenarios driven against the extracted block): =DESKTOP_ENV=dwm= plus =--no-gpu-drivers= on an NVIDIA machine with an old repo driver aborts the install; so does =DESKTOP_ENV=none=. Neither install ever runs a compositor, and =--no-gpu-drivers= means the user installs the driver themselves. Worse, the abort's own fix hint reads "install with DESKTOP_ENV=dwm (X11) instead" — the one remedy it prints is the one it refuses to honor, so the user has no working workaround short of editing the script.
-
-The sibling asymmetry that makes it an oversight rather than a decision: =install_gpu_drivers= returns early on =skip_gpu_drivers=, and =display_server= / =window_manager= both branch on =desktop_env= with a =none= arm that skips outright. The preflight gate applied neither ruling.
-
-Second defect at the same site, fixed in the same commit: the rc-10 path (card detected, driver fine) prompts with a bare =read=. =--config-file= is documented as "unattended installation", and =aur_install= already rules that a prompt not covered by =--noconfirm= "blocks forever waiting for input" on a headless install. The rc-10 prompt is advisory, so it now answers itself with its own =[Y/n]= default when a config file was supplied. rc 11 stays a hard stop either way.
-
-Grading: Critical severity (archsetup cannot be run at all on that machine, and the printed workaround does not work — graded on the harm once in the failure state, not on how rarely it is entered) × rare edge case (needs an NVIDIA card, a repo driver below the floor or an unsynced pacman db, and a non-hyprland =desktop_env=; hyprland is the default and Craig's own machines are AMD and Intel) = P2 = [#B].
-
-Noted, not fixed: =display_server= and =window_manager= both point their unknown-value hint at a =--desktop-env= flag that the argument parser does not implement. Both arms are unreachable today (=validate_config= rejects a bad =DESKTOP_ENV=, and without a config file the value is always the default), so it is a stale string rather than a live defect.
-** DONE [#B] mkplaylist retags only the first file :bug:music:quick:solo:
-CLOSED: [2026-07-24 Fri]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-24
-:END:
-Fixed as dotfiles =a57c443= (committed locally, deliberately NOT pushed — held for Craig's morning review of the sentry run). =ffmpeg -nostdin= on the conversion call. New =tests/mkplaylist= suite, 12 tests; removing the flag turns the suite red (verified by reverting: 5 failures, green on restore). NOTE: the fake ffmpeg does a full =cat > /dev/null= drain, which the real one does not do — so the suite gates the flag's presence, not the production failure mode. The docstring claiming the fake "drains stdin exactly when the real one would" is false and should be corrected.
-Found in the 2026-07-24 sentry bug-hunt (shellcheck SC2095). =common/.local/bin/mkplaylist=: =generate_music_m3u= pipes the file list into =tag_music_file= (line 130), which consumes it with =while IFS= read -r file=. Inside that loop, =ffmpeg -i "$file" -vn -c:a flac "$outputfile"= (line 46) reads stdin by default for its interactive keyboard controls, so it consumes bytes the loop is relying on.
-
-CORRECTION (2026-07-24, from an adversarial re-review): the failure mode stated above — "the loop sees EOF and exits after the first file" — is WRONG, and this task originally asserted it. Measured under strace, ffmpeg polls fd 0 and reads roughly one byte per half-second of transcode wall time; flac encoding runs about 2000x realtime, so a ten-minute mp3 converts in ~0.28s and yields zero or one stolen byte, never a drain. Driven end to end with real ffmpeg against four 120s mp3s, three runs of three: all four were converted and retagged every time. The loop never truncated.
-
-What is real is the hazard, not the observed symptom: one stolen byte mangles a path, which makes mid3v2/metaflac fail and =set -e= abort the run loudly. =-nostdin= is still the right fix and the commit still stands. The original finding came from shellcheck SC2095 plus reasoning, and was never run — which is exactly what "verify before filing" exists to prevent.
-
-Effect: on a directory of non-flac audio, only the first file is converted and retagged. Files 2..N are silently skipped — no error, no output, and the playlist itself still generates (a separate =find=), so nothing signals that the retagging stopped.
-
-Grading: Major severity (the retagging feature is broken past the first file, and it fails silently) × most users frequently (the script exists to batch-process a directory, so more than one non-flac file is the normal case) = P2 = [#B].
-
-Fix: =ffmpeg -nostdin= (or =< /dev/null= on the call). Verifiable with a fake =ffmpeg= on PATH asserting it is invoked once per input file.
-** DONE [#C] timezone-change prints command-not-found instead of its help :bug:tooling:quick:solo:
-CLOSED: [2026-07-24 Fri]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-24
-:END:
-Fixed as dotfiles =15d2b63= (committed locally, deliberately NOT pushed — held for Craig's morning review), together with the Portugal-zone defect below. New =tests/timezone-change= suite, 12 tests.
-Found in the 2026-07-24 sentry bug-hunt (shellcheck SC2288). =common/.local/bin/timezone-change=, default =*)= case (lines 63-67): =echo= sits alone on its own line, so the following quoted string runs as a *command* rather than as its argument.
-
-#+begin_src sh
-*)
- echo
- "Invalid option chosen."
- echo
- "Some valid options are: eastern, central, pacific, rome, london, st_lucia, italy, france, spain ."
- ;;
-#+end_src
-
-The user gets two blank lines and two =command not found= errors; the list of valid options never prints. The timezone is correctly left unchanged, so this is an output defect only.
-
-Grading: Minor severity (wrong output on an error path, nothing corrupted) × some users sometimes (only on an unrecognized option) = P3 = [#C].
-
-Fix: fold each string into its =echo=. Verifiable by running the script with a bogus argument and asserting the option list appears on stdout.
-** DONE [#C] Thumbnail sweep wipes the whole cache when a wallpaper source is unreadable :bug:settings:solo:
-CLOSED: [2026-07-24 Fri]
+** TODO [#B] Proton static WireGuard profiles pass no traffic :chore:network:
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-24
+:LAST_REVIEWED: 2026-09-09
:END:
-Fixed as dotfiles =0bd8c67= (committed locally, deliberately NOT pushed — held for Craig's morning review). 8 new tests.
-Found in the 2026-07-24 sentry bug-hunt, reviewing the orphan sweep shipped the night before (dotfiles =e752a16=). =os.walk= stays silent about a directory it cannot enter, so =wallpaper.scan_sources= returns =[]= for a source that is missing, renamed, or permission-denied — the same answer it gives for a gallery the user emptied on purpose. =settings/cli.py= tick then hands that empty list to =thumbstore.sweep_orphans=, =live_names= comes back empty, and every cache-shaped file is classified an orphan.
+wg-US-CA-144, wg-US-TX-714 and wg-NL-781 (all on wgpvpn) complete a WireGuard handshake and answer ICMP at 10.2.0.1, then forward nothing: no DNS on any transport, no HTTPS payload, no IPv6. The same account over the Proton CLI works, so the static configs are what Proton stopped honoring (the shape of an expired certificate on the profile). Diagnosed 2026-09-01.
-Proven empirically rather than reasoned: seeding three well-formed thumbnails plus a stray README, then sweeping against a nonexistent source directory, deleted all three (the README survived, so the cache-name regex guard works — it just doesn't help here).
+The net doctor now names these as a dead tunnel and brings them down (dotfiles f56fd1a), which gets the machine back online but doesn't restore the tunnels. Two ways out: re-download the WireGuard configs from the Proton dashboard and re-import them (nmcli connection import type wireguard file ...), or drop the static profiles and use the Proton CLI only. Needs the Proton account, so not solo.
-Effect once entered: the entire persistent thumbnail cache is deleted, so the next wallpaper-view open pays the cold-decode cost the cache was built to remove (measured at 3.7s for a viewport of Craig's largest 8, which is what tripped the compositor's kill prompt), and the tick needs roughly ten idle beats — about twenty minutes — to rewarm at =WARM_PER_BEAT= 8.
+*** 2026-09-12 Sat @ 23:26:50 -0500 The NM tunnel DoT drop-in already covers wgpvpn
+=/etc/NetworkManager/conf.d/tunnel-dns-over-tls.conf= (live on both daily
+drivers since 2026-09-10/12, and now written by the installer) matches
+=interface-name:wgpvpn= as well as =proton0=, so a re-imported static profile
+gets per-link DNS over TLS turned off automatically. Their dead-forwarding
+problem is separate and unchanged.
-Grading: Major severity (grading the being-in-it, per the don't-double-count-rarity rule: the cache is gone, the original freeze returns, and recovery is unattended and slow) × rare edge case (both configured sources — =~/videos/wallpaper= and =~/pictures/wallpaper= — are local directories, so this needs one deleted, renamed, or made unreadable while a beat fires; a removable or network source would hit it routinely) = P3 = [#C].
-
-Fixed in this session: new =wallpaper.sources_available(sources)= tells "readable and empty" apart from "could not read", and =sweep_orphans= grew a =sources_ok= parameter that declines to sweep when it is False. Deferring a sweep costs only some stale files; sweeping wrongly costs the whole cache.
-** DONE [#C] timezone-change sets a nonexistent zone for Portugal :bug:tooling:quick:solo:
-CLOSED: [2026-07-24 Fri]
+** TODO [#C] Declined dot-link-restore branch untested :test:network:dotfiles:solo:quick:
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-24
+:LAST_REVIEWED: 2026-09-09
:END:
-Fixed as dotfiles =15d2b63= (committed locally, deliberately NOT pushed — held for Craig's morning review). =Europe/Lisbon=. The suite also pins the general invariant: every zone the script can emit must exist in tzdata, so a future bad entry fails at test time rather than in Craig's hands.
-Found in the 2026-07-24 sentry bug-hunt, validating every zone the script sets against =/usr/share/zoneinfo=. =common/.local/bin/timezone-change= line 39 maps =portugal= / =lisbon= to =Europe/Portugal=, which is not a tzdata identifier — the real one is =Europe/Lisbon= (a bare =Portugal= legacy alias also exists at the top level, but not under =Europe/=). =timedatectl set-timezone "Europe/Portugal"= fails, so the timezone is never changed.
-
-The other 17 zones the script sets all resolve correctly, so this is the single bad entry.
-
-Grading: Major severity (the option is wholly broken — the zone is not set and the command errors) × rare edge case (one option of eighteen, hit only when actually switching to Portugal) = P3 = [#C].
-
-Fix: =Europe/Lisbon=. Verifiable by asserting the argument handed to a fake =timedatectl=, plus a suite-wide check that every zone the script names exists in the tzdata database.
-** DONE [#C] settings-project stop() can SIGTERM an unrelated process :bug:settings:solo:
-CLOSED: [2026-07-24 Fri]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-24
-:END:
-Found in the 2026-07-24 sentry bug-hunt, round 2, reviewing =settings/src/settings/project.py=. =stop()= read a pid out of =$XDG_RUNTIME_DIR/settings-project.pid= and SIGTERMed it with no check that the pid still belonged to the projection. A projection that dies without running =stop()= (crash, OOM, a failed =execvpe= on the clock path — that last one was already noted as tolerated residue) leaves the file behind, so once the kernel wraps its pid counter that pid can name something else entirely, and the next =start= or =stop= kills it.
-
-This is a hazard the codebase had already ruled on elsewhere and simply hadn't applied here: =maint/src/maint/doctor.py= revalidates =/proc/<pid>/comm= against the expected name before its KILL remedy fires, explicitly to refuse recycled pids.
-
-Grading: Major severity (grading the being-in-it — an arbitrary user process takes a SIGTERM, and an editor with unsaved work is a plausible victim) × rare edge case (needs an unclean exit *and* pid reuse; =pid_max= here is 4194304, so wrap-around takes a very long time) = P3 = [#C].
-
-Fixed as dotfiles =722994e= (committed locally, deliberately NOT pushed — held for Craig's morning review). The pidfile now records the process start time from =/proc/<pid>/stat= next to the pid, and =stop()= fires only when the recorded value still matches the live process. Start time is the right token rather than =comm=: it is mode-independent (the clock channel execs into =python3=, so comm changes while comm-matching would have needed per-mode knowledge) and it is exec-stable, verified directly — pid and start time were identical either side of an =execvpe=. A recycled pid cannot reproduce it. Legacy bare-pid pidfiles keep the old unconditional behavior so the upgrade never strands a live projection.
-** DONE [#C] wtimer alarms fire an hour off on the eve of a DST change :bug:timer:solo:
-CLOSED: [2026-07-24 Fri]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-24
-:END:
-Found in the 2026-07-24 sentry bug-hunt, round 3, reading =timer/src/timer/engine.py=. =parse_alarm= resolves a bare wall-clock time ("07:00") to its next occurrence: it builds today's instant, and when that is already past it rolled forward with =epoch += 86400=. A DST day is 23 or 25 hours long, so a fixed 86400 lands on the wrong wall time whenever tomorrow crosses a transition.
-
-Reproduced against America/Chicago and the two 2026 US transitions. Asking for =07:00= at 08:00 on Sat 2026-03-07 (spring forward that Sunday) gave 08:00 Sunday — an hour late. Asking for =07:00= at 08:00 on Sat 2026-10-31 (fall back that Sunday) gave 06:00 Sunday — an hour early.
-
-The recurring path was never affected, which is what makes this an oversight rather than a design choice: =next_alarm= walks candidate days and rebuilds =datetime(y, m, d, hh, mm)= per day, so it is already DST-correct. Only the one-shot rollover took the shortcut. Both were pinned by the new tests.
+repair_tunnel_dot_off (dotfiles net/src/net/repair.py) puts a tunnel link back to its DoT mode when turning DoT off didn't bring names back, and the evidence says "put back to <mode>" only when that restore succeeded. The restore-declined branch has no direct test. Give fake-resolvectl a second failure switch (FAKE_RESOLVECTL_DOT_RESTORE_FAIL) so the wording can be asserted absent as well as present. Follow-up from the f56fd1a review.
-Grading: Major severity (grading the being-in-it — an alarm that fires an hour off has wholly failed at the one thing an alarm does, and the fall-back direction wakes you early while the spring-forward direction lets you oversleep) × rare edge case (two nights a year, and only when the requested wall time has already passed today) = P3 = [#C].
-
-Fixed as dotfiles =9b6c2c9= (committed locally, deliberately NOT pushed — held for Craig's morning review). The rollover now rebuilds the local time on tomorrow's calendar date, the same construction =next_alarm= uses. Eight tests pin =TZ=America/Chicago= (saved and restored around each case), covering both transitions, the twelve-hour form, an ordinary-day control, a DST eve where the requested time is still ahead, and two characterization cases asserting the recurring path stays DST-safe.
-** DONE [#B] net portal-restore claims encrypted DNS is back without checking :bug:net:solo:
-CLOSED: [2026-07-24 Fri]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-24
-:END:
-Found in the 2026-07-24 sentry bug-hunt, round 3, reading =net/src/net/repair.py=. A captive-portal login moves the DNS-over-TLS drop-in aside so plain DNS can reach the venue's login page, and =_restore_dot()= moves it back afterwards. It fired both privileged steps — the =mv= and the =systemctl restart systemd-resolved= — and returned ="restored"= without reading either result. =repair_portal_restore()= then rendered a pass step reading "DNS-over-TLS restored".
-
-So a declined or failed =sudo -n mv= left DNS-over-TLS off while the tool told the user it was back on. The same for a resolved restart that fails: the drop-in is on disk but the running resolver is still serving plain DNS.
-
-The asymmetry is what makes it an oversight rather than a decision. The sibling =_disable_dot()=, twenty lines up, checks its own move with =_ok()= and returns False rather than claiming a success it did not get. The restore half simply never got the same treatment, and it is the half where the failure is silent — the disable path's failure is visible immediately because the portal page won't load.
-
-Grading: graded on severity alone under the privacy carve-out. DNS queries continue in cleartext to the venue resolver on an untrusted network, and the affirmative "restored" message is what removes the user's reason to check. Bounded by =net diagnose='s =encrypted-dns= step, which exists precisely to catch a portal run that never restored, so the exposure ends at the next diagnose rather than persisting unseen forever. Major severity = P2 = [#B].
-
-Fixed as dotfiles =018c0c5= (committed locally, deliberately NOT pushed — held for Craig's morning review). Both privileged steps are now checked, with two new outcomes: ="failed"= when the move back fails (encrypted DNS still off, rendered as a fail step) and ="unapplied"= when the drop-in is back but resolved would not restart (rendered as a warn step). Each names the command to run by hand. Four tests cover both failures at the =_restore_dot()= and step levels, mirroring the existing declined-move test on the disable side.
-** DONE [#B] the portal restore watcher fails silently, so DNS stays in the clear :bug:net:solo:
-CLOSED: [2026-07-24 Fri]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-24
-:END:
-Found in the 2026-07-24 sentry bug-hunt, round 4, reading the rest of =net/src/net/repair.py= after the round-3 fix above. =portal_restore_watch()= polls until the link comes back online, calls =_restore_dot()=, and discards the outcome entirely.
-
-Three things compound into a silent failure. The watcher is spawned detached with =stdin=, =stdout=, and =stderr= all on =/dev/null=, so nothing it could print reaches anyone. It runs outside the =repair()= dispatch, so unlike every other mutating tier it never wrote an event-log line either. And =repair_portal_login= tells the user "encrypted DNS restores itself once you're online", which is precisely what removes their reason to check. A ="failed"=, ="unapplied"=, or ="ambiguous"= restore therefore left the machine on plain DNS on a venue network with no signal at any level.
-
-This is the round-3 finding one layer out, and the asymmetry is the tell: =018c0c5= taught =repair_portal_restore()= — the *manual fallback* — to stop claiming a success it did not get, while the *automatic* path, the one that actually runs in the normal flow, kept dropping the same result on the floor. Fixing the fallback and leaving the primary silent is a worse split than the original bug.
-
-Grading: graded on severity alone under the privacy carve-out, exactly as the round-3 sibling. Same exposure (cleartext DNS to an untrusted venue resolver), same bound (=net diagnose='s =encrypted-dns= step catches the stranded state), and the same affirmative promise removing the reason to look. Major severity = P2 = [#B].
-
-Fixed as dotfiles =601c5b4= (committed locally, deliberately NOT pushed — held for Craig's morning review). The watcher now returns the outcome, appends a =portal-restore-watch= event with it, and fires a persistent =notify security= alert on each of the three failing outcomes, each naming the command to run by hand. A clean restore stays silent. Five tests: one per failing outcome, one pinning the silence on a clean restore, and one on the event-log line. The whole =TestPortalLogin= class now shadows =notify= with a logging fake, so no future watcher test can fire a real desktop notification mid-suite.
-** DONE [#D] dns-override failure path says "reverted" without checking :bug:net:quick:solo:
-CLOSED: [2026-07-24 Fri]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-24
-:END:
-Fixed as dotfiles =2cf3fb3=. The revert is checked; a declined one now says 1.1.1.1 is still set and names =resolvectl revert <iface>=.
-Found in the 2026-07-24 sentry bug-hunt, round 3, sweeping for siblings of the portal-restore finding above. =net/src/net/repair.py=, =repair_dns_override()= failure path: when the 1.1.1.1 override doesn't restore resolution, it calls =priv.run("dns-revert", iface)=, discards the result, and returns evidence reading "override didn't restore resolution — reverted". A failed revert leaves 1.1.1.1 set on the link while the step says it was removed.
-
-Same defect class as the portal-restore bug, three hundred lines up in the same file, and it survived the sweep only because the consequence is much smaller. Every other mutating repair in this file verifies by re-measuring afterwards rather than by reading an exit code, which is the stronger pattern and is why the sweep otherwise came back dry.
-
-Grading: Minor severity (a stale per-link override sends DNS to Cloudflare instead of the venue resolver, it dies on the next reconnect, and =net diagnose='s =dns-override-present= step exists specifically to catch it) × rare edge case (needs the override to fail *and* the revert to fail) = P4 = [#D].
-
-Fix: the same idiom the portal-restore fix now uses. Wrap the revert in =_ok()= and drop the "— reverted" claim (or say the revert failed and name =resolvectl revert <iface>=) when it returns False. The existing =RepairHarness= makes the privileged call fail with =NET_SUDO="false"=, so the test is a near-copy of =test_restore_reports_failure_when_the_move_back_is_declined=.
-** DONE [#B] a timezone-less Date header crashes the whole net diagnose run :bug:net:solo:
-CLOSED: [2026-07-24 Fri]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-24
-:END:
-Found in the 2026-07-24 sentry bug-hunt, round 4, reading =net/src/net/diag.py=. =_clock_skew_s()= fetches the probe server's =Date= header with =curl -sI=, parses it with =parsedate_to_datetime=, and subtracts it from a timezone-aware =datetime.now(timezone.utc)=. RFC 5322 allows a =Date= to carry =-0000=, which means UTC while explicitly claiming no local zone, and a =Date= with no zone at all parses leniently as well. Both come back *naive*, and subtracting a naive datetime from an aware one raises =TypeError=.
-
-The =try= wraps only the =parsedate_to_datetime= call, so the =TypeError= from the line below it is uncaught. It escapes =_clock_skew_s=, escapes =_steps_egress_edges=, and takes down the entire =diagnose()= run — no report, no steps, a Python traceback. =net doctor= runs diagnose first, so the panel's doctor button dies with it.
-
-Verified against Python 3.14.6 before writing the fix: =parsedate_to_datetime("Thu, 01 Jan 2020 00:00:00 -0000")= returns =tzinfo=None=, and the subtraction raises. The zoneless form behaves the same. Only the =GMT= form (which the well-behaved probe host sends) comes back aware, which is why this never showed up in normal use.
-
-What makes it more than a curiosity is *when* the code runs. =_steps_egress_edges= fires only after the http-probe has already failed, so the server answering that =HEAD= is frequently a captive portal's interception appliance rather than the real probe host — and a minimal embedded HTTP stack is exactly the kind that emits a non-GMT =Date=. The one path guaranteed to be talking to a non-standard server is the one that can't survive a non-standard header.
-
-Grading: Major severity (grading the being-in-it — the diagnostic tool produces no report at all, and =net doctor= goes with it, on precisely the broken network it exists to diagnose) × rare edge case (needs a failing probe *and* a portal appliance that omits a numeric offset) = P2 = [#B].
-
-Fixed as dotfiles =8933500= (committed locally, deliberately NOT pushed — held for Craig's morning review). A naive parse is now read as UTC, which is what =-0000= means. Two tests, and the second is the one that matters: it drives a *current* =-0000= timestamp and asserts no clock row, so a lazy "catch =TypeError= and return None" fix would fail it while the correct reading passes.
-** DONE [#C] a tunnel import that can't be disarmed still reports success :bug:net:solo:
-CLOSED: [2026-07-24 Fri]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-24
-:END:
-Found in the 2026-07-24 sentry bug-hunt, round 4, reading =net/src/net/manage.py=. =import_config()= imports a WireGuard or OpenVPN config as an NM profile, then fires =nmcli connection modify <uuid> connection.id <name> connection.autoconnect no= — and discarded the result, returning =ok=True= regardless.
-
-That modify is the whole safety of the feature, and the module's own docstring says so: =nmcli connection import= *auto-activates* the profile it creates, "which nobody asked for by picking a file", so "every import here ends with the profile deactivated and autoconnect off". A failed modify inverts that. For WireGuard — a device-type connection — autoconnect stays on, so the tunnel re-arms itself at the next boot and takes the default route with it, and the profile keeps the transient staged interface name (=wgpvpn=) while the envelope reports the config's real name, so the panel names a profile that isn't there.
-
-CORRECTION (2026-07-24, from an adversarial re-review): the blanket claim originally written here — that a failed disarm re-arms the tunnel at boot — is wrong for OpenVPN. =man 5 nm-settings-nmcli= states autoconnect is not implemented for VPN profiles, and an OpenVPN import is an NM VPN profile, so the modify is near-cosmetic on that half. The bug is real and security-relevant for WireGuard, which is the primary case; the severity as stated overreached to cover both.
-
-The asymmetry, again the tell: =_nmcli_import()=, twenty lines up in the same file, checks its own =returncode= and raises rather than return a UUID it did not get. The modify below it never got the same treatment.
-
-Grading: Major severity (grading the being-in-it — a full-tunnel VPN the user never asked to connect arms on every boot and carries all their egress, it persists across reboots rather than self-healing, and the affirmative "imported X" is what removes the reason to check) × rare edge case (needs the modify to fail after the import succeeded) = P3 = [#C].
-
-Fixed as dotfiles =e0d4d8a= (committed locally, deliberately NOT pushed — held for Craig's morning review). New =_disarm()= returns whether the modify took. On failure the profile is still deactivated first — the import already brought it up, and the verdict shouldn't decide whether it keeps running — and then a =disarm-failed= envelope names the UUID and the exact command to finish the job. Three tests: the failing verdict, =import_configs= counting it as failed rather than imported, and a characterization test pinning that the deactivate still runs on the failure path.
-** DONE [#C] a binary that can't be exec'd crashes the panels instead of degrading :bug:net:bluetooth:audio:maint:solo:
-CLOSED: [2026-07-24 Fri]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-24
-:END:
-Found in the 2026-07-24 sentry bug-hunt, round 5, comparing the four panel packages' subprocess wrappers against each other.
-
-Every wrapper in the panels states the same contract: an unusable tool becomes a degraded result, never an exception. =cmd.run= returns None; =nmcli.run=, =btctl.run= and =pactl.run= raise their own domain error, which every caller already guards on; =speedtest.run_speedtest= returns an error envelope. All of them caught only =FileNotFoundError=, so they kept the contract for a tool that is *absent* and broke it for a tool that is *present but unusable*.
-
-Verified against Python 3.14.6 rather than argued. =subprocess.run= raises =PermissionError= for a file without its execute bit, =OSError= (ENOEXEC, "Exec format error") for an executable file that is neither a binary nor a script with a shebang, =NotADirectoryError= when a path component is a plain file, and =OSError= when a fork is refused under memory or PID pressure. None of the four is =FileNotFoundError=, so each escapes the guard: waybar's net/bt/audio modules die rather than dimming, and a maint probe takes the whole envelope with it — in exactly the machine state maint exists to report on.
-
-The asymmetry, and this codebase had already ruled on it three separate times: =net/iw.py='s =signal_dbm= and =settings/spawn.py='s =detached= both catch =(OSError, subprocess.TimeoutExpired)=, and =audio/cmd.py='s doctor-tier =probe()= enumerates =FileNotFoundError=, =NotADirectoryError= and =PermissionError= as "absent" under a docstring promising it never raises. Its sibling =run()=, twenty lines up in the same file, kept the narrow catch — as did all five copies of =run()= and all three tool wrappers. =audio/status.py='s docstring records that this same class already bit once ("the bar's audio module died rather than dimming"); that fix widened the guard's *scope* and left its *exception set* alone.
-
-Grading: Major severity (grading the being-in-it — the status surface is dead while the condition holds, and for maint the tool that reports the fault is the one that dies of it; no data loss, and it clears when the tool or the pressure does) × rare edge case (needs a binary with wrong permissions, a lost shebang, or a fork refused under pressure) = P3 = [#C].
-
-Fixed as dotfiles =44fdae1= (committed locally, deliberately NOT pushed — held for Craig's morning review). Widened to =OSError= across net, bt, audio, maint and panelkit — five =cmd.run= helpers, the three tool wrappers, =probe._curl= and =speedtest.run_speedtest=. The domain-error wrappers keep their "<tool> not found" message for a genuinely absent binary and add a second arm naming the errno for an unusable one, so the report can still tell the two apart. 28 tests, one class per package, driving all three exec failures against real files on a temp PATH; each was watched failing against unmodified production code first (27 red). Audio's class carries a characterization case pinning =cmd.probe='s existing behavior, so the sibling that got this right can't regress into the one that didn't.
-** DONE [#C] a failed pty-backed spawn strands both ends of the pty :bug:net:bluetooth:solo:
-CLOSED: [2026-07-24 Fri]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-24
-:END:
-Found in the 2026-07-24 sentry bug-hunt, round 6, auditing the =subprocess.Popen= sites the round-5 fix didn't reach.
-
-Two spawns open a pty before launching and catch only =FileNotFoundError= around the =Popen=: =bt/pairing.py='s =pair_interactive= (bluetoothctl under a pty so the passkey agent is interactive) and =net/speedtest.py='s =run_speedtest_stream= (speedtest-go under a pty because it buffers everything to exit when piped). Both are the same exec-failure class as =44fdae1= — a binary present but not executable raises =PermissionError=, a lost shebang raises =OSError= — and neither is =FileNotFoundError=.
-
-What makes these worse than the =run= wrappers is where the cleanup lives. =os.close(master)= and =os.close(slave)= sit *inside* the =FileNotFoundError= arm, so an escaping =OSError= skips them: every failed attempt strands two descriptors. Both call sites are buttons in a long-lived panel process — the pairing flow and the console's SPEED key — and a user who gets no feedback presses again, so the leak accumulates under exactly the conditions that caused it.
-
-Grading: Major severity (grading the being-in-it — a descriptor leak in a process meant to run for days, on a path the user retries, plus the exception escaping a documented "(ok, detail)" / error-envelope contract) × rare edge case (needs an unusable bluetoothctl or speedtest-go) = P3 = [#C].
-
-Fixed as dotfiles =c2eb3e1= (committed locally, deliberately NOT pushed — held for Craig's morning review). An =OSError= arm on each closes both ends and returns the module's own failure shape, naming the errno. Four tests: two pin the return contract, two count =/proc/self/fd= across three attempts — the fd count is what actually fails against unmodified code, and it was watched failing before the fix.
-
-The wider sweep this came from is recorded so it isn't repeated: every =except FileNotFoundError= in production was enumerated. The other exec sites were already correct (=maint/gui.py= x3, =net/kick.py=, =timer/engine.py= x2, =timer/gui.py=, =net/repair.py= x2, =audio/peak.py= all catch =OSError=), and the remaining hits are file-open catches, not exec. =clock/__main__.py='s =toggle()= has no guard at all but spawns =sys.executable=, which is by definition runnable; not filed.
-** DONE [#C] one impatient client kills the clock panel's toggle listener for good :bug:clock:waybar:solo:
-CLOSED: [2026-07-24 Fri]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-24
-:END:
-Found in the 2026-07-24 sentry bug-hunt, round 6, sweeping every acquired resource (pty, socket, mkstemp, tempdir) for cleanup that isn't in a =finally=.
-
-=clock/src/clock/app.py='s =_listen()= guards =accept()= with =except OSError: return= and leaves the request body — =recv=, =runtime_log=, =sendall= — outside any guard. =send_toggle()= in =__main__.py= gives the panel 0.25s to acknowledge, then closes. An ack later than that hits a dead peer and raises =BrokenPipeError=, which escapes the =while= loop and ends the listener thread.
-
-Verified empirically, not argued: a client that connects, sends, and gives up after 250ms makes the server's =sendall= raise =BrokenPipeError= (errno 32) and the listener thread exits.
-
-What makes it Major rather than a nuisance is that it neither self-heals nor announces itself. The socket file stays bound, so every later =clock toggle= still *connects* — then stalls the full 250ms, gets no reply, and falls through to spawning =clock serve=. GTK's single-instance forwarding turns that into =do_activate= on the running service, and =do_activate= calls =show_clock()=, not =toggle()=. So from the first bad client onward, clicking the waybar time module opens the panel every time and never closes it; the only ways out are the right-click dismiss inside the panel or restarting the service. Nothing logs it.
-
-Grading: Major severity (grading the being-in-it — the toggle is one-way from then on, it persists for the life of the service, and there is no signal it happened) × rare edge case (needs a reply to miss the 250ms budget: a busy main loop mid-redraw, a slow runtime-log write, or an interrupted =clock toggle=) = P3 = [#C].
-
-Fixed as dotfiles =7c02614= (committed locally, deliberately NOT pushed — held for Craig's morning review). An =OSError= arm around the request body scopes a dead peer to its own request, mirroring the guard =accept()= already had. =GLib.idle_add= runs before the ack, so the user's click still takes effect — only the acknowledgement is lost. New =tests/clock/test_socket.py=, 3 tests driving the real =_listen= against a stand-in owner (it touches only =self._socket= and =self.toggle=, so no Gtk.Application is needed). The gate is the second toggle after an impatient first: it times out on unmodified code because no listener is left. The other two pin what the fix must preserve — the toggle fires even when the ack can't be delivered, and an unknown command is still answered without toggling.
-
-Left alone deliberately: =do_activate= calling =show_clock()= rather than =toggle()=. Changing it would alter what a cold =clock toggle= does on first launch, which is a design call for Craig rather than part of this defect. Worth raising if he ever wants the spawn path to toggle too.
-** DONE [#B] fuzzel breaks the pinentry protocol loop on every passphrase :bug:security:gpg:solo:
-CLOSED: [2026-07-24 Fri]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-24
-:END:
-Found in the 2026-07-24 sentry bug-hunt, round 7 — from the live journal rather than from reading. Grepping this boot for tracebacks turned up four instances of =pinentry-fuzzel: line 36: read: 0: read error: Resource temporarily unavailable=, and every one sits 4-7 seconds after a =GETPIN= (the time it takes to type a passphrase). The =BYE= handler's log line never appears once.
-
-=hyprland/.local/bin/pinentry-fuzzel= speaks the Assuan pinentry protocol on a pipe gpg-agent keeps open, reading one command per iteration of =while read cmd rest=. The =GETPIN= arm shells out to fuzzel, which *inherits that pipe as its stdin*. fuzzel runs an event loop over its own input, so it sets =O_NONBLOCK= on fd 0 — and =--dmenu= would read the pipe as menu items besides. The flag lands on the shared open file description and outlives fuzzel, so the shell's next =read= fails with =EAGAIN= and the loop ends mid-protocol.
-
-Grading: Minor severity (the passphrase is delivered *before* the break, so decrypts still succeed and nothing is corrupted — what's lost is everything after: =BYE= is never acknowledged, and gpg-agent's same-connection retry after a wrong passphrase, =SETERROR= then =GETPIN= again, can't be served; that retry is what the script's "reenter" label exists for, and it has never once been reachable) × every user, every time (four for four in the journal, and the test reproduces it deterministically) = P2 = [#B].
-
-Fixed as dotfiles =e727dcd= (committed locally, deliberately NOT pushed — held for Craig's morning review). =< /dev/null= on the fuzzel call, so the non-blocking flag lands somewhere harmless; =--lines 0= was already there, so no menu input was ever wanted. =ENABLE_LOGGING= became env-overridable as a test seam — the script logs through an absolute =/usr/bin/logger= that PATH can't shadow, so without it every test run would write ten lines into the real journal.
-
-New =tests/pinentry-fuzzel/=, 8 tests driving the real script over a live pipe the way gpg-agent does. The fake fuzzel sets =O_NONBLOCK= on whatever fd 0 it is handed, exactly as the real one does, which is what makes them a gate rather than a restatement of the fix. Four fail against unmodified code — one reproducing the journal's message verbatim — and one records the fd fuzzel was given, pinning the cause rather than the symptom.
-
-THE CALIBRATION NOTE, and it is about my own earlier sweep. This is the same shape as round 1's =a57c443= (ffmpeg draining the pipe a =while read= loop was consuming). Round 1 swept both repos for siblings of that bug and came back empty — because it searched for the *mechanism* (a child that drains stdin) rather than the *shape* (a child that inherits stdin at all inside a read loop). Two different mechanisms, one shape, and the narrower search missed a live daily-use instance. Scope a class sweep by shape, not by the mechanism of the first instance found.
-** DONE [#C] a truncated webcam record strands every camera off :bug:settings:privacy:solo:
-CLOSED: [2026-07-24 Fri]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-24
-:END:
-Found in the 2026-07-24 sentry bug-hunt, round 8, sweeping production for non-atomic file writes.
-
-=settings/src/settings/webcam.py='s =_record()= wrote =~/.local/state/settings/webcam.json= with a plain truncate-in-place =open(path, "w")=. That record is the only route back on, and the module docstring says so: deauthorizing a camera removes its video4linux nodes, so =usb_devices()= returns nothing afterward and =_recorded()= becomes the sole source of the paths to re-authorize. A write that truncated and then failed left an empty file; =_recorded()= caught the resulting =JSONDecodeError= and returned =[]=; =_known_devices()= then had nothing; and =set_power(True)= returned None without re-authorizing anything. Every camera stranded off, with no way back through the panel until a replug or a reboot.
-
-The asymmetry, seventh instance of this read: six other state writers in the tree already write through a temp file and a rename — =maint/cache=, =net/cache=, =audio/ptt=, =timer/engine=, =settings/store=, =maint/curation=. The one whose loss is most expensive was the one that didn't.
-
-Grading: Major severity (grading the being-in-it — the privacy switch becomes one-way, the panel offers no route back, and the user has to know to replug the camera or write sysfs by hand; bounded by the fact that a reboot re-enumerates USB and restores authorized=1) × rare edge case (needs a crash or ENOSPC inside a microsecond-wide write window) = P3 = [#C].
-
-Fixed as dotfiles =8b40b79= (committed locally, deliberately NOT pushed — held for Craig's morning review). =_record= now mirrors =store.save=: =mkstemp= in the target directory, write, =os.replace=, unlink the temp on any failure. Four tests; the gate is a =_record= whose =json.dump= raises, after which the previous record must still be readable — it isn't on the old code. The other three pin what the fix must preserve: no temp-file residue, the =_recorded()= round trip, and the end-to-end power-off/power-on with the class symlinks removed, which is the scenario the record exists for.
-
-HOW IT WAS FOUND, and it confirms round 7's lesson twice over. Round 4 ran an atomic-write sweep and reported "nine sites, six unique-per-writer, three sharing a fixed =.tmp=" — it enumerated the writers that *were* atomic and compared their temp-file naming, and never asked which state writers aren't atomic at all. Same narrowing that made round 1's stdin sweep miss the pinentry bug: the sweep was scoped to a property of the instances already found rather than to the shape of the hazard.
-** DONE [#D] a failed wallpaper apply reports "nothing to apply" :bug:settings:quick:solo:
-CLOSED: [2026-07-24 Fri]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-24
-:END:
-Fixed as dotfiles =2cf3fb3=. The decision moved to =gui.wallpaper_apply_toast=, a module-level pure helper, because the callback lives inside a GTK widget where no test can reach it. 5 tests.
-Found in the 2026-07-24 sentry bug-hunt, round 7, sweeping the settings panel's worker callbacks.
-
-=settings/gui.py='s =_async= passes an exception through as the *result* rather than as a separate error argument, so every =done= callback has to test =isinstance(res, Exception)=. Five do — =_mx_pin=, =_mx_letter=, =_after_matrix=, =_set_pointer=, the drum/dial/gallery/refresh callbacks. =_wp_apply= is the one that doesn't:
-
-#+begin_src python
-def _wp_apply(self, note="Wallpaper set"):
- self._async(lambda: panel.wallpaper_apply(self.state),
- lambda ok: self._toast(
- note if ok is True else "nothing to apply",
- good=ok is True))
-#+end_src
-
-=panel.wallpaper_apply= calls =store.save=, which can raise =OSError= (disk full, a permissions change on the config dir). The exception then arrives as =ok=, =ok is True= is False, and the toast reads "nothing to apply" — describing a no-op when the apply actually failed. The toast is at least marked =good=False= (red), so the user gets a negative signal; what's lost is the reason, which every sibling callback surfaces via =str(res)=.
-
-Grading: Minor severity (wrong text on an error path, correctly marked as a failure, nothing corrupted) × rare edge case (needs =store.save= or =wallpaper.apply= to raise rather than return False) = P4 = [#D].
-
-Fix: give it the same =isinstance(res, Exception)= arm its five siblings have — toast =str(res)= on an exception, keep the current two-way message otherwise. One callback, three lines.
-** DONE [#D] two manage.py nmcli reads sit outside their own error conversion :bug:net:quick:solo:
-CLOSED: [2026-07-24 Fri]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-24
-:END:
-Fixed as dotfiles =2cf3fb3=. =_key_mgmt= converts both nmcli exceptions to "", which both call sites already treat as neither wpa-eap nor sae. 2 tests, including one driving =_classify_up_failure= end to end.
-Found in the 2026-07-24 sentry bug-hunt, round 4, reading =net/src/net/manage.py=. =nmcli.run()= raises =NmcliTimeout= on timeout and =NmcliError= on a missing binary, and every mutation in this module is written to convert both into a result envelope. Two calls escape that conversion because they run through =_key_mgmt()=, which wraps =nmcli.get_value= and catches nothing:
-
-- =edit()= line 243 calls =_key_mgmt(uuid)= for the enterprise-profile refusal *before* its own =try=, while the next four lines catch exactly those two exceptions around =nmcli.run=.
-- =_classify_up_failure()= calls it on =up()='s failure path, so a slow =connection show= turns a classifiable activation failure into an exception.
-
-Consequence is a leaked exception where the caller expected an envelope. The panel absorbs it — =gui.bg()= catches =Exception= and renders =str(e)= — so there it degrades to a worse message rather than a crash. =net edit= from the CLI has no such catch and prints a traceback.
-
-Grading: Minor severity (the operation fails either way; what's lost is the classified message, and only the CLI path shows a traceback) × rare edge case (=connection show= has a 2s timeout and nmcli's presence is already established by the time either site runs) = P4 = [#D].
-
-Fix: give =_key_mgmt= the same conversion its callers use — catch =(nmcli.NmcliError, nmcli.NmcliTimeout)= and return "", which both call sites already handle correctly (neither "wpa-eap" nor "sae"). One =try= in one helper covers both sites.
-** DONE [#D] three atomic writers share one fixed .tmp name :bug:quick:solo:
-CLOSED: [2026-07-24 Fri]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-24
-:END:
-Fixed as dotfiles =2cf3fb3=. All three carry =.tmp.$(getpid)=, matching the six writers that already did. 6 tests across audio and maint.
-Found in the 2026-07-24 sentry bug-hunt, round 4, sweeping both repos for the temp-file half of the atomic-write idiom. The tree writes state atomically in nine places, and six of them make the temp path unique per writer: =net/cache.py= and =timer/engine.py= both use =f"{path}.tmp.{os.getpid()}"=, and =settings/store.py=, =settings/idle.py=, =bt/repair.py=, =net/probe.py= all use =tempfile.mkstemp=/=NamedTemporaryFile=. Three use a bare =path + ".tmp"=:
-
-- =audio/src/audio/ptt.py= =write_state= (the lead carried over from round 3's Next Steps)
-- =maint/src/maint/cache.py= =put=
-- =maint/src/maint/curation.py= =_write_user=
-
-=os.replace= makes the *rename* atomic, but a shared temp name is not: two writers open the same path, the second truncates under the first, and the file that gets renamed into place is a blend of both. The loser's own =os.replace= then raises =FileNotFoundError=, because the winner already renamed the name out from under it.
-
-Real concurrent-writer pairs exist for two of the three. =maint/cache.py= =updates_repo= is written by =maint-net-scan.timer= hourly and again by =doctor._fresh_pending()= at UPDATE fire time. =audio/ptt.py= has three writers by design (the CLI toggle bound to a key, the waybar right-click, and the GTK panel) — its module docstring says so. =curation.py= is written by panel key presses and CLI verbs.
-
-Grading: Minor severity (every reader degrades rather than crashes — =cache.get= catches =ValueError= and reports no data, =read_state= reads a torn file as disarmed, and both recover on the next write; the sharpest edge is the loser's =FileNotFoundError= aborting the rest of =scan_net=, which the next hourly run repairs) × rare edge case (the write window is a millisecond or two, and the overlapping writers are an hourly timer against a human keypress) = P4 = [#D].
-
-Fix: give all three the =f"{path}.tmp.{os.getpid()}"= form the two careful siblings already use. It is three one-line changes and needs no new abstraction. Note this closes the torn-file half only — the read-modify-write in =ptt.toggle_plan= and =curation.set_preference= can still lose an update between two writers, which wants a lock rather than a temp-name change and should stay a separate decision.
-** DONE [#C] dmenuexitmenu word-splits its menu so no entry matches :bug:dwm:solo:
-CLOSED: [2026-07-24 Fri]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-24
-:END:
-Found in the 2026-07-24 sentry bug-hunt (shellcheck SC2128). =dwm/.local/bin/dmenuexitmenu= line 4 expands the menu unquoted: =choice=$(echo -e $menuitems | dmenu ...)=. Word-splitting collapses the runs of spaces the labels carry, so dmenu shows =Lock= where the =case= arm expects =Lock = (two spaces) and =Logout 󰩈= where the arm expects a trailing space. No arm matches, so choosing an entry does nothing at all.
-
-CORRECTION (2026-07-24): THE BUG AS FILED DOES NOT EXIST. Ran it. =echo= rejoins the words split off the unquoted expansion with single spaces, and no label carries two spaces, so quoted and unquoted produce byte-identical output — verified against the exact literals from git rather than a retyped copy. Every =case= arm matches and every menu action works.
-
-What is real is latent. An unquoted expansion collapses a double space and glob-expands a =*=; the second was demonstrated turning a label into a directory listing. No current label triggers either.
-
-Hardened anyway in dotfiles =2cf3fb3= as robustness, not as a bug fix: the expansion is quoted and the bogus one-element array is now a plain string. Output confirmed unchanged byte-for-byte. New =tests/dmenuexitmenu/= (10 tests) pins the working behaviour, and shellcheck on the file drops from three findings to one.
-
-SECOND SENTRY FILING DISPROVED BY RUNNING IT, after =a57c443= (mkplaylist). Both came from a shellcheck hit plus reasoning, neither was executed. A static-analysis finding says a construct is unsafe, not that it currently misbehaves, and both filings treated the first as the second.
-** DONE [#C] Timer module hero hierarchy :feature:waybar:timer:quick:solo:
-CLOSED: [2026-07-24 Fri]
-From the roam inbox (Craig, claimed 2026-07-22). Which display ("hero") wins the waybar timer module when several timer modes run simultaneously: pomodoro wins over everything (the user is actively working; it's likely their main focus). The rest rank in chronological order of when they would ring. Worked example: with a just-started 15-min timer, a 1-hr timer at 10 minutes left, a pomodoro, and an alarm ringing in 12 minutes — show the pomodoro; when it completes, the 1-hr timer (rings first), then the alarm, then the 15-min timer. Feeds the timer-panel spec (docs/specs/2026-07-02-timer-panel-spec.org).
-
-Shipped as dotfiles =9eedb39=. Pomodoro wins the hero, then soonest-to-ring, in both selectors (=engine.select_primary= for the bar, =panel.primary_id= for the GTK hero). Craig's worked example is a test. FLAGGED FOR CRAIG: the two selectors diverge on a *ringing* alarm (the bar excludes it, the panel gives it the hero) and I left that as-is rather than reverse a deliberate choice. Whether to unify them is your call.
-** DONE [#C] Timer module: drop RING message, persistent notifications :bug:waybar:timer:quick:solo:
-CLOSED: [2026-07-24 Fri]
-From the roam inbox (Craig, claimed 2026-07-22). Remove the RING message from the timer module display; verify all timer and alarm notifications are persistent; the icon returns to normal once the notification has fired. Rationale: keeps timers and pomodoros from interfering with one another's displays (pairs with the hero-hierarchy task above).
-
-Shipped as dotfiles =9eedb39=. The tooltip no longer prints RING or a (ringing) suffix; a fired alarm shows its clock time and its persistent notification carries the alert. Verified the timer and alarm completion notes already set persist=True.
-** DONE [#C] PTT icon outline removal :bug:waybar:quick:solo:
-CLOSED: [2026-07-24 Fri]
-From the roam inbox (Craig, claimed 2026-07-22): the waybar PTT icon should not have an outline. Cosmetic × every-glance = P3 = [#C].
-
-Shipped as dotfiles =e63c0cf= (live style.css + dupre theme source). Removed the amber/green text-shadow glow from the armed/talk states, the only outline-like effect on the icon. FLAGGED FOR CRAIG: this is my read of "outline" (the glow). If you meant the glyph shape itself, it's a one-line revert. Confirm live by pressing PTT.
-** DONE [#B] Dotfiles tests leak state across files :bug:test:dotfiles:solo:
-CLOSED: [2026-07-23 Thu]
-Resolved 2026-07-23 as dotfiles =c333598=. The polluter was =tests/weather/test_weather.py=, and it accounted for all 38 failures on its own.
-
-The mechanism was not the env leak the body below guessed at — tests/weather never writes =os.environ=. Its whereami fake did =weather.subprocess.run = ...= on a freshly-loaded module object. The fresh module isolated the weather code, but =weather.subprocess= is the one shared stdlib module object every module in the process holds, so the assignment replaced =subprocess.run= process-wide and never restored it. Every later test file got weather's fake result back from =subprocess.run=; the tell was wtimer asserting on =r.returncode= and getting "'R' object has no attribute 'returncode'", where =R= is weather's fake result class.
-
-Triage: TEST HYGIENE, not production global state. The weather script reads env at import and never writes, so no long-lived-process caching defect sits behind it. A scan for the same pattern (patching a stdlib module attribute reached through another module's namespace) finds exactly one instance in the suite — the three other =setattr= sites all snapshot and restore. So the planned shared env helper across 28 files was aimed at the wrong target and wasn't needed.
-
-Fix: rebind the loaded module's own =subprocess= name to a stub namespace, so nothing outside that module changes and there is nothing to restore.
-
-Gate: =make test= now runs two gates per the add-don't-replace decision — =test-forked= (one process per file, catches order dependence) and the new =test-shared= (every suite in one process, catches leakage). Built on stdlib unittest rather than pytest, since pytest was only the diagnostic tool and isn't a project dependency. Verified as a real gate, not just green today: with the defect deliberately reintroduced it goes red, and green once restored. A focused test in tests/weather pins the invariant on the culprit as well, because the shared gate alone blames the three victim files.
-
-Verification: 3500 tests, both gates, exit 0.
-
-Original finding follows.
-
-Found 2026-07-23 during the speedrun. =make test= is green, but it runs each test file in its own =python3 -m unittest= process, which hides cross-file state leakage. A single-process whole-tree run (=python3 -m pytest tests/ -p no:randomly=) fails 38: 22 in =tests/wtimer/test_wtimer.py=, 10 in =tests/zoom-web/test_zoom_web.py=, 6 in =tests/wlogout-menu/test_wlogout_menu.py=.
-
-Not a regression — a worktree at the pre-speedrun commit produces the identical 22/10/6 profile, so this predates tonight's work. Those three files also pass cleanly when run together (170 passed), so the polluter is a fourth file somewhere in the tree that mutates global state (env var, cwd, or a module-level patch) without restoring it. 28 test files write =os.environ= directly.
-
-Why it matters: the green gate can't see this class of bug, so a real isolation defect — or a genuine failure that only appears under a different order — passes CI silently. Bisect by running the tree with subsets until the polluter is identified (pytest's =-p no:randomly= keeps the order stable while bisecting), fix its cleanup, then decide whether =make test= should gain a single-process pass so the gate covers it.
-** DONE [#B] Wallpaper view freezes the panel — thumbnail decode :bug:dotfiles:solo:
-CLOSED: [2026-07-23 Thu]
-Craig reported 2026-07-23: selecting the wallpaper button freezes the module and the compositor asks whether to kill it. Root cause proven: =_Thumb._draw= decoded each source image with =new_from_file_at_scale= on the GTK main thread. Measured on Craig's 78 wallpapers — a viewport of the 8 largest takes 3.7s, the whole set 13s. That block trips Hyprland's "not responding" watchdog.
-
-Grading: Critical severity (panel unusable, watchdog kill) × every user every time the wallpaper view opens = P1 = [#A] by the matrix. Held at [#B] because step 1 already shipped and removes the user-visible freeze; the remainder is a latency enhancement, not a showstopper.
-
-*** 2026-07-23 Thu @ 15:40 Step 1 — async decode (dotfiles f45f321)
-Moved the decode to a worker thread via a new =settings/thumbcache.py= (pure, injected decode/scheduler/thread; 6 tests). The thumb shows its dark ground until the pixbuf lands, then redraws. Verified live on a headless output: worst main-loop stall opening the pair view dropped from multi-second to 68ms; the cache filled with 81 decoded pixbufs (the one miss is a .webm, correctly falling back to the ▶ glyph). Full suite 3512, both gates, smoke OK. This alone fixes the reported freeze.
-
-*** 2026-07-23 Thu @ 16:30 Step 2 — persistent on-disk cache (dotfiles 463cc4f)
-Built the persistent layer: =settings/thumbstore.py= decodes each source once to a 512px PNG under =~/.cache/settings/thumbs=, keyed by path + mtime so an edited wallpaper self-invalidates. The hot-path decode reads that PNG and scales in-memory. Warming rides the existing =settings tick= CLI verb (the 2-min timer already runs it), building up to =WARM_PER_BEAT=8= missing thumbnails per beat — best-effort, journals a line on failure, never blocks the wallpaper flip. thumbstore is pure (stat/decode/load/save injected); 10 tests.
-
-Went with incremental warming (8/beat, ~10 beats to full) as the safe default rather than full-warm-on-change — the per-beat cap is a one-line flip if Craig wants it faster. Measured: hot-path decode of a viewport dropped from 3.7s cold to 47ms warm. No installer change (the tick service already runs =settings tick=); cache lives outside the repo. Full suite 3522, both gates, smoke OK, live panel verified (81 pixbufs render, 48ms worst stall warm).
-** DONE [#C] Panel scrollbars too short :bug:dotfiles:quick:solo:
-CLOSED: [2026-07-23 Thu]
-Shipped 2026-07-23 as dotfiles =0d64837= (22px scrollbar, 16px trough, 14px slider thickness with a 48px floor along the travel axis). Left open by oversight during the speedrun; closing now.
-
-Follow-on, and my own regression: enlarging the bar to 22px is what made it start covering the thumbnails, because nothing grew the tray to match. Craig reported it the same day ("scrollbars that obscure the images") and it's fixed in =c0ddf57= — the tray now reserves a 22px lane for the bar as a margin on the scrolled box, so the bar sits below the images instead of across them. Measured before: tray 68px, content 68px, a visible 14px bar inside the same 68px. After: tray 90, content 68, bar clear. The lane is a constant under the scrollbar CSS with a note to keep the two in step, since the coupling between bar thickness and tray height is exactly what broke.
-
-From the roam inbox (Craig, claimed 2026-07-23): all scrollbars need to be much taller than before. The always-visible scrollbars shipped in 7e8eb4a set =min-height: 10px; min-width: 10px= on the slider (=settings/src/settings/gui.py=, the =.dupre-panel scrollbar slider= rule) — that's the floor for a short slider, and the trough itself is thin. Raise both the slider floor and the trough thickness so the bar is comfortably grabbable. Cosmetic × every glance at the wallpaper trays = P3 = [#C].
-** DONE [#C] Video wallpapers don't fit the desktop :bug:dotfiles:solo:
-CLOSED: [2026-07-24 Fri]
-From the roam inbox (Craig, claimed 2026-07-23): videos don't fit the desktop in desktop-settings. The video channel drives mpvpaper (=settings/src/settings/wallpaper.py=); mpvpaper passes options through to mpv, so the fit is a =--panscan=/=--video-unscaled=/keepaspect question rather than a layout one. Reproduce with a video whose aspect differs from the output, pick the mode that fills without distorting (cover, matching how the image channels behave), and cover it in the wallpaper tests. Minor severity × whenever the video channel is selected = P3 = [#C].
-
-Shipped as dotfiles =04d1489=. =set_video= now passes =panscan=1.0=, so mpvpaper fills the output and crops the overflow instead of letterboxing; keepaspect stays on so nothing stretches. Tested against the mpvpaper arg log.
-** DONE [#C] World-clock wallpaper arrangement :feature:dotfiles:
-CLOSED: [2026-07-24 Fri]
-Shipped 2026-07-24 as dotfiles =6afbe09=, iterated live with Craig. The grid of boxed mini-clocks became a centered vertical clock line: cities down a spine, west (Honolulu) top to east (Wellington) bottom, labels alternating both sides, no boxes. Each shows city / time (12h) / day+date / timezone region name ("US Central"). Day/night dimming + amber home carried over, title dropped, cursor restored over the desktop. Prototypes archived in archsetup 40216e7. The face is parameterized (=?layout=vertical|horizontal=, =?hour12=1|0=) so the panel pickers below can drive it.
-** DONE [#C] Floating layout — should we? :feature:hyprland:
-CLOSED: [2026-07-24 Fri]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-24
-:END:
-From the roam inbox (Craig, claimed 2026-07-23): consider whether Hyprland should offer a floating layout — how it would work, the benefits, and the complexity. A brainstorm/spike, not a build: the deliverable is an assessment Craig reads and decides on, not a shipped layout. Not :solo:. When picked up, run it as a brainstorm — how a floating mode coexists with the current tiling binds (toggle keybind, per-workspace vs global, window-rule interactions), what it buys over the existing =togglefloating=, and the config/muscle-memory cost — then bring Craig the recommendation.
-
-CONCRETE PROPOSAL from a second roam item (Craig, 2026-07-24 via work) — "floating mode as the easiest mode":
-- Can't select floating until at least one window is displayed.
-- Entering floating freezes each window's position and floats it exactly where it is.
-- During floating, drag windows with mod+mouse-drag.
-- Exiting floating switches to tiling or monocle and lets that layout take over.
-Craig's note: "simple, could be useful for different reasons." This is the design the brainstorm should evaluate first — assess feasibility against Hyprland's actual float/tile transitions (does freezing current geometry survive the tiling↔floating switch, does re-tiling on exit reflow cleanly) before recommending.
-
-ASSESSED, dotfiles =8cf4728=: =docs/2026-07-24-floating-layout-assessment.org=. Verdict: buildable and worth building on a capture-then-restore of window geometry (=hyprctl clients -j= gives at/size), which is a real gesture plain =togglefloating= can't express. Craig's four-rule proposal is folded in and each rule assessed. One taste call flagged (exit to previous layout vs always monocle). Ready to file a build task on Craig's go.
-** DONE [#C] World clock wallpaper: bold the city names :feature:dotfiles:quick:solo:
-CLOSED: [2026-07-24 Fri]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-24
-:END:
-From the roam inbox (Craig, 2026-07-24 via work): bold the city names on the world-clock wallpaper face (=settings/faces/world.html=, shipped =6afbe09=). Cosmetic × every glance at the world face = P3 = [#C]. Solo — a CSS weight change, screenshot-verifiable — but it's a visual call, so build it and show the render rather than close off a green suite. Pairs with the open world-face picker task.
-
-Shipped as dotfiles =e63c0cf=. =.lbl .city= is now =font-weight:700=. Rendered offscreen and confirmed the bold reads well over the time/zone lines; home city stays amber. Comparison render was on ws5 for Craig.
-** DONE [#C] Floating clock toggles on control+mod+c :feature:dotfiles:hyprland:solo:
-CLOSED: [2026-07-24 Fri]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-24
-:END:
-From the roam inbox (Craig, 2026-07-24 via work): a control+mod+c keychord should toggle the floating clock, the same as clicking the time waybar module.
-
-This answers the design question the round-6 clock-toggle fix deliberately left open (see the =clock toggle listener= DONE task above): =do_activate= calls =show_clock()= rather than =toggle()=, and the note there flagged "worth raising if he ever wants the spawn path to toggle too." He does. Build: a hyprland keybind bound to =clock toggle=, and confirm the toggle path (not show-only) fires whether the service is cold or warm. Solo — buildable and locally verifiable.
-
-Shipped as dotfiles =e73a70e=. =bind = $mod CONTROL, C, exec, clock-panel toggle= reuses the exact command the time module's click runs, so it toggles identically. Registered clean on reload. Live keypress is Craig's to confirm.
-** DONE [#C] Calculator scratchpad won't toggle closed on mod+x :bug:hyprland:solo:
-CLOSED: [2026-07-24 Fri]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-24
-:END:
-From the roam inbox (Craig, 2026-07-24 via work): =mod+x= opens the calculator scratchpad but doesn't close it — Craig has to kill the window by hand. A second =mod+x= should toggle it shut. Almost certainly a =togglespecialworkspace= vs plain =exec= binding in the hyprland config, or a scratchpad window-rule mismatch. Minor severity (a workaround exists: kill the window) × every time the calc scratchpad is used = P3 = [#C]. Solo — a keybind/window-rule fix, locally verifiable.
-
-Shipped as dotfiles =e73a70e=. New =calc-toggle= script (mirrors fuzzel-toggle: pgrep -x, pkill or launch), and =mod+X= now points at it, so a second press closes the calculator. 3 tests in tests/calc-toggle.
-** DONE [#C] Saving and recalling window configurations :feature:hyprland:
-CLOSED: [2026-07-24 Fri]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-24
-:END:
-From the roam inbox (Craig, 2026-07-24 via work), a research idea: Craig wants to save a specific window+app arrangement and have it reappear on demand. What has to be known and built to make that happen — is there prior art (another WM or OS that does session/layout save-restore), what information do those need (app identity, geometry, workspace, launch command), and what are their rules. Explore how far Hyprland can get (hyprctl clients + dispatch, exec rules, window rules by class/title), document thoroughly, and review with Craig next time. Not :solo: — the deliverable is an assessment he reads and decides on, and it may spawn a build task once the shape is clear. Offer to file the build separately if part of it turns out urgent.
-
-RESEARCHED, dotfiles =8cf4728=: =docs/2026-07-24-window-config-save-recall-assessment.org=. Prior art surveyed (i3/sway =append_layout= swallow, KDE window rules, macOS Moom). Three tiers from cheapest: (1) reposition open windows — buildable + testable now; (2) relaunch + place by class rule; (3) full swallow-by-title, which hits the same-class ambiguity every tool hands back to the user. Recommends shipping tier 1; tiers 2-3 need Craig's call on how much manual disambiguation he'll accept.
-** DONE [#C] Velox refresh sweep :chore:maint:
-CLOSED: [2026-07-23 Thu]
-From the roam inbox (Craig, claimed 2026-07-23): velox needs bringing up to date, the mouse/touchpad module is still there, investigate what else didn't move over.
-
-Resolved 2026-07-23 by a full sweep over tailscale. The touchpad module was already gone — velox's running waybar (started 01:05, after the reboot) and its tracked config both carry zero =custom/touchpad= entries; what Craig saw was the pre-restow waybar process from before the reboot, and the reboot cleared it. Sweep results: both machines at dotfiles f9b6404 (all three hyprland lock/exit fixes live on velox, config errors clean, =allow_session_lock_restore= reads true); stow restow clean, only the expected skip-worktree files; rulesets pulled to 50fc7ca and =make install= run (agent-text verified working by invoking it — an earlier "MISSING" reading was a PATH artifact of the non-interactive ssh shell, not a real gap); desktop-settings tick timer active; mpvpaper, power-profiles-daemon, gtk4-layer-shell, webkit2gtk all present.
-
-Genuine remaining differences, all per-machine installs rather than sync failures: =cmail-action=, =gcalcli=, and =playwright= aren't installed on velox, and =obsbot-wb-guard.service= isn't enabled there (the OBSBOT lives on ratio). None block anything; file separately if velox should send mail or drive browser tests.
-** DONE [#C] Weather tooltip sunrise and sunset :feature:waybar:weather:quick:solo:
-CLOSED: [2026-07-23 Thu]
-Shipped 2026-07-23 as dotfiles =de62e9d=. The two rows sit directly below Humidity in the current-conditions block, rendered in the footer's 12-hour format (=%-I:%M %p=) so the tooltip reads one way throughout.
-
-Confirmed the no-extra-round-trip premise held: =sunrise,sunset= joined the existing =&daily== block. Split =forecast_url= and =reading_from= out of =fetch= so both the request and the reading are testable without network — that's what let the new cases cover a payload missing the fields. Six tests (Normal/Boundary/Error): row placement and format, a pre-change cache with no sun fields, an unparseable stamp, today's pair picked out of the six-day arrays, and the API omitting them. Reused the existing =_at= helper rather than adding a near-duplicate =_first=.
-
-Live-verified against the real API: sunrise 6:14 AM, sunset 7:59 PM for today in New Orleans, rendering in the actual tooltip. Full suite 3506 tests, both gates, exit 0.
-
-Open, not blocking: every other header row carries a glyph (thermometer, droplet, wind arrow) and the sun rows are plain text. The file's glyphs are marked font-confirmed codepoints, and I haven't verified a sunrise/sunset glyph renders rather than showing tofu, so I left them bare. Craig's call.
-
-From the roam inbox (Craig, claimed 2026-07-23): in the weather module's hover text, the section immediately after the location ends with the current humidity. Add the sunrise and sunset times for the current location directly below it.
-
-Cheap to source: the module already calls Open-Meteo with a =&daily== block (=common/.local/bin/weather=, the forecast URL around line 336), so =sunrise,sunset= joins that same request with no extra round trip — normalise_daily already parses the daily arrays. Times arrive as local ISO strings; render in Craig's canonical clock format rather than re-deriving one. The settings package's =suntimes.py= (pure NOAA math, no network) stays the offline fallback path if the API field is ever absent — don't duplicate its math here.
-** DONE [#C] Maint doctor-row copy button :refactor:maint:quick:solo:
-CLOSED: [2026-07-23 Thu]
-Shipped 2026-07-23 as dotfiles =761fa5c=, "fix(maint): drop the COPY key from the doctor row" — the key and its orphaned handler removed from =maint/src/maint/gui.py=. =viewmodel.status_copy_text= stays: it's a tested pure serializer and the obvious source if a copy surface returns somewhere better placed.
-
-Correction to the body below: it describes a per-row button and a separate global one. There is only one COPY key, and it IS the global one Craig added in 8bc79ba two days earlier. He tried it and wanted it gone, so the row now reads DOCTOR · CLEAN UP · REVIEW & FIX.
-
-From the roam inbox (Craig, claimed 2026-07-23): remove the per-doctor-row copy button (next to REVIEW and FIX) from the maint status wall. The global COPY key (dotfiles 8bc79ba, "one global button copying rendered text") stays the one copy surface — the per-row button turned out to be clutter next to it.
-** DONE [#C] WiFi tooltip signal strength :feature:waybar:network:
-CLOSED: [2026-07-22 Wed]
-From the roam inbox (Craig, claimed 2026-07-22): add signal strength to the WiFi tooltip.
-
-Resolved 2026-07-22: the tooltip's signal line existed but never fired on ratio — the mt7925 driver leaves /proc/net/wireless empty (legacy WEXT procfs unimplemented), so the dBm read returned None and the bar glyph fell to the weakest tier. Fix in dotfiles net/: an iw-dev-link nl80211 fallback (only spawns when procfs is empty), a signal_percent mapping, and an enriched line — Signal: ▂▄▆█ 100% · -32 dBm (excellent) — bars by band, percent, raw dBm, band word. The bar icon tier fixed itself as a side effect.
-** DONE [#B] Desktop-settings dropdown panel :feature:waybar:
-CLOSED: [2026-07-22 Wed]
-:PROPERTIES:
-:LAST_REVIEWED: 2026-07-22
-:END:
-Resolved 2026-07-22: shipped end to end via the "Build: desktop-settings panel" task (dotfiles 7a15237 → 9038eee; spec IMPLEMENTED, 85 suites + smoke 13/13 + e2e 17/17). Every open question below got settled in the spec: bar consolidation landed (74f723e), the wallpaper manager became the in-panel sub-view, and the format pickers split into their own sibling spec ([[file:docs/specs/2026-07-19-display-format-single-source-of-truth-spec.org]], DRAFT stub). Remaining human-eye checks live under "Manual testing and validation".
-
-Original body follows as the record.
-
-Initial spec written 2026-07-02: [[file:docs/specs/2026-07-02-desktop-settings-panel-spec.org]] (DRAFT — four decisions await Craig's review before build; architecture updated to the net panel's Blueprint/GTK4 stack).
-
-One waybar dropdown gathering the desktop toggles and sliders into a single settings panel, opened from a gear/settings glyph on the bar. Incorporate:
-- *Auto-dim* toggle (the =custom/dim= feature just shipped — fold in here, or keep the standalone indicator and mirror it).
-- *Brightness* slider (backlight, via brightnessctl).
-- *Keyboard-backlight* brightness slider (brightnessctl on the kbd_backlight class).
-- *Mouse* enable/disable toggle — shown only when a mouse is connected.
-- *Trackpad* enable/disable toggle — shown only when a trackpad is connected (mirror =toggle-touchpad= / =touchpad-auto=).
-- *Idle inhibitor* (the =custom/idle= module that replaced the built-in =idle_inhibitor= 2026-06-24 — toggles the hypridle daemon, state-synced icon).
-- *Airplane mode* (the existing =airplane-mode= toggle; laptop-only).
-
-The conditional rows (mouse, trackpad, airplane) appear only when their hardware/context applies — reuse the laptop/device detection the airplane and touchpad indicators already do.
-
-Design / open questions (propose before building):
-- Panel tech: sliders need a real toolkit (waybar can't host a slider), so a GTK4 + gtk4-layer-shell app like pocketbook is the likely shape.
-- Which existing standalone bar modules (dim, touchpad, airplane, idle_inhibitor) collapse INTO this panel vs. stay on the bar as quick-access indicators. Craig's call.
-
-Implementation notes: a small GTK layer-shell app (mirror pocketbook's structure: src-layout Python package, pytest, Makefile) talking to brightnessctl / hyprctl / the touchpad + airplane helpers. Lives in the dotfiles repo or in-tree like pocketbook. TDD the backing toggle/slider logic. Sizable — worth a design doc first.
-
-Home handoff 2026-07-19 (inbox, resolving the open "few other things" decision — fold into the spec, close the open decision, extend the controls table, then run spec-review, may flip DRAFT→READY). Ownership: home drives the build (dotfiles settings/), archsetup keeps the canonical spec. Full reconciliation in home docs/design/2026-07-19-desktop-settings-module-brainstorm.org.
-- ADD controls: night-light / color temperature; Do Not Disturb / notifications (dunst); lock / suspend quick actions; power profile (performance/balanced/saver); scenes/profiles — one control flipping several toggles at once (Focus, Presentation, Battery-saver, Night). Scenes are the payoff of consolidating everything.
-- OUT (record reasons): volume / master-mute stays with the audio panel (no mirror here); theme light/dark goes to the theme-studio task.
-- FORMAT PICKERS pulled to their own future sibling spec — time/date/weather format is out of THIS panel. Rationale: format settings live in many programs, so the design problem is a single source of truth for the canonical format. Track a future sibling-spec stub in docs/specs (time/date/weather format single-source-of-truth); Craig thinking it through separately, not started.
-- STILL OPEN (spec already flags): wallpaper manager confirmed in scope, but row-that-opens-a-sub-view vs its own sub-spec undecided — resolve at spec-review.
-** DONE [#C] Gallery probe: the fader-drag check is flaky :bug:test:design:quick:solo:
-CLOSED: [2026-07-23 Thu]
-Fixed 2026-07-23. Root cause confirmed rather than suspected: =panel-widget-gallery.html= line 74 sets =html{scroll-behavior:smooth}=, so =scrollIntoView= animates and the fixed 200ms sleep sometimes read =getBoundingClientRect= mid-scroll. The drag then dispatched at stale coordinates, the press missed the fader, and the check reported a dead widget.
-
-Fix: scroll with =behavior:'instant'=. The probe never needed the animation, so this removes the race instead of waiting it out. Also added a =settledRect= guard (rect stable across two reads AND on-screen) for zoom/column relayout, and a =hits()= assertion that the press actually lands on the fader before the drag goes out.
-
-Applied to the toggle-click check too — it shares the same fixed-sleep shape, and it failed for this exact reason during the diagnosis, so fixing only the fader would have left half the defect.
-
-Worth recording: my FIRST fix was wrong and made it worse. Polling until the rect stopped changing returned pre-scroll coordinates every time, because two identical samples are also what you get before the animation starts — an intermittent failure became a consistent one. The new hit-test assertion is what caught it, printing the press point at y=1326 against a 1200px window. That's the argument for asserting the press landed rather than only asserting the readout moved.
-
-Verified against the measured 1-in-6 failure rate: 8 consecutive runs, all three checks passing, with the press point identical every run (429,480) — deterministic, not lucky. Full probe 96 PASS, 0 FAIL, exit 0.
-
-=probe.mjs= check 3 ("fader drag tracks at 3x") intermittently reports =level 68 -> level 68=, i.e. the synthetic drag never registers. It has presumably been doing this all along unnoticed, since the suite is normally run once per batch.
-
-Grading: *Minor* severity (a false FAIL costs a re-run and a few minutes, and never ships a defect) x *most users, frequently* = P3 = =[#C]=.
-
-Frequency measured 2026-07-16, not estimated: 1 failure in 6 consecutive runs, having already fired twice in about fifteen that afternoon. The first grading guessed "some users, sometimes" (~1 in 10); at ~1 in 6, both people who run this suite hit it most sessions, so the row is "most users, frequently". The letter lands on =[#C]= either way, but the input was wrong and the matrix is only worth anything if its inputs are measured.
-
-Suspected cause: the check clicks the 3x size chip, calls =scrollIntoView=, waits a fixed 200ms, then reads =getBoundingClientRect= and dispatches the drag against those coordinates. If the zoom relayout or the smooth scroll hasn't settled, the rect is stale and the press lands off the fader — so the drag is a no-op and the readout never moves. The other timing-sensitive checks share the same fixed-sleep shape.
-
-*Do not fix this by raising the sleep.* That hides the race rather than removing it and leaves the check failing again on a slower run. Wait on the actual condition instead: poll until the rect stops changing between frames, or assert the press landed on the fader before dispatching the drag (the probes' own README already warns that a =find()= miss dispatches into nothing and reports as a widget bug).
-
-Why it matters beyond the annoyance: a gate that cries wolf gets its real failures ignored, and this suite is the only thing standing between the gallery and a silent regression.
-
-Recurrences: 2026-07-18 batch-6 gate (first run, passed 3 reruns); 2026-07-18 batch-9 gate (first cold run, =level 68 -> level 68=, passed 2 reruns); 2026-07-21 double-speedrun run (flashed one RED mid-run, passed on rerun). All were a session's first/early probe run — consistent with the stale-rect theory (cold-start relayout settling slower than the fixed 200ms sleep).
-** DONE [#C] Dotfiles stow conflicts: first-launch risk + restow directory handling :bug:dotfiles:quick:solo:
-CLOSED: [2026-07-23 Thu]
+* Archsetup Resolved
+** DONE [#A] Lock-screen clock stale after a real sleep :bug:hyprland:dotfiles:velox:
+CLOSED: [2026-09-13 Sun] SCHEDULED: <2026-08-25 Tue>
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-14
+:CREATED: [2026-08-25 Tue]
+:LAST_REVIEWED: 2026-08-25
:END:
-Closed 2026-07-23. The last open item was the velox check, and velox is reachable again. Pulled it from =02df01a= to =de62e9d= (clean tree, fast-forward), then ran =make conflicts hyprland=, which dry-runs every tier: "No stow conflicts", exit 0. Its old conflict copy had already cleared against the updated repo, so there was nothing for =make reset= to do.
-
-Verified the pull is live through the symlinks rather than just present in the repo: =~/.local/bin/weather= resolves into the dotfiles tree and returns today's sun times on velox.
-
-Note for the record: =make conflicts common= is not a valid invocation — =check-de= rejects it, because common and the host tier are auto-included in the DE-scoped run. =make conflicts hyprland= is the whole check.
-*** 2026-07-14 Tue @ 00:51:51 -0500 Ratio calibre check passed; waypaper canonical decided (dark-lion)
-Ratio's ~/.config/calibre is a directory symlink into the dotfiles repo (stow folded the whole dir), so the first-launch gap never existed there — check closed. Craig decided dark-lion.jpg is the canonical waypaper wallpaper; the repo config.ini updated from the that-one-up-there.jpg placeholder (the file is skip-worktree volatile, unskipped for the commit and re-flagged). Remaining: when velox is back online, run make conflicts / make reset there so its old conflict copy clears against the updated repo.
-*** 2026-07-02 Thu @ 17:30:00 -0400 Shipped the Makefile hardening + first-launch guard (dotfiles 42a82d2)
-The solo-able subset landed in the speedrun. =make conflicts <de>= is the loud first-launch guard: dry-runs all tiers, parses all four stow error shapes (plain file conflict, foreign symlink, dir-over-file, and restow's unstow_contents non-directory ERROR), lists each blocker with a directory/foreign-symlink marker, exits 1 when any exist. =make reset= now pre-clears the directory and foreign-symlink blockers =--adopt= aborts atomically on (removals printed; repo version wins per the target's contract), then adopts + git-checkouts as before. =make restow='s overwrite path switched rm -f → rm -rf so directory conflicts clear. 8 sandbox tests drive the real Makefile against a throwaway HOME (44 suites green). Also verified on velox: the whereami and mpd-playlists conflicts noted in this task were already hand-converted 2026-06-29 — =make conflicts hyprland= reports clean live. REMAINING (deferred per Craig's speedrun pre-flight): the waypaper canonical decision (live velox dark-lion.jpg vs repo that-one-up-there.jpg) and the ratio calibre-symlink check (ratio paused).
-From the velox calibre incident (2026-06-27, note in ~/.dotfiles/inbox/processed/): calibre was launched before =make stow= ran, wrote its own default config into =~/.config/calibre/=, and silently blocked its own stow — it ran on factory defaults while the rest of common/ stowed fine. General pattern: any GUI app that auto-creates config on first run, launched before stow, blocks its own stow the same way. Velox was repaired by hand (=ln -srf= symlinks byte-identical to =stow --no-folding= output).
-
-Remaining work (re-graded C 2026-07-02 — the first-launch risk and the Makefile handling shipped in the speedrun; what's left is a paused-machine check):
-- Waypaper canonical decision (Craig): RESOLVED 2026-07-14 — dark-lion.jpg is canonical (dotfiles fea3e93), repo config.ini updated off the that-one-up-there.jpg placeholder.
-- Ratio check: RESOLVED 2026-07-14 — ratio's =~/.config/calibre= is a directory symlink into the repo (stow folded the dir), so the first-launch gap never existed there.
-- When velox is back online: run =make conflicts= / =make reset= there so its old conflict copy clears against the updated repo. (velox carries a separate boot-recovery task; check once it's reachable.)
-** DONE [#B] Weather tooltip caching :feature:waybar:weather:solo:
-CLOSED: [2026-07-25 Sat 10:53]
-From the roam inbox (Craig, claimed 2026-07-22): retrieve the weather tooltip data once per hour and cache it. If the network is unavailable, display the cached tooltip with explanatory text saying so. Dotfiles-side work (archsetup owns the lifecycle); touches common/.local/bin/weather.
-Verified complete in the 2026-07-25 batch: the weather CLI already had the hourly default TTL, fresh-cache no-fetch path, stale fallback, and explicit offline footer. Its 33-test suite and the full dotfiles suite pass.
-** DONE [#B] Settings gear becomes four device toggles :feature:waybar:dotfiles:solo:
-CLOSED: [2026-07-25 Sat 10:53]
-From the roam inbox (Craig, claimed 2026-07-23): the waybar gear should become four icons — touchpad, mouse, webcam, and a notification bubble. Clicking each toggles that setting directly. The first three turn red when disabled; the bubble turns red when DND is enabled.
-
-Today =custom/settings= (=hyprland/.config/waybar/config=) is one gear glyph (󰒓) whose only job is =on-click: settings-panel=. The toggles themselves already exist and are tested — the settings package owns touchpad, mouse, and webcam (=webcam.py= is the USB-authorized kill switch from 2026-07-22), so this is a bar-side surface over existing backends rather than new capability.
-
-Note the state-polarity split when wiring the colors: three read "red = off" and DND reads "red = on". That asymmetry is deliberate (red means "something is disabled that normally isn't, or suppressed that normally isn't"), so encode it per-icon rather than deriving one rule.
-
-Decided 2026-07-23 (Craig): the gear STAYS alongside the four toggles as the panel launcher. So the bar's right side grows from 12 modules to 16 — the four toggles are net-new, the gear keeps its =on-click: settings-panel=. Open sub-question for build time, not blocking: whether the four toggles are four separate waybar modules or one custom module rendering four glyphs (fewer layout entries, one exec). Pick at build; the four-module shape is simplest and matches how mic/net already sit as individual modules.
-Shipped in the 2026-07-25 batch as four independent JSON modules over the existing verified settings backends. Touchpad, mouse, and webcam turn terracotta when disabled; DND uses the deliberate inverse polarity; unavailable hardware dims. The gear remains the panel launcher. The live and Dupre theme CSS copies stay byte-identical.
-** DONE [#C] Wallpaper panel selection and scroll state :feature:dotfiles:solo:
-CLOSED: [2026-07-25 Sat 10:53]
+After waking velox from a real sleep, the hyprlock clock shows a stale time
+(Craig confirmed 2026-08-24: the wake-from-sleep case, not an idle-locked
+screen). Three isolated tests on 2026-08-24 failed to reproduce it — hyprlock
+0.9.6 repainted within a second of a display power-cycle, a three-minute
+SIGSTOP, and both together with the screenshot background — so it needs a real
+suspend on the real hardware.
+
+Grading: Minor severity (cosmetic-to-confusing, the screen still unlocks) ×
+most users frequently (every wake) = P3 = [#C] by the matrix; held at [#A] at
+Craig's direction on 2026-08-25 so it gets run while velox is the daily driver
+on the road. Revisit the letter once the manual check has an answer.
+
+Not :solo: — the distinguishing observation is Craig's. The check lives under
+Manual testing and validation: "Lock screen after a real sleep: is the clock
+frozen, or is all of hyprlock frozen?". Its three outcomes each name a
+different fix: stale-then-corrects → repaint interval; frozen with live input
+→ clock rendering; frozen with dead input → hyprlock hung, a crash/hang
+recovery bug the =screen-lock= watchdog doesn't cover.
+
+*** 2026-09-13 Sun @ 07:57:04 -0500 Closed: fixed, per Craig
+Craig confirmed on 2026-09-13 Sun that the stale clock after a real sleep is fixed on
+velox. I could not identify the commit from here (nothing in the dotfiles
+or archsetup log since 2026-08-24 names hyprlock, the lock clock, sleep or
+resume), so this closes on his report rather than a cited change; the
+manual-testing check for it is retired with it.
+** DONE [#A] Velox reinstall — DR test of archangel + archsetup :velox:chore:
+CLOSED: [2026-09-13 Sun] DEADLINE: <2026-08-15 Sat>
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-25
+:CREATED: [2026-08-13 Thu]
+:LAST_REVIEWED: 2026-08-13
:END:
-From the roam inbox (Craig, 2026-07-25). Screenshot: =~/pictures/screenshots/2026-07-25_013041.png=. Three related behaviors in the settings wallpaper panel (=settings/src/settings/wallpaper.py=):
-1. Open at the wallpaper currently displayed, not the top of the list.
-2. Highlight that wallpaper as selected in the scrollable pane while it shows in the preview.
-3. Keep the scroll position when a picture is selected. Today selecting a picture snaps the scroll back to the top, which is the bug half of this.
-Grade: minor scroll-reset defect x every panel selection = P3 = [#C]; the open-at-current and select-current behaviors are enhancements at the same level. One type tag, so filed =:feature:= with the scroll-reset called out as the bug. Solo: buildable in the settings GTK panel, agent-verifiable via headless capture plus the wallpaper.py tests, no design call — swww query gives the current wallpaper, and scroll-position preservation and row selection are standard GTK.
-Shipped in the 2026-07-25 batch. The panel queries =awww query= off the UI thread, prefers the actually displayed image over stale stored state, highlights it, scrolls it into view on first open, and remembers the horizontal adjustment across selection-triggered rebuilds.
-** DONE [#C] Net tooltip IPs and line order :feature:waybar:network:solo:
-CLOSED: [2026-07-25 Sat 10:53]
-From the roam inbox (Craig, claimed 2026-07-23): in the wifi hover, add the internal IP, external IP, and gateway IP just below the Interface line; move the Signal line to just above the keyboard-shortcuts line. Design constraint: the bar's hot path does no network I/O (status.py deliberately skips _address_facts on the 2s beat) — internal IP + gateway can ride cheap local reads, but the external IP must come from a cache the connectivity probe refreshes, never a live lookup in waybar-net.
-Shipped in the 2026-07-25 batch. The slow connectivity probe caches local addressing and a validated external IP with the network identity; the Waybar hot path only reads that valid cache. Tooltip order is Interface, internal/external/gateway IPs, connectivity detail, throughput, Signal, shortcut.
-** DONE [#B] Dupre Kit merge — casting additions :feature:tooling:solo:
-CLOSED: [2026-07-25 Sat 10:53]
-Fold docs/prototypes/dupre-kit-additions.js back into the kit proper: detentFader (NEW — multi-detent slide attenuator with speedbump drag physics: magnet + escape hysteresis, parked tick glow) and the drumRoller redefinition (UPGRADE — 1..N channels and min/max range; stock hardcodes two drums and throws on one, defaults reproduce stock exactly) and the guardedToggle redefinition (UPGRADE — lever throws with rotateX so it flips toward the viewer instead of the stock 180° planar spin that sweeps sideways mid-transition; contract unchanged). Merge means: builders into widgets.js, the additions CSS into DUPRE_CSS, additions-scoped gradients into the shared defs plate, gallery cards for both in panel-widget-gallery.html, and POLICY entries. Origin: the desktop-settings casting sitting 2026-07-21 — Craig's direction is that components get finished by being needed ("the ones needed most will have had the most attention"), so more additions may accrue here before the merge; batch them.
-Shipped in the 2026-07-25 batch. =widgets.js= now owns all three builders, shared gradients/CSS, contracts, and policy records; additions no longer redefines them when older casting pages load it. The gallery has a three-detent fader card and a three-channel 0–12 drum demonstration (112 cards total). Static ownership tests, JS syntax checks, and the complete headless interaction probe pass.
-** DONE [#C] Maint live-refresh hairline replacement :feature:maint:solo:
-CLOSED: [2026-07-25 Sat 10:53]
+Mainboard swapped Intel→AMD (Ryzen AI 9 HX 370); new NVRAM has no boot entry.
+Decision: full reinstall via archangel+archsetup, run deliberately as a
+disaster-recovery drill before the Sunday flight. Runbook (live checklist):
+[[file:docs/2026-08-13-velox-reinstall-runbook.org][docs/2026-08-13-velox-reinstall-runbook.org]]
+Done 2026-08-13: ISO rebuilt (archangel-2026-08-13, archsetup baked with AMD
+microcode detection, velox profiles at /root/, .ai/inbox excluded — build.sh
+edits pending commit in archangel), contents verified, dotfiles swept clean of
+Intel assumptions.
+Finding folded in: velox's truenas backups silently stopped ~Jul 6 (newest is
+DAILY.0 Jul 6; wolf.conf.gpg from Jul 29 is in NO backup). Salvage pass in the
+runbook is therefore REQUIRED before partitioning, and the fresh install must
+fix + verify the backup timer (runbook Phase 5).
+
+*** 2026-09-13 Sun @ 07:16:27 -0500 Applied the two live convergence steps on velox
+Ratio got both by hand on 2026-09-12 while velox was off the tailnet; velox
+came back on 2026-09-13 and got them over ssh: =systemctl disable --now
+wsdd.service= (no Samba host to advertise; 64cccef stops the installer
+enabling it) and =systemctl mask passim.service= (the unit is static, so the
+09-12 disable was a no-op; 495d16d masks it in the installer, but
+supplemental_software is a completed step there and doesn't re-run).
+Verified after: wsdd inactive/disabled, passim inactive/masked, zero
+listeners on 5357 and 27500. Same pass fast-forwarded velox's dotfiles to
+f56fd1a and archsetup to 9c77b07, and confirmed the headless Proton Bridge
+service is still disabled there.
+
+*** 2026-09-13 Sun @ 07:56:37 -0500 Closed the drill and filed its working-dir artifacts
+The reinstall itself finished on 2026-08-14; every finding it surfaced is
+its own task, and the live convergence steps landed on 2026-09-13, so nothing
+was left in this task but the record. Filed per the working-files
+convention: the runbook to docs/2026-08-13-velox-reinstall-runbook.org (with
+an Outcome section, since the checklist was never ticked during the run),
+the boot-entry reference to docs/2026-08-15-velox-uefi-boot-entry-reference.org,
+the three gap reports to docs/design/2026-08-14-velox-reinstall-gaps-1 to 3,
+and the wttrin bundle to working/emacs-wttrin-rescue/ under the task that
+owns it. working/velox-reinstall/ is gone and every inbound link repointed.
+** DONE [#B] Land the rescued emacs-wttrin commit :chore:velox:
+CLOSED: [2026-09-13 Sun]
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-14
+:CREATED: [2026-08-14 Fri]
+:LAST_REVIEWED: 2026-08-17
:END:
-From the roam inbox (routed 2026-07-13): the memory-killer section seemed to update too often, and "it's a bit unclear what the line is doing; consider something else." Diagnosis (2026-07-14): the data cadence is already the requested 3s (gui live tier, _LIVE_SECONDS); the perceived churn is the live-refresh hairline — the 2px bar under the live sections that drains full-to-empty over each 3s window, redrawn at 150ms (gui._hair_tick, viewmodel.refresh_fraction). It exists to tell a stale board from a frozen one (2026-07-09), but it reads as constant unexplained motion. Design call for Craig: replace the draining line with something whose meaning is legible — candidates: a dot that blinks once per refresh, a "3s" age caption that only appears when refresh is overdue, slowing the drain redraw, or dropping the indicator on live tiers and keeping it only when data goes stale. Keep the stale-vs-frozen distinguishability that motivated the hairline.
-*** 2026-07-21 Tue @ 08:35:00 -0500 Decided (Craig): silent-until-stale age caption
-Replace the draining 2px hairline with an age caption that shows ONLY when refresh is overdue (e.g. "3s", "8s" once past the expected window) and shows nothing while the board is healthy. This keeps the stale-vs-frozen signal — a frozen board surfaces a growing age number, a live one stays clean — while removing the constant motion the hairline created. Implementation (dotfiles, archsetup-owned): drop =gui._hair_tick= / the hairline draw, add an overdue-age caption driven off =viewmodel.refresh_fraction= (or the last-refresh timestamp) rendered only past the live window. Now unblocked; needs a live visual check on the panel after.
-Shipped in the 2026-07-25 batch. The animated draw area and 150ms timer are gone; the memory section header stays silent through the healthy three-second window, then shows a once-per-second growing age caption. Pure boundary tests and the full maintenance suite pass.
-** DONE [#D] Test-framework + prototype refactor cluster :refactor:solo:
-CLOSED: [2026-07-25 Sat 10:53]
-Grading: no behavior change; parking lot. Refactors from the S5-S7 audit, distinct from the installer refactor rollup above.
-scripts/testing/run-test.sh + run-test-baremetal.sh duplicate the run/poll/report skeleton and have drifted (VM uses setsid + copy helpers, baremetal uses nohup + hand-rolled sshpass scp) — extract the shared core so baremetal inherits the sturdier paths; run-maint-nspawn.sh:66 + run-maint-scenarios.sh:78 duplicate the transport-independent _scenario_var/_validate_scenario/run_scenario (a sourced lib/maint-scenario.sh); run-test.sh:251,265 uses two different mechanisms (pgrep vs ps|grep) for the same liveness check; docs/prototypes/gen_tokens.py:78 repeats the section-iteration skeleton across four emitters; gallery-widget.el:95,136 hardcodes SVG arc/hub path strings that duplicate the cx/cy/radius geometry (dial desyncs silently on a constant change); gallery-widget.el:72,84 leans on the private svg--append. See findings doc (S5, S6, S7).
-Completed test-first in the 2026-07-25 batch. QEMU and bare-metal runners share liveness/report helpers; maintenance transports share scenario validation/execution; token emitters share ordered section traversal; and the Emacs SVG gauge shares semicircle geometry and uses the public DOM append API. Every fast Python/ERT suite passes.
-** DONE [#B] Two agent sessions sharing one git repo :chore:tooling:
-CLOSED: [2026-07-26 Sun]
-Craig approved the shared-rules-layer solution on 2026-07-26.
-
-Use one repository-scoped publish lock for every session and worktree sharing a clone. Derive the lock name from the real Git common-directory path; hold it across reconcile, stage, staged review, and commit; track the owning session and reviewed staged-tree fingerprint; refresh it after conversational waits; and repeat the staged review if ownership or the fingerprint changed. Ordinary working-tree edits remain concurrent.
-
-An approval waiver never waives the staged review, because that review is the gate that reads the actual hunks entering the commit. Rulesets owns the implementation in =commits.md=, =agent-lock=, and its Bats coverage; archsetup sent the approved implementation package through the rulesets inbox.
-** DONE [#A] Reboot ratio to activate amdgpu.runpm=0 :bug:hyprland:ratio:
-CLOSED: [2026-07-28 Tue] DEADLINE: <2026-07-28 Tue>
+bf0457f "feat: add wttrin-hide-follow-line to hide the wttr.in follow line"
+(2026-06-24) was the only genuinely unpushed commit anywhere on the old
+velox — 3 files, 103 insertions, with a test file. Rescued as a verified
+git bundle before the disk was wiped (wttrin-bf0457f.bundle, deleted on
+2026-09-13 once the commit was on the remote):
+To land it: clone emacs-wttrin, =git fetch <bundle> --branches=, review the
+commit, then push to git@cjennings.net:emacs-wttrin.git. Delete the bundle
+once it's on the remote.
+
+*** 2026-08-17 Mon @ 19:57:42 -0700 Re-checked: still unlanded, and the bundle is still the only copy
+Cloned the remote bare and asked it for the object directly: =git cat-file -t
+bf0457f= returns "Not a valid object name", so the commit has never reached
+=git@cjennings.net:emacs-wttrin.git=. Remote =main= is =ee8fdeb=.
+
+That makes =working/emacs-wttrin-rescue/wttrin-bf0457f.bundle= (moved there 2026-09-13 when the reinstall task filed its artifacts) the sole surviving
+copy of 103 insertions across three files, on one laptop that is travelling.
+Worth doing sooner than its =[#B]= suggests for that reason alone. It also
+pinned the reinstall working directory open until 2026-09-13, when the bundle
+moved into its own working dir here and the reinstall task closed.
+
+*** 2026-09-13 Sun @ 09:47:20 -0500 Landed on emacs-wttrin release/0.4.0 and deleted the bundle
+Cherry-picked unchanged onto release/0.4.0 (which already contains main; the
+remote's default branch is still main, so main doesn't carry it yet) as
+cb70193, patch-id identical to bf0457f. Review turned up two
+defects the rescued commit carried, both fixed test-first and pushed with it:
+f9449f6 makes the existing-F check case-sensitive (case-fold-search defaults
+to t, so a lowercase f read as F), and 9c8d23e keys the cache on the effective
+display options (toggling the setting kept serving a cached buffer with the
+Follow line, against the 944a52f rule). Pushed 37e1c94..9c8d23e; full suite
+green at 73 files.
+
+Two things checked before landing: an old emacs-wttrin note that F broke ANSI
+colour is stale (wttr.in sends identical colour codes with and without F), and
+the suite's smoke failure was only missing Emacs 31.1 eask deps. The bundle's
+main and release/0.4.0 heads are on the remote as-is, and its only other commit
+(bf0457f) is there as the patch-identical cb70193, so the bundle and its
+working dir are deleted.
+emacs-wttrin has a handoff note in its inbox covering all of it.
+** DONE [#A] Pre-vacation fix list — morning review
+CLOSED: [2026-09-17 Thu]
:PROPERTIES:
-:CREATED: [2026-07-28 Tue]
-:LAST_REVIEWED: 2026-07-28
+:LAST_REVIEWED: 2026-09-17
:END:
-Craig's plan: close everything down, run topgrade, then reboot. Alarm set for 08:00 (=at= job 56, persistent desktop notify).
-
-=amdgpu.runpm=0= sits in =/etc/default/grub= and in the generated =/boot/grub/grub.cfg= (5 occurrences, so the reboot will actually apply it) but is absent from =/proc/cmdline=. The box has been up since 2026-07-22 21:10 and the fix landed 2026-07-24, so the running kernel predates it. The GPU is AMD Strix Halo (Radeon 8060S, =1002:1586=), exactly what the parameter targets: runtime power management invalidates the GPU resources hyprlock holds across a display power-cycle, so hyprlock exits without unlocking.
-
-That is the root cause under the 2026-07-27 lockdead screen. The screen-lock flock fix (dotfiles =ec18fd7=) stops one dead client from becoming a lockdead screen, but it treats the symptom -- this reboot treats the cause.
-
-Not :solo: — Craig closes his own session and runs topgrade first.
-
-Rebooted 2026-07-28 08:59. =amdgpu.runpm=0= confirmed present in =/proc/cmdline= afterward, so the parameter is finally live.
-
-Correction, 2026-07-29: the claim above and in the body that this is "the root cause under the 2026-07-27 lockdead screen" is wrong, and superseded. hyprlock was never crashing. Every logged exit is =rc=143=, SIGTERM, from =settings-watch= killing it by design. See =[#B] Night watch and the lock watchdog fight each other=. The reboot was still worth doing (the parameter is a genuine mitigation for a real AMD defect) but it did not fix this, and the lockdead screens continued after it.
-** DONE [#B] Caffeine state is unreadable on both surfaces :bug:dotfiles:design:solo:
-CLOSED: [2026-07-28 Tue]
-:PROPERTIES:
-:CREATED: [2026-07-28 Tue]
-:LAST_REVIEWED: 2026-07-28
-:END:
-Neither surface that reports caffeine tells the truth reliably, so there is no way to know at a glance whether the screen will lock. Found while investigating the 2026-07-27 lockout, where Craig believed caffeine was on and the screen locked anyway.
-
-Defect 1 — the settings panel shows a frozen value. =gui.py= calls =_refresh_async()= once during window construction (line 454) and again only after the user's own actions (=_after_matrix=, line 663). The only two =GLib.timeout_add= calls are one-shots (the 2400ms toast hide and a 350ms fire), so nothing re-reads state on a timer. An open panel therefore displays the caffeine value from the moment it opened, forever. Any external flip -- the waybar click, Super+I, the =caffeine-toggle= script -- leaves it stale with no self-correction. The panel is the only surface in the repo carrying a caffeine control (=panel.py:25=); maint has none and does not embed these toggles, so this is the display Craig read.
-
-Defect 2 — there is no caffeine indicator on the bar at all. =custom/caffeine= appears in neither the stowed =hyprland/.config/waybar/config= nor the live generated =/run/user/1000/waybar/config=, and no =custom/caffeine= block is defined anywhere in the waybar config dir. The =waybar-caffeine= script exists, works, and has its own passing test suite, but nothing displays it. So the bar has never been a source of caffeine state, and the keybind and script have been signalling (=pkill -RTMIN+8 waybar=) a module that isn't there.
-
-(An earlier read of this task said the bar showed two near-identical glyphs. That was wrong: the module is absent, not merely unstyled. The script's class names are still backwards -- =active= when caffeine is OFF, =inhibited= when ON -- and neither class is styled, but both points are moot until the module is actually in the bar.)
-
-Grading: Major severity (the panel reports state wrongly while it is open, and the only other surface does not exist, so there is no reliable source for a setting Craig actively manages) x most-of-the-time (any external toggle while the panel is open; the bar never shows it) = P2 = [#B].
-
-Fix all three. Wire =custom/caffeine= into the bar, rename its classes so they describe caffeine rather than idle, and style them from the existing palette. Give the panel's toggle row a re-read on a timer or on focus-in. Solo -- buildable and testable, and the direction is settled by the defects rather than a taste call, though the bar color is worth a glance from Craig once it renders.
-
-All three shipped as dotfiles =033076c=, pushed. =custom/caffeine= now sits in the bar between DND and settings on =interval: 2=; classes renamed =on=/=off= and both styled, caffeine-ON in the theme's gold =#dab53d=; the panel re-reads live state every 3s while visible. Verified live in the stowed config and the generated =/run/user/1000/waybar/config=. The full suite caught a theme-copy regression (=themes/dupre/waybar.css= out of sync with =waybar/style.css=) that the focused suites missed.
-** DONE [#B] hyprlock still exits mid-lock; the watchdog relaunch is silent :bug:hyprland:dotfiles:
-CLOSED: [2026-07-29 Wed]
+Reviewed 2026-08-08; the decisions it drew are recorded in the tasks below.
+Closed at the 2026-09-17 review because the ~08-15 departure it ranked work for
+has passed. Where each of the seven items went:
+
+1. Velox reliability → the [#A] sleep/suspend task.
+2. Velox machine health for travel → moot: velox was wiped and reinstalled on
+ 2026-08-13.
+3. Remote access from outside the LAN → the wolf WireGuard rider under the
+ sleep/suspend task. Tailscale to ratio works off-LAN (checked 2026-09-17);
+ truenas and truenas-kvm weren't re-checked.
+4. cgit secrets audit → the [#A] audit task and its rotation VERIFY.
+5. Osbot camera → its own task; the podman socket and camera udev rule are
+ installed (sleep/suspend task, 08-17 entry).
+6. Hotspot/metered WiFi and network ordering → the held design calls under
+ Next Session Focus.
+7. The orchestrator sequence-pin gap → filed 2026-09-17 as [#C] Orchestrator
+ sequence pin misses an added step.
+** DONE [#B] Ship the Framework 13 AMD internal-mic workaround in the installer :bug:velox:audio:solo:
+CLOSED: [2026-10-06 Tue]
:PROPERTIES:
-:CREATED: [2026-07-28 Tue]
-:LAST_REVIEWED: 2026-07-28
+:CREATED: [2026-10-06 Tue]
+:LAST_REVIEWED: 2026-10-06
:END:
-Craig, 2026-07-28 ~15:00: saw the Hyprland lockdead/error text blurred *behind* a working lock screen; it vanished when he authenticated.
-
-That ordering is the diagnosis. hyprlock's blur samples what the compositor is currently rendering, so the compositor was already showing lockdead when the new hyprlock attached. Sequence: hyprlock exits non-zero (no coredump, so it exits rather than crashing), Hyprland renders lockdead because the client is gone while the session stays locked, =screen-lock='s watchdog relaunches within =LOCK_RELAUNCH_DELAY= (0.5s), and the new client draws over the lockdead frame and blurs it.
-
-*The recovery worked.* On 2026-07-27 this same hyprlock exit produced two contending clients and a session recoverable only from another console. It now self-heals in half a second, and the residue is cosmetic. Both the flock guard (dotfiles =ec18fd7=) and the watchdog did their jobs — verified in this session's compositor log, where all four lock events created exactly one =sessionLock= and one =sessionLockSurface= each, against two of each on 2026-07-27.
-
-Two things remain.
-
-*Why hyprlock exits.* The wrapper's header blames GPU-resource invalidation across a display power-cycle (hyprlock#953), which =amdgpu.runpm=0= targets — and that parameter is live as of the 2026-07-28 08:59 reboot, confirmed in =/proc/cmdline=. There is also no DPMS idle rule any more (=e900903=), so idling never power-cycles the display. Yet hyprlock still exited. Strongest untested candidate: a screen recording (=wf-recorder= into =~/sync/recordings/2026-07-28-12-53-57.mkv=, running 12:53 until Craig killed it) held screencopy sessions on DP-4 across the lock. The compositor log carries 2454 screenshare sessions and a =CScreencopyProtocol= bind in the window between the last two locks. A screencopy client churning dmabufs alongside hyprlock's own is a plausible way to invalidate them, and it was the one large new variable that day.
-
-*The relaunch is silent.* The watchdog loop re-runs hyprlock and logs nothing, so there is no record of how often this fires, when, or with what exit code — which is exactly why the frequency couldn't be established from the logs. Log the exit code and a timestamp on each relaunch.
-
-Grading: Major severity (the lock client dies mid-lock, and the pre-fix version of this wedged a session unrecoverably) x most users frequently (twice in three days, and this is a single-user machine, so every occurrence lands on the only user) = P2 = [#B]. Downgraded from the 2026-07-27 [#A] because the wedge is fixed and the failure now self-heals.
-
-An earlier draft of this grading said "some users sometimes", which the matrix maps to P3 = [#C], not the [#B] written beside it. The frequency row was the wrong input rather than the letter: on a one-user machine a fault hitting twice in three days is frequent, not occasional. Corrected the input per the rule that a disputed grade is fixed at its inputs.
-
-Solo for the instrumentation half only: adding the relaunch logging is buildable, testable against the existing =tests/screen-lock= suite, and needs no decision. Diagnosing the exit is not solo — it needs a reproduction, and the likely trigger is Craig recording his screen.
-
-Next step when picked up: land the relaunch logging first so the next occurrence produces evidence, then try to reproduce by locking with =wf-recorder= running.
-
-Superseded 2026-07-29 by =[#A] Night watch and the lock watchdog fight each other=. The logging landed (dotfiles =5bbe2c3=) and answered it within hours: three =rc=143= entries, SIGTERM, from =settings-watch= killing hyprlock by design. Nothing was crashing, so both the AMD-iGPU and the screen-recorder hypotheses in this task are wrong. Kept closed rather than deleted because the reasoning that led here is worth the record.
+Shipped 2026-10-06 as archsetup commit =5189b7c=. =configure_framework_amd_internal_mic= runs in
+=desktop_environment= right after the pipewire/wireplumber package loop,
+gated by =is_framework_13_ryzen_ai_300= (DMI: vendor Framework, product_name
+carrying "Laptop 13" and "AMD Ryzen AI 300"; ratio's "AI Max 300", the Intel
+13, the 7040 13, and the 16 all miss). It installs
+=configs/wireplumber/50-disable-ucm.conf= with =install -Dm644=. The shipped
+file matches the PDM card by name pattern (=~alsa_card.pci-.*-platform-acp-pdm-mach=)
+rather than the one PCI path home's hand-written version pinned, so a
+renumbered bus on the same board is still caught; the live velox file was
+replaced with the shipped one and wireplumber restarted, with the card set and
+default source unchanged. The audio failure taxonomy gained the bug's row and
+a Cluster 5 note so the doctor's use-ucm remedy treats the drop-in as the fix,
+not an offending config. No wireplumber restart from the installer
+(no user session bus during install), so it applies at the next wireplumber
+start. Fixture test: =tests/installer-steps/test_configure_framework_amd_internal_mic.py=,
+17 cases across the gate and the step; full =make test-unit= green, 57
+suites. Verified live: the gate fires on velox's real DMI, the step's output
+matches the live file byte for byte, the default source is the analog-stereo
+one, and a 4 s capture with the source briefly unmuted settled at rms about
+0.05 (room noise at +30 dB capture gain) after a 0.6 s unmute transient, not
+the 0.99999 rail. The source was muted at the PipeWire level before and
+after; that state was restored.
+From home's 2026-10-06 handoff, verified live the same morning. On velox
+(Framework 13, Ryzen AI 9 HX 370, linux-lts 6.18.55, BIOS 04.02) the internal
+microphone was dead in every app. Root cause is a known upstream kernel bug in
+the AMD ACP PDM digital-mic path on the Ryzen AI 300 Framework 13: the DMIC
+card (=acp-pdm-mach=, =hw:acppdmmach=) emits a constant full-scale rail (rms
+0.99999) instead of audio, and with UCM on PipeWire exposes only that broken
+DMIC as the internal mic (Framework community threads 81866 and 75748).
+
+Workaround home applied by hand, now live on velox:
+=/etc/wireplumber/wireplumber.conf.d/50-disable-ucm.conf= sets
+=monitor.alsa.properties { alsa.use-ucm = false }= and a =monitor.alsa.rules=
+entry with =device.disabled = true= for
+=alsa_card.pci-0000_c1_00.5-platform-acp-pdm-mach=, then =systemctl --user
+restart wireplumber=. The ALC285 falls back to its analog-stereo profile whose
+Internal Microphone pin (0x12) carries real audio; the default source is
+=alsa_input.pci-0000_c1_00.6.analog-stereo=, port =analog-input-internal-mic=,
+with the source volume lowered because the analog path stacks +30 dB capture
+on +30 dB boost. Revert: delete the file and restart wireplumber.
+
+What the installer needs: write that drop-in on this hardware only (gate on
+the =acp-pdm-mach= card or the Framework 13 AMD board id, not on the
+hostname), beside the existing pipewire/wireplumber package install, with a
+fixture test in the installer-steps suite. The live file on velox is the
+reference content. The audio failure taxonomy already lists =use-ucm= both
+ways as an audio-doctor AUTO remedy
+([[file:docs/design/2026-07-10-audio-failure-taxonomy.org][docs/design/2026-07-10-audio-failure-taxonomy.org]]), so the
+doctor's remedy and this drop-in should agree. The standing re-check after
+each kernel update (remove the drop-in once the upstream fix lands, UCM back
+on) lives in velox's machine notes, the KB node "velox's internal mic needs
+UCM off and the ACP PDM card disabled until the AMD digital-mic kernel bug is
+fixed", linked from the velox hardware node's neighbourhood, not in this
+one-shot task.
+
+Verify: =pactl get-default-source= names the analog-stereo source, and a
+short =arecord= of the internal mic has an rms well below 0.99.
diff --git a/working/clock-display-references/2026-07-30-maeda-cosmos-notes.org b/working/clock-display-references/2026-07-30-maeda-cosmos-notes.org
new file mode 100644
index 0000000..04d8629
--- /dev/null
+++ b/working/clock-display-references/2026-07-30-maeda-cosmos-notes.org
@@ -0,0 +1,11 @@
+#+TITLE: Companion to the Line applet sent a few minutes ago: a copy
+#+SOURCE: from website
+#+DATE: 2026-07-30 18:48:00 -0500
+
+Companion to the Line applet sent a few minutes ago: a copy of Cosmos (C1, 1995), the orbits piece — 'numbers in orbit'. Delivered as 2026-07-30-maeda-cosmos-standalone.html in this inbox.
+
+Same shape as the Line copy: the <maeda-cosmos> web component plus its model/step/render modules esbuild-bundled inline as an IIFE, so it opens straight from file:// with no server and no network. Verified in Chrome from file:// — canvas 400x300, the month's days riding the rotated ellipse with today red on the sweep hand, seconds odometer live at the right edge, zero console errors.
+
+Controls: hover reshapes every orbit; press-drag pulls a new loop out of the system; the mark at bottom-left clears them and replays the fly-in credit; click the canvas then ESC toggles strip-move mode. Note this one has no readout event, unlike Line.
+
+Source of truth stays ~/code/maeda-tribute (src/pieces/cosmos/); regenerate rather than edit. Reference only — no action needed.
diff --git a/working/clock-display-references/2026-07-30-maeda-cosmos-standalone.html b/working/clock-display-references/2026-07-30-maeda-cosmos-standalone.html
new file mode 100644
index 0000000..bfa9fbb
--- /dev/null
+++ b/working/clock-display-references/2026-07-30-maeda-cosmos-standalone.html
@@ -0,0 +1,422 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Cosmos (C1, 1995) — Maeda × Shiseido tribute</title>
+<style>
+:root{
+ --ground:#151311;
+ --panel:#100f0f;
+ --well:#0a0c0d;
+ --raise:#1a1917;
+ --silver:#bfc4d0;
+ --cream:#f3e7c5;
+ --steel:#969385;
+ --dim:#7c838a;
+ --wash:#2c2f32;
+ --gold:#e2a038;
+ --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html{background:var(--ground);font-size:112%}
+body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 4rem;line-height:1.45;
+ background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)}
+.wrap{max-width:820px;margin:0 auto}
+.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase}
+h1{font-size:1.5rem;color:var(--cream);font-weight:600;margin:.35rem 0 .2rem}
+.sub{color:var(--dim);font-size:.9rem;margin-bottom:1.6rem}
+.card{background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320;
+ border-radius:12px;padding:1.2rem;margin-bottom:1.4rem}
+.stage{background:var(--well);border:1px solid #22201d;border-radius:8px;padding:1rem;
+ display:flex;justify-content:center}
+.wnote{color:var(--dim);font-size:.85rem;margin-top:1rem}
+.wnote b{color:var(--steel);font-weight:600}
+.igrid{display:grid;grid-template-columns:11rem 1fr;gap:.35rem .9rem;margin-top:1rem;
+ font-size:.82rem}
+.ik{color:var(--steel)}
+.iv{color:var(--dim)}
+</style>
+</head>
+<body>
+<div class="wrap">
+ <p class="eyebrow">Waste Time Beautifully · C1</p>
+ <h1>Cosmos — numbers in orbit</h1>
+ <p class="sub">John Maeda for Shiseido, 1995 (Cal0.class) — rebuilt as a web component. Standalone copy, no build step.</p>
+
+ <div class="card">
+ <div class="stage"><maeda-cosmos id="piece"></maeda-cosmos></div>
+ <p class="wnote"><b>Controls:</b> hover to reshape every orbit — the axes track 1.5× the
+ cursor's distance from each orbit's center; press and drag to pull a new loop out of the
+ system; the mark at bottom-left clears them and replays the fly-in credit. Click the
+ canvas first, then <b>ESC</b> toggles strip-move mode (the ground goes orange and the
+ clock strip comes free).</p>
+ <p class="wnote">The days of the month ride a rotated ellipse, today in red on the sweep
+ hand, with a seconds odometer scrolling at the right edge. The month as a gravitational
+ system, days as bodies in orbit, the user as a hand that perturbs the heavens. Time is
+ periodic, not linear.</p>
+ </div>
+
+ <div class="card">
+ <p class="eyebrow">Spec</p>
+ <div class="igrid">
+ <span class="ik">original</span><span class="iv">Cal0.class + UniverseCal, orbit, eint, efloatsin (Java 1.1)</span>
+ <span class="ik">dimensions</span><span class="iv">400×300, black ground, Helvetica 10</span>
+ <span class="ik">collection</span><span class="iv">SFMOMA 99.550</span>
+ <span class="ik">revolution</span><span class="iv">7,500 ms per sweep, phase = wall clock mod cycle — every orbit in lockstep forever; orientation wobbles ±10° on a 15,000 ms sine</span>
+ <span class="ik">interaction</span><span class="iv">every mouse move (not just drag) restyles all orbits; press spawns a zero-axis orbit that inflates as the cursor pulls away</span>
+ <span class="ik">period quirks</span><span class="iv">a cursor-driven rotation angle is computed and stored but never drawn — a dead store in the 1995 bytecode; Mac Java Date bug detected and adjusted</span>
+ <span class="ik">credit</span><span class="iv">chars fly in at 800+80i ms from random scatter, hold 1.5 s, vanish; logo click replays</span>
+ <span class="ik">tests</span><span class="iv">38 (Vitest + fast-check)</span>
+ </div>
+ </div>
+</div>
+
+<script>
+(() => {
+ // src/pieces/cosmos/model.js
+ var TAU = Math.PI * 2;
+ var CREDIT = "designed by john maeda";
+ var FAITHFUL_1995 = {
+ faceW: 400,
+ // applet tag WIDTH=400 HEIGHT=300
+ faceH: 300,
+ cycleMs: 7500,
+ // one revolution, wall-clock modulo
+ wobbleAmp: 10 * Math.PI / 180,
+ // efloatsin(0, 10°, 15000)
+ wobblePeriodMs: 15e3,
+ stretch: 1.5,
+ // axes = 1.5× cursor distance from center
+ initialAxes: [300, 100],
+ // the startup orbit at panel center
+ fontSize: 10,
+ // applet param default; Helvetica plain
+ stripWidth: 20,
+ // stringWidth("59")+6 at 10 px, rounded
+ clockReach: 120,
+ // readout + weekday row extent left of the strip
+ logo: { dx: 8, dy: 8, w: 70, h: 16 }
+ // shiseido.gif box, bottom-left
+ };
+ function sweepPhase(cfg, nowMs) {
+ return nowMs % cfg.cycleMs / cfg.cycleMs * TAU;
+ }
+ function wobbleAngle(cfg, elapsedMs) {
+ return cfg.wobbleAmp * Math.sin(elapsedMs * TAU / cfg.wobblePeriodMs);
+ }
+ function orbitPoint(o, theta, alpha) {
+ const ct = Math.cos(theta);
+ const st = Math.sin(theta);
+ const ca = Math.cos(alpha);
+ const sa = Math.sin(alpha);
+ return {
+ x: o.lmaj * ct * ca - o.lmin * st * sa + o.h,
+ y: o.lmaj * ct * sa + o.lmin * st * ca + o.k
+ };
+ }
+ function dayTheta(day, today, ndays, phase) {
+ return phase + (day - today) * TAU / ndays;
+ }
+ function makeOrbit(x, y, lmaj = 0, lmin = 0) {
+ return { h: x + 0.5, k: y + 0.5, lmaj, lmin, wobbleT: 0 };
+ }
+ function stretchOrbit(cfg, o, mx, my) {
+ return { ...o, lmaj: (mx - o.h) * cfg.stretch, lmin: (my - o.k) * cfg.stretch };
+ }
+ function creditDurations(len) {
+ const chars = [];
+ for (let i = 0; i < len; i++) chars.push(800 + 80 * i);
+ return { chars, sentinel: 800 + 80 * len + 1500 };
+ }
+ function easeLinear(t, a, b, dur) {
+ if (t <= 0) return a;
+ if (t >= dur) return b;
+ return a + (b - a) * t / dur;
+ }
+ function creditDone(len, t) {
+ return t >= creditDurations(len).sentinel;
+ }
+ function stripOffset(cfg, sec, msWithin) {
+ const ld = cfg.fontSize + 1;
+ const siddy = 3 + cfg.fontSize;
+ return -ld * sec - msWithin * ld / 1e3 + siddy - ld;
+ }
+ function logoHit(cfg, x, y) {
+ const { dx, dy, w, h } = cfg.logo;
+ return x < dx + w && y > cfg.faceH - h - dy;
+ }
+ function clockHit(cfg, strip, x, y) {
+ const ld = cfg.fontSize + 1;
+ const left = cfg.faceW - strip.sidex - cfg.clockReach;
+ const right = cfg.faceW - strip.sidex + cfg.stripWidth;
+ return x > left && x < right && y < strip.siddy && y > strip.siddy - ld;
+ }
+
+ // src/pieces/cosmos/step.js
+ function scatterStarts(cfg, rng) {
+ const starts = [];
+ for (let i = 0; i < CREDIT.length; i++) {
+ starts.push({
+ x: Math.floor(rng() * cfg.faceW) * (rng() < 0.5 ? 1 : -1) + cfg.faceW / 2,
+ y: Math.floor(rng() * cfg.faceH) * (rng() < 0.5 ? 1 : -1) + cfg.faceH / 2
+ });
+ }
+ return starts;
+ }
+ function initState(cfg, rng = Math.random) {
+ return {
+ orbits: [makeOrbit(cfg.faceW / 2, cfg.faceH / 2, cfg.initialAxes[0], cfg.initialAxes[1])],
+ credit: { t: 0, starts: scatterStarts(cfg, rng) },
+ strip: { sidex: cfg.stripWidth, siddy: 3 + cfg.fontSize },
+ killPending: false,
+ movable: false,
+ draggingStrip: false,
+ prevHeld: false,
+ prevCursor: null,
+ rng
+ };
+ }
+ function toggleMovable(state) {
+ state.movable = !state.movable;
+ return state;
+ }
+ function step(cfg, state, dtMs, input) {
+ const s = { ...state, strip: { ...state.strip }, credit: { ...state.credit } };
+ const { x, y, held } = input;
+ s.credit.t += dtMs;
+ s.orbits = s.orbits.map((o) => ({ ...o, wobbleT: o.wobbleT + dtMs }));
+ if (x == null) {
+ s.prevHeld = held;
+ return s;
+ }
+ if (held && !s.prevHeld) {
+ if (logoHit(cfg, x, y)) {
+ s.killPending = true;
+ s.credit = { t: 0, starts: scatterStarts(cfg, s.rng) };
+ } else if (clockHit(cfg, s.strip, x, y) || s.movable) {
+ s.draggingStrip = true;
+ } else {
+ s.orbits = [...s.orbits, makeOrbit(x, y)];
+ }
+ }
+ if (held && s.draggingStrip && s.prevCursor) {
+ s.strip.sidex -= x - s.prevCursor.x;
+ s.strip.siddy += y - s.prevCursor.y;
+ }
+ s.orbits = s.orbits.map((o) => stretchOrbit(cfg, o, x, y));
+ if (!held && s.prevHeld) {
+ if (s.killPending) {
+ s.orbits = [s.orbits[0]];
+ s.strip = { sidex: cfg.stripWidth, siddy: 3 + cfg.fontSize };
+ }
+ s.killPending = false;
+ s.draggingStrip = false;
+ }
+ s.prevHeld = held;
+ s.prevCursor = { x, y };
+ return s;
+ }
+
+ // src/lib/calendar.js
+ function isLeapYear(y) {
+ return y % 4 === 0 && (y % 100 !== 0 || y % 400 === 0);
+ }
+ var MDAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
+ function daysInMonth(y, m) {
+ return m === 2 && isLeapYear(y) ? 29 : MDAYS[m - 1];
+ }
+
+ // src/pieces/cosmos/render.js
+ var FACE = { w: 400, h: 300 };
+ var MONTHS = ["jan ", "feb ", "mar ", "apr ", "may ", "jun ", "jul ", "aug ", "sep ", "oct ", "nov ", "dec "];
+ var WEEKDAYS = ["S ", "M ", "T ", "W ", "T ", "F ", "S "];
+ function font(cfg) {
+ return `${cfg.fontSize}px Helvetica, Arial, sans-serif`;
+ }
+ function dateParts(nowMs) {
+ const d = new Date(nowMs);
+ return {
+ year: d.getFullYear(),
+ month: d.getMonth() + 1,
+ // 1-12
+ day: d.getDate(),
+ weekday: d.getDay(),
+ hours: d.getHours(),
+ minutes: d.getMinutes(),
+ seconds: d.getSeconds(),
+ msWithin: nowMs % 1e3
+ };
+ }
+ function drawOrbit(ctx, cfg, o, dp, phase, dim) {
+ const ndays = daysInMonth(dp.year, dp.month);
+ const alpha = wobbleAngle(cfg, o.wobbleT);
+ const daystr = `${dp.day} ${dp.year}`;
+ const monthstr = MONTHS[dp.month - 1];
+ for (let d = 1; d <= ndays; d++) {
+ const p = orbitPoint(o, dayTheta(d, dp.day, ndays, phase), alpha);
+ if (d === dp.day) {
+ ctx.fillStyle = "#f00";
+ ctx.fillText(daystr, p.x, p.y);
+ ctx.fillText(monthstr, p.x - ctx.measureText(monthstr).width, p.y);
+ } else {
+ ctx.fillStyle = dim ? "#808080" : "#fff";
+ ctx.fillText(String(d), p.x, p.y);
+ }
+ }
+ }
+ function drawStrip(ctx, cfg, state, dp) {
+ const ld = cfg.fontSize + 1;
+ const { sidex, siddy } = state.strip;
+ const x = FACE.w - sidex;
+ const tileH = ld * 60;
+ const off = stripOffset(cfg, dp.seconds, dp.msWithin) + (siddy - (3 + cfg.fontSize));
+ ctx.save();
+ ctx.beginPath();
+ ctx.rect(x, 0, cfg.stripWidth, FACE.h);
+ ctx.clip();
+ ctx.fillStyle = "#404040";
+ ctx.fillRect(x, 0, cfg.stripWidth, FACE.h);
+ ctx.fillStyle = "#808080";
+ for (const tile of [-1, 0, 1]) {
+ for (let i = 0; i < 60; i++) {
+ const y = off + tile * tileH + i * ld + ld;
+ if (y > -ld && y < FACE.h + ld) {
+ ctx.fillText(String(i).padStart(2, "0"), x + 3, y);
+ }
+ }
+ }
+ ctx.restore();
+ ctx.fillStyle = "#f00";
+ ctx.fillText(String(dp.seconds).padStart(2, "0"), x + 3, siddy);
+ const h12 = dp.hours % 12 === 0 ? 12 : dp.hours % 12;
+ const clock = `${h12}${dp.minutes < 10 ? ":0" : ":"}${dp.minutes}:`;
+ const cx = x + 3 - ctx.measureText(clock).width;
+ ctx.fillText(clock, cx, siddy);
+ let wx = cx - ctx.measureText("S M T W T F S ").width;
+ for (let i = 0; i < 7; i++) {
+ ctx.fillStyle = i === dp.weekday ? "#f00" : "#808080";
+ ctx.fillText(WEEKDAYS[i], wx, siddy);
+ wx += ctx.measureText(WEEKDAYS[i]).width;
+ }
+ }
+ function drawCredit(ctx, cfg, state) {
+ const { t, starts } = state.credit;
+ if (creditDone(CREDIT.length, t)) return;
+ const durs = creditDurations(CREDIT.length);
+ let fx = (FACE.w - ctx.measureText(CREDIT).width) / 2 + 1;
+ const fy = FACE.h / 2 + cfg.fontSize / 2;
+ ctx.fillStyle = "#fff";
+ for (let i = 0; i < CREDIT.length; i++) {
+ const ch = CREDIT[i];
+ const x = easeLinear(t, starts[i].x, fx, durs.chars[i]);
+ const y = easeLinear(t, starts[i].y, fy, durs.chars[i]);
+ ctx.fillText(ch, x, y);
+ fx += ctx.measureText(ch).width;
+ }
+ }
+ function drawLogo(ctx, cfg) {
+ const { dx, dy, h } = cfg.logo;
+ ctx.fillStyle = "#808080";
+ ctx.fillText("shiseido", dx, FACE.h - dy - h / 2 + cfg.fontSize / 2);
+ }
+ function draw(ctx, cfg, state, nowMs) {
+ const dp = dateParts(nowMs);
+ ctx.fillStyle = state.movable ? "#e08000" : "#000";
+ ctx.fillRect(0, 0, FACE.w, FACE.h);
+ ctx.font = font(cfg);
+ ctx.textBaseline = "alphabetic";
+ drawStrip(ctx, cfg, state, dp);
+ const phase = sweepPhase(cfg, nowMs);
+ state.orbits.forEach((o, i) => {
+ drawOrbit(ctx, cfg, o, dp, phase, state.killPending && i > 0);
+ });
+ drawLogo(ctx, cfg);
+ drawCredit(ctx, cfg, state);
+ ctx.strokeStyle = "#fff";
+ ctx.lineWidth = 1;
+ ctx.strokeRect(0.5, 0.5, FACE.w - 1, FACE.h - 1);
+ }
+
+ // src/lib/loop.js
+ var STEP_MS = 30;
+ function startLoop({ stepFn, renderFn, stepMs = STEP_MS }) {
+ let acc = 0;
+ let last = null;
+ let raf = null;
+ let running = true;
+ function frame(ts) {
+ if (!running) return;
+ if (last === null) last = ts;
+ acc += Math.min(ts - last, 250);
+ last = ts;
+ while (acc >= stepMs) {
+ stepFn(stepMs);
+ acc -= stepMs;
+ }
+ renderFn();
+ raf = requestAnimationFrame(frame);
+ }
+ raf = requestAnimationFrame(frame);
+ return () => {
+ running = false;
+ if (raf) cancelAnimationFrame(raf);
+ };
+ }
+
+ // src/pieces/cosmos/index.js
+ var MaedaCosmos = class extends HTMLElement {
+ connectedCallback() {
+ const canvas = document.createElement("canvas");
+ canvas.width = FACE.w;
+ canvas.height = FACE.h;
+ canvas.style.cssText = "display:block;width:100%;max-width:400px;cursor:crosshair;touch-action:none";
+ canvas.tabIndex = 0;
+ this.appendChild(canvas);
+ const ctx = canvas.getContext("2d");
+ const frozen = this.getAttribute("frozen-now");
+ const nowMs = () => frozen ? Number(frozen) * 1e3 : Date.now();
+ const cfg = FAITHFUL_1995;
+ let state = initState(cfg);
+ const input = { x: null, y: null, held: false };
+ const toFace = (ev) => {
+ const r = canvas.getBoundingClientRect();
+ return {
+ x: (ev.clientX - r.left) * (FACE.w / r.width),
+ y: (ev.clientY - r.top) * (FACE.h / r.height)
+ };
+ };
+ canvas.addEventListener("pointermove", (ev) => {
+ Object.assign(input, toFace(ev));
+ });
+ canvas.addEventListener("pointerdown", (ev) => {
+ canvas.setPointerCapture(ev.pointerId);
+ canvas.focus();
+ Object.assign(input, toFace(ev));
+ input.held = true;
+ });
+ const release = () => {
+ input.held = false;
+ };
+ canvas.addEventListener("pointerup", release);
+ canvas.addEventListener("pointercancel", release);
+ canvas.addEventListener("keydown", (ev) => {
+ if (ev.key === "Escape") toggleMovable(state);
+ });
+ this._stop = startLoop({
+ stepFn: (dtMs) => {
+ state = step(cfg, state, dtMs, input);
+ },
+ renderFn: () => draw(ctx, cfg, state, nowMs())
+ });
+ }
+ disconnectedCallback() {
+ if (this._stop) this._stop();
+ }
+ };
+ customElements.define("maeda-cosmos", MaedaCosmos);
+})();
+
+</script>
+</body>
+</html>
diff --git a/working/clock-display-references/2026-07-30-maeda-line-notes.org b/working/clock-display-references/2026-07-30-maeda-line-notes.org
new file mode 100644
index 0000000..ba50a80
--- /dev/null
+++ b/working/clock-display-references/2026-07-30-maeda-line-notes.org
@@ -0,0 +1,11 @@
+#+TITLE: Copy of the Line applet (C2, 1997) from the Maeda x Shiseido
+#+SOURCE: from website
+#+DATE: 2026-07-30 18:38:41 -0500
+
+Copy of the Line applet (C2, 1997) from the Maeda x Shiseido tribute — the diagonal timeline piece, 'zoom through time'. Delivered as 2026-07-30-1838-from-website-2026-07-30-maeda-line-standalone.html in this inbox.
+
+It is a single self-contained file: the <maeda-line> web component and its model/step/render modules are esbuild-bundled inline as an IIFE, so it opens straight from file:// with no server, no build step, and no network. Verified rendering in Chrome from file:// — canvas 460x400, zero console errors, live readout wired.
+
+Controls: move the pointer along the diagonal to aim, press and hold to zoom continuously into that moment, release to float back out. Full zoom is exactly 60 seconds and the release is symmetric.
+
+Source of truth stays ~/code/maeda-tribute (src/pieces/line/); this copy is a snapshot, so regenerate rather than edit it if the piece changes. Sent for reference — no action needed unless you want it wired into the panel-widget gallery.
diff --git a/working/clock-display-references/2026-07-30-maeda-line-standalone.html b/working/clock-display-references/2026-07-30-maeda-line-standalone.html
new file mode 100644
index 0000000..783939c
--- /dev/null
+++ b/working/clock-display-references/2026-07-30-maeda-line-standalone.html
@@ -0,0 +1,457 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Line (C2, 1997) — Maeda × Shiseido tribute</title>
+<style>
+:root{
+ --ground:#151311;
+ --panel:#100f0f;
+ --well:#0a0c0d;
+ --raise:#1a1917;
+ --silver:#bfc4d0;
+ --cream:#f3e7c5;
+ --steel:#969385;
+ --dim:#7c838a;
+ --wash:#2c2f32;
+ --gold:#e2a038;
+ --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html{background:var(--ground);font-size:112%}
+body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 4rem;line-height:1.45;
+ background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)}
+.wrap{max-width:820px;margin:0 auto}
+.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase}
+h1{font-size:1.5rem;color:var(--cream);font-weight:600;margin:.35rem 0 .2rem}
+.sub{color:var(--dim);font-size:.9rem;margin-bottom:1.6rem}
+.card{background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320;
+ border-radius:12px;padding:1.2rem;margin-bottom:1.4rem}
+.stage{background:var(--well);border:1px solid #22201d;border-radius:8px;padding:1rem;
+ display:flex;justify-content:center}
+.readout{margin-top:.8rem;color:var(--gold);font-size:.82rem;letter-spacing:.06em}
+.wnote{color:var(--dim);font-size:.85rem;margin-top:1rem}
+.wnote b{color:var(--steel);font-weight:600}
+.igrid{display:grid;grid-template-columns:11rem 1fr;gap:.35rem .9rem;margin-top:1rem;
+ font-size:.82rem}
+.ik{color:var(--steel)}
+.iv{color:var(--dim)}
+</style>
+</head>
+<body>
+<div class="wrap">
+ <p class="eyebrow">Waste Time Beautifully · C2</p>
+ <h1>Line — zoom through time</h1>
+ <p class="sub">John Maeda for Shiseido, 1997 (cal1.class) — rebuilt as a web component. Standalone copy, no build step.</p>
+
+ <div class="card">
+ <div class="stage"><maeda-line id="piece"></maeda-line></div>
+ <div class="readout" id="readout">scale — · counter —</div>
+ <p class="wnote"><b>Controls:</b> move the pointer along the diagonal to aim; press and
+ hold to zoom continuously into that moment; release and you float back out. Full zoom
+ is exactly 60 seconds, and the release is symmetric.</p>
+ <p class="wnote">Time as a single line — <b>diagonal</b>, bottom-left to top-right, the
+ detail every written description of this applet gets wrong. Maeda's note: it reflects
+ the relativity and comparability of time intervals. The zoom is the content.</p>
+ </div>
+
+ <div class="card">
+ <p class="eyebrow">Spec</p>
+ <div class="igrid">
+ <span class="ik">original</span><span class="iv">cal1.class, 12,339 bytes, single class, self-contained (Java 1.1)</span>
+ <span class="ik">dimensions</span><span class="iv">460×400, black ground, Helvetica throughout</span>
+ <span class="ik">collection</span><span class="iv">SFMOMA 99.554</span>
+ <span class="ik">zoom plateaus</span><span class="iv">72px per day / hour / minute / second — scn 1 → 72 → 1,728 → 103,680 → 6,220,800 px/day</span>
+ <span class="ik">easing</span><span class="iv">counter at 20 increments/sec over segments [150, 250, 350, 450]</span>
+ <span class="ik">labels</span><span class="iv">font = min(72, unit spacing); text under 5px degrades to a line; gray ramps 128→255 as a unit matures; the current unit is always red</span>
+ <span class="ik">period quirks</span><span class="iv">Mac JVM patch capped zoom at hour level; an unused HAPPY NEW YEAR string ships in the bytecode</span>
+ <span class="ik">tests</span><span class="iv">33 (Vitest + fast-check)</span>
+ </div>
+ </div>
+</div>
+
+<script>
+(() => {
+ // src/lib/calendar.js
+ function epochDayFromCivil(y, m, d) {
+ const yy = y - (m <= 2 ? 1 : 0);
+ const era = Math.floor(yy / 400);
+ const yoe = yy - era * 400;
+ const doy = Math.floor((153 * (m + (m > 2 ? -3 : 9)) + 2) / 5) + d - 1;
+ const doe = yoe * 365 + Math.floor(yoe / 4) - Math.floor(yoe / 100) + doy;
+ return era * 146097 + doe - 719468;
+ }
+ function civilFromEpochDay(ed) {
+ const z = ed + 719468;
+ const era = Math.floor(z / 146097);
+ const doe = z - era * 146097;
+ const yoe = Math.floor(
+ (doe - Math.floor(doe / 1460) + Math.floor(doe / 36524) - Math.floor(doe / 146096)) / 365
+ );
+ const y = yoe + era * 400;
+ const doy = doe - (365 * yoe + Math.floor(yoe / 4) - Math.floor(yoe / 100));
+ const mp = Math.floor((5 * doy + 2) / 153);
+ const d = doy - Math.floor((153 * mp + 2) / 5) + 1;
+ const m = mp + (mp < 10 ? 3 : -9);
+ return { y: y + (m <= 2 ? 1 : 0), m, d };
+ }
+
+ // src/pieces/line/model.js
+ var DAY = 86400;
+ var FAITHFUL_1997 = {
+ stops: [1, 72, 1728, 103680, 6220800],
+ segments: [150, 250, 350, 450],
+ incsPerSec: 20
+ };
+ function totalIncrements(cfg) {
+ return cfg.segments.reduce((a, b) => a + b, 0);
+ }
+ function scaleForCounter(cfg, counter) {
+ const total = totalIncrements(cfg);
+ const c = Math.max(0, Math.min(total, counter));
+ let acc = 0;
+ for (let i = 0; i < cfg.segments.length; i++) {
+ const seg = cfg.segments[i];
+ if (c <= acc + seg) {
+ const f = (c - acc) / seg;
+ return cfg.stops[i] + f * (cfg.stops[i + 1] - cfg.stops[i]);
+ }
+ acc += seg;
+ }
+ return cfg.stops[cfg.stops.length - 1];
+ }
+ function advanceCounter(cfg, counter, dtMs, held) {
+ const d = cfg.incsPerSec * dtMs / 1e3;
+ const next = held ? counter + d : counter - d;
+ return Math.max(0, Math.min(totalIncrements(cfg), next));
+ }
+ function pxOfTime(view, t) {
+ return (t - view.focus) * view.scale / DAY + view.anchorPx;
+ }
+ function timeOfPx(view, px) {
+ return (px - view.anchorPx) * DAY / view.scale + view.focus;
+ }
+ var MAX_FONT = 72;
+ var MIN_TEXT_PX = 5;
+ function fontSizeFor(spacingPx) {
+ return Math.min(MAX_FONT, spacingPx);
+ }
+ function textVisible(fontSize) {
+ return fontSize >= MIN_TEXT_PX;
+ }
+ function grayFor(fontSize) {
+ return Math.floor(Math.min(MAX_FONT, fontSize) * 127 / MAX_FONT) + 128;
+ }
+ var FIXED_UNITS = { second: 1, minute: 60, hour: 3600, day: DAY };
+ function* calendarTicks(unit, t0, t1) {
+ let { y, m } = civilFromEpochDay(Math.floor(t0 / DAY));
+ const stride = unit === "decade" ? 10 : unit === "century" ? 100 : 1;
+ if (unit === "month") {
+ for (; ; ) {
+ const t = epochDayFromCivil(y, m, 1) * DAY;
+ if (t >= t0) break;
+ m++;
+ if (m > 12) m = 1, y++;
+ }
+ for (; ; ) {
+ const t = epochDayFromCivil(y, m, 1) * DAY;
+ if (t > t1) return;
+ yield t;
+ m++;
+ if (m > 12) m = 1, y++;
+ }
+ } else {
+ let yy = Math.ceil(y / stride) * stride;
+ if (epochDayFromCivil(yy, 1, 1) * DAY < t0) yy += stride;
+ while (epochDayFromCivil(yy - stride, 1, 1) * DAY >= t0) yy -= stride;
+ for (; ; ) {
+ const t = epochDayFromCivil(yy, 1, 1) * DAY;
+ if (t > t1) return;
+ if (t >= t0) yield t;
+ yy += stride;
+ }
+ }
+ }
+ function ticksInRange(unit, t0, t1) {
+ if (unit in FIXED_UNITS) {
+ const w = FIXED_UNITS[unit];
+ const out = [];
+ for (let t = Math.ceil(t0 / w) * w; t <= t1; t += w) out.push(t);
+ return out;
+ }
+ return [...calendarTicks(unit, t0, t1)];
+ }
+ var MONTHS = [
+ "JANUARY",
+ "FEBRUARY",
+ "MARCH",
+ "APRIL",
+ "MAY",
+ "JUNE",
+ "JULY",
+ "AUGUST",
+ "SEPTEMBER",
+ "OCTOBER",
+ "NOVEMBER",
+ "DECEMBER"
+ ];
+ function formatMonth(m) {
+ return MONTHS[m - 1];
+ }
+ function formatDay(m, d) {
+ return `${m}/${d}`;
+ }
+ function formatHour(h) {
+ const twelve = h % 12 === 0 ? 12 : h % 12;
+ return { text: ` ${twelve}`, suffix: h >= 12 ? "pm" : "am" };
+ }
+ function formatMinute(mm) {
+ return `:${String(mm).padStart(2, "0")}`;
+ }
+
+ // src/pieces/line/step.js
+ function initState(cfg, range, lineLenPx) {
+ return {
+ counter: 0,
+ scale: scaleForCounter(cfg, 0),
+ focus: 0,
+ // epoch seconds under the anchor pixel
+ anchorPx: 0,
+ // pixel along the line where focus projects
+ range,
+ // {tMin, tMax} or null for the unbounded twist
+ lineLenPx
+ };
+ }
+ function step(cfg, s, dtMs, input) {
+ const cursorPx = Math.max(0, Math.min(s.lineLenPx, input.cursorPx));
+ let { focus, anchorPx } = s;
+ if (input.held || s.counter > 0) {
+ focus = timeOfPx({ focus: s.focus, scale: s.scale, anchorPx: s.anchorPx }, cursorPx);
+ anchorPx = cursorPx;
+ }
+ const counter = advanceCounter(cfg, s.counter, dtMs, input.held);
+ const scale = scaleForCounter(cfg, counter);
+ if (!input.held && counter < cfg.segments[0]) {
+ const target = cursorPx * DAY;
+ focus += (target - focus) / (counter + 1);
+ anchorPx = cursorPx;
+ }
+ if (s.range) {
+ focus = Math.max(s.range.tMin, Math.min(s.range.tMax, focus));
+ }
+ return { ...s, counter, scale, focus, anchorPx };
+ }
+
+ // src/pieces/line/render.js
+ var LEVELS = [
+ { unit: "month", secs: 30 * DAY },
+ { unit: "day", secs: DAY },
+ { unit: "hour", secs: 3600 },
+ { unit: "minute", secs: 60 },
+ { unit: "second", secs: 1 }
+ ];
+ var FACE = { w: 460, h: 400 };
+ function lineOrigin(state) {
+ const margin = (FACE.h - state.lineLenPx) / 2;
+ return { mx: FACE.w - FACE.h + margin, my: margin };
+ }
+ function labelFor(unit, t) {
+ const dayIdx = Math.floor(t / DAY);
+ const civ = civilFromEpochDay(dayIdx);
+ const rem = t - dayIdx * DAY;
+ const hh = Math.floor(rem / 3600);
+ const mm = Math.floor(rem % 3600 / 60);
+ const ss = Math.floor(rem % 60);
+ switch (unit) {
+ case "month":
+ return formatMonth(civ.m);
+ case "day":
+ return formatDay(civ.m, civ.d);
+ case "hour": {
+ const h = formatHour(hh);
+ return h.text + h.suffix;
+ }
+ case "minute":
+ return formatMinute(mm);
+ case "second":
+ return formatMinute(ss);
+ default:
+ return "";
+ }
+ }
+ function sameTick(unit, t, now) {
+ const w = { second: 1, minute: 60, hour: 3600, day: DAY }[unit];
+ if (w) return Math.floor(t / w) === Math.floor(now / w);
+ const a = civilFromEpochDay(Math.floor(t / DAY));
+ const b = civilFromEpochDay(Math.floor(now / DAY));
+ return a.y === b.y && a.m === b.m;
+ }
+ function draw(ctx, state, nowSec) {
+ const { mx, my } = lineOrigin(state);
+ ctx.fillStyle = "#000";
+ ctx.fillRect(0, 0, FACE.w, FACE.h);
+ const view = { focus: state.focus, scale: state.scale, anchorPx: state.anchorPx };
+ const tA = Math.max(
+ state.range ? state.range.tMin : -Infinity,
+ state.focus - state.anchorPx * DAY / state.scale
+ );
+ const tB = Math.min(
+ state.range ? state.range.tMax : Infinity,
+ state.focus + (state.lineLenPx - state.anchorPx) * DAY / state.scale
+ );
+ ctx.strokeStyle = "#666";
+ ctx.beginPath();
+ ctx.moveTo(mx, FACE.h - my);
+ ctx.lineTo(mx + state.lineLenPx, FACE.h - my - state.lineLenPx);
+ ctx.stroke();
+ const colWidth = {};
+ for (let i = LEVELS.length - 1; i >= 0; i--) {
+ const { unit, secs } = LEVELS[i];
+ const spacing = secs * state.scale / DAY;
+ if (spacing < 2 && unit !== "month") {
+ colWidth[unit] = 0;
+ continue;
+ }
+ const size = Math.floor(fontSizeFor(spacing));
+ colWidth[unit] = textVisible(size) ? size * 2.2 : 0;
+ }
+ for (let i = LEVELS.length - 1; i >= 0; i--) {
+ const { unit, secs } = LEVELS[i];
+ const spacing = secs * state.scale / DAY;
+ if (spacing < 2 && unit !== "month") continue;
+ const size = unit === "month" ? Math.floor(Math.min(72, 11 + state.scale)) : Math.floor(fontSizeFor(spacing));
+ const gray = unit === "month" ? 255 : grayFor(size);
+ let offset = 0;
+ for (let j = LEVELS.length - 1; j > i; j--) offset += colWidth[LEVELS[j].unit];
+ ctx.font = `${Math.max(size, 1)}px Helvetica, Arial, sans-serif`;
+ for (const t of ticksInRange(unit, tA, tB)) {
+ const n = pxOfTime(view, t);
+ if (n < 0 || n > state.lineLenPx) continue;
+ const x = mx + n;
+ const y = FACE.h - my - n;
+ const isNow = sameTick(unit, t, nowSec);
+ ctx.strokeStyle = ctx.fillStyle = isNow ? "#f00" : `rgb(${gray},${gray},${gray})`;
+ ctx.beginPath();
+ ctx.moveTo(x, y);
+ ctx.lineTo(x + 3, y);
+ ctx.stroke();
+ const text = labelFor(unit, t);
+ if (textVisible(size)) {
+ const w = ctx.measureText(text).width;
+ ctx.fillText(text, x - w - offset, y);
+ } else {
+ ctx.beginPath();
+ ctx.moveTo(x - 4 - offset, y);
+ ctx.lineTo(x - offset, y);
+ ctx.stroke();
+ }
+ }
+ }
+ ctx.fillStyle = "#ccc";
+ ctx.font = "15px Helvetica, Arial, sans-serif";
+ const label = `${Math.round(state.scale)}X${state.counter >= 1200 ? " (MAX)" : ""}`;
+ ctx.fillText(label, 3, 16);
+ }
+
+ // src/lib/loop.js
+ var STEP_MS = 30;
+ function startLoop({ stepFn, renderFn, stepMs = STEP_MS }) {
+ let acc = 0;
+ let last = null;
+ let raf = null;
+ let running = true;
+ function frame(ts) {
+ if (!running) return;
+ if (last === null) last = ts;
+ acc += Math.min(ts - last, 250);
+ last = ts;
+ while (acc >= stepMs) {
+ stepFn(stepMs);
+ acc -= stepMs;
+ }
+ renderFn();
+ raf = requestAnimationFrame(frame);
+ }
+ raf = requestAnimationFrame(frame);
+ return () => {
+ running = false;
+ if (raf) cancelAnimationFrame(raf);
+ };
+ }
+
+ // src/pieces/line/index.js
+ var MaedaLine = class extends HTMLElement {
+ connectedCallback() {
+ const canvas = document.createElement("canvas");
+ canvas.width = FACE.w;
+ canvas.height = FACE.h;
+ canvas.style.cssText = "display:block;width:100%;max-width:460px;cursor:crosshair;touch-action:none";
+ this.appendChild(canvas);
+ const ctx = canvas.getContext("2d");
+ const frozen = this.getAttribute("frozen-now");
+ const nowSec = () => frozen ? Number(frozen) : Date.now() / 1e3;
+ const { y } = (() => {
+ const d = new Date(nowSec() * 1e3);
+ return { y: d.getUTCFullYear() };
+ })();
+ const t0 = epochDayFromCivil(y, 1, 1) * DAY;
+ const t1 = epochDayFromCivil(y + 1, 1, 1) * DAY;
+ const lineLen = Math.round((t1 - t0) / DAY);
+ const cfg = FAITHFUL_1997;
+ let state = initState(cfg, { tMin: 0, tMax: t1 - t0 }, lineLen);
+ state.focus = nowSec() - t0;
+ state.anchorPx = state.focus / DAY;
+ const input = { cursorPx: state.anchorPx, held: false };
+ const toLinePx = (ev) => {
+ const r = canvas.getBoundingClientRect();
+ const sx = FACE.w / r.width;
+ const x = (ev.clientX - r.left) * sx;
+ const yy = (ev.clientY - r.top) * (FACE.h / r.height);
+ const margin = (FACE.h - lineLen) / 2;
+ const mx = FACE.w - FACE.h + margin;
+ return (x - mx + (FACE.h - margin - yy)) / 2;
+ };
+ canvas.addEventListener("pointermove", (ev) => {
+ input.cursorPx = toLinePx(ev);
+ });
+ canvas.addEventListener("pointerdown", (ev) => {
+ canvas.setPointerCapture(ev.pointerId);
+ input.cursorPx = toLinePx(ev);
+ input.held = true;
+ });
+ const release = () => {
+ input.held = false;
+ };
+ canvas.addEventListener("pointerup", release);
+ canvas.addEventListener("pointercancel", release);
+ this._stop = startLoop({
+ stepFn: (dtMs) => {
+ state = step(cfg, state, dtMs, input);
+ },
+ renderFn: () => {
+ draw(ctx, state, nowSec() - t0);
+ this.dispatchEvent(new CustomEvent("readout", {
+ detail: { scale: state.scale, counter: state.counter }
+ }));
+ }
+ });
+ }
+ disconnectedCallback() {
+ if (this._stop) this._stop();
+ }
+ };
+ customElements.define("maeda-line", MaedaLine);
+})();
+
+</script>
+<script>
+document.getElementById("piece").addEventListener("readout", (e) => {
+ const { scale, counter } = e.detail;
+ document.getElementById("readout").textContent =
+ "scale " + Number(scale).toFixed(3) + " · counter " + Math.round(counter);
+});
+</script>
+</body>
+</html>
diff --git a/working/hyprland-lua-port/README.org b/working/hyprland-lua-port/README.org
new file mode 100644
index 0000000..42779e5
--- /dev/null
+++ b/working/hyprland-lua-port/README.org
@@ -0,0 +1,131 @@
+#+TITLE: Hyprland .conf → Lua port — staged, not deployed
+#+AUTHOR: Craig Jennings
+
+* Status
+
+Built and verified in a nested compositor. *Not deployed.* I deployed it to the
+dotfiles tree on 2026-08-24 and then rolled it back the same afternoon, because
+the switch had not been checked on real hardware and the machine needs to stay
+usable. The dotfiles repo is untouched at =8f692f5=; the live config is the
+original =hyprland.conf=.
+
+The port goes live only after the hardware check in =todo.org= under "Manual
+testing and validation" passes.
+
+* What is here
+
+| File | What it is |
+|----------------------------+---------------------------------------------------------------|
+| =hyprland.lua= | The deliverable, 788 lines. Shared config. |
+| =velox-local.lua= | velox host overrides, for =velox/.config/hypr/conf.d/=. |
+| =ratio-local.lua= | ratio host overrides, for =ratio/.config/hypr/conf.d/=. |
+| =reader-changes-for-lua.patch= | dotfiles-side: the four readers, ported and mutation-tested. |
+| =test-desktop-for-lua.patch= | archsetup-side: the post-install desktop checks. |
+| =hyprland.lua.generated= | Raw =hyprlang2lua= output, merging mode. Derivation evidence. |
+| =nomerge.lua= | Same converter with =--no-merge=. Derivation evidence. |
+
+The two patches are the part that is easy to lose and expensive to redo. Both
+were mutation-tested — every ported assertion was confirmed to go red when the
+property it guards was removed — so replay them rather than rewriting the
+assertions from scratch.
+
+* Redeploy, when the port is ready
+
+1. =cp hyprland.lua ~/.dotfiles/hyprland/.config/hypr/hyprland.lua=
+2. =cp velox-local.lua ~/.dotfiles/velox/.config/hypr/conf.d/local.lua=
+3. =cp ratio-local.lua ~/.dotfiles/ratio/.config/hypr/conf.d/local.lua=
+4. Move the three =.conf= files out of their stow packages. Do NOT merely leave
+ them beside the =.lua=: with both present Hyprland 0.56.2 loads the =.lua=
+ (proven — see below), so leaving the =.conf= in place buys no rollback and
+ only creates ambiguity about which file is live.
+5. =cd ~/.dotfiles && git apply <path>/reader-changes-for-lua.patch=
+6. =cd ~/code/archsetup && git apply <path>/test-desktop-for-lua.patch=
+7. Restow. Expect two traps, both hit on 2026-08-24 and both documented in
+ =todo.org=: =make restow hyprland= aborts on the pre-existing
+ =obsbot-wb-guard.service= conflict in =common= (restow =hyprland= and the host
+ package individually instead), and the running Hyprland rewrites a stub
+ =hyprland.conf= within a second of the symlink vanishing. Silence the stub with
+ =hyprctl keyword misc:disable_autoreload 1=, do the stow, then set it back to 0.
+8. *Push the dotfiles change before committing archsetup.* The installer clones
+ the dotfiles *remote* (=archsetup:1481=), so until the push lands a fresh VM
+ stows a tree with only =hyprland.conf= and the post-install checks fail.
+
+* Two things already proven, so nobody re-derives them
+
+*With both files present, the =.lua= wins.* Tested in a nested Hyprland 0.56.2
+with a fixture whose =.conf= set =gaps_in=11= and whose =.lua= set =77=. Result
+was 77, and the log read "[cfg] Using lua config found at ...hyprland.lua". This
+is why step 4 moves the =.conf= out rather than leaving it as a fallback.
+
+*The converter is a draft, not an answer.* =hyprlang2lua=
+(github.com/EIonTusk/hyprlang2lua) reported 100% coverage and still produced
+three functional defects, two of which would have broken the desktop. The two
+generated files are kept as evidence: both still carry the unfixed bind defect
+(="CTRL" .. mod .. " + S"=, which collapses to an unparseable =CTRLSUPER + S=),
+and the merging-mode file shows the source glob emitted mid-file where it silently
+reverses every per-host override. =--no-merge= fixed the ordering structurally;
+the rest were hand-fixed.
+
+* Review findings folded in (2026-08-24)
+
+An isolated review of the deployed diff, before the rollback. Four were fixed in
+the files here; the rest are gates on redeploying, not on the port's correctness.
+
+** Fixed here
+
+- =hl_source_glob= now surfaces all three failure modes and survives them. A
+ host override that fails to parse was warned about and skipped, which on velox
+ means coming up with no =force_zero_scaling= and no monitor scale — looking
+ like the whole port failed rather than one file. A runtime error inside the
+ chunk was unprotected and would have taken the entire config down over a single
+ host file. Now: parse failure says SKIPPED, runtime failure says PARTIAL and is
+ caught with =pcall=, and a glob matching nothing says NO MATCH. hyprlang did
+ none of this.
+- The =col.nogroup_border*= rationale sat *below* the =col= table, reading as a
+ preamble to =layout=. Moved above the two keys it explains — the same defect
+ the file header says was fixed for autostart.
+- =Generated by hyprlang2lua. Review TODOs before reloading Hyprland= removed
+ from all three files. No TODOs exist, and in =ratio-local.lua= it had landed
+ mid-paragraph, splitting the DP-4 rationale from the =hl.monitor= call it
+ explains.
+- =ratio-local.lua='s usage examples were still hyprlang syntax
+ (=monitor=DP-1,...=, =bind = $mod, L, ...=), which are syntax errors in a Lua
+ file, and the second named =$mod= — a variable a sourced chunk cannot see.
+ Rewritten in Lua, with a note on the scoping. Verified rather than assumed:
+ =loadfile= gives the chunk globals only, and both =mod= and =at_start= are
+ locals, so both read =nil= inside a sourced file.
+
+** Refuted by measurement
+
+- *Duplicate chords append; they do not replace.* The config binds Super+Z twice
+ on purpose (=exec pypr zoom= plus =submap zoom=), and the same for Escape
+ inside the submap — if the Lua API replaced rather than appended, Super+Z would
+ enter the submap without zooming and the pairing the config's own comment
+ relies on would be broken. Tested in a nested Hyprland 0.56.2 with exactly that
+ shape: both binds register. The live =.conf= session registers the same pair,
+ so behaviour matches. No action needed; recorded so nobody re-derives it.
+
+** Gates on redeploy — do these as part of the switch
+
+1. *Do not put the =.conf= files in a =retired/= directory inside the repo
+ without also excluding them from =dotfiles-validate=.* Its find uses
+ =-path '*/.config/hypr/*.conf'=, which globs across slashes and would match
+ the retired copies — 81 of 228 checked references came from the dead config
+ when this was tried. The validator would then fail pointing at a file kept
+ precisely because nothing loads it. Add =-not -path "$root/retired/*"= to both
+ finds, or park the =.conf= files outside the repo entirely.
+2. *Add tests for the new =dotfiles-validate= Lua branch.* The 25 new lines ship
+ with none. Proven vacuous: replacing both new awk regexes with =NEVERMATCHES=
+ still leaves =tests/dotfiles-validate/= reporting 15 tests OK. An extractor
+ that matches nothing prints nothing and exits 0 — the same false-pass shape
+ the three ported test suites got vacuity guards for. This one has no guard.
+3. *Guard the override ordering.* Nothing asserts =hl_source_glob= is the last
+ statement in =hyprland.lua=, and it is the single invariant the whole per-host
+ layer rests on. An edit that moves it above the =hl.config= blocks silently
+ reverses every host override — converter defect 1, the one that would have
+ reverted the Qt scaling fix. archsetup's VM suite structurally cannot catch
+ it, because the VM stows no host tier. The guard belongs in the dotfiles repo.
+4. *Sweep the prose comments that still name =hyprland.conf=.* About fifteen
+ across live scripts. =hyprland/.local/bin/waybar-reserve:12= is the one that
+ matters: it documents "Wired as =exec = waybar-reserve= in hyprland.conf",
+ which is exactly the mechanism the port replaces with =at_reload=.
diff --git a/working/hyprland-lua-port/hyprland.lua b/working/hyprland-lua-port/hyprland.lua
new file mode 100644
index 0000000..c005193
--- /dev/null
+++ b/working/hyprland-lua-port/hyprland.lua
@@ -0,0 +1,812 @@
+-- Hyprland Configuration
+-- Translated from DWM config.def.h and sxhkdrc
+-- Craig Jennings <c@cjennings.net>
+
+-- ============================================================================
+-- Monitor Configuration
+-- ============================================================================
+
+-- hyprlang2lua polyfills — runtime helpers reproducing
+-- hyprlang behaviour the typed Lua API doesn't expose directly.
+
+local function hl_source_glob(pattern)
+ -- 'source = path/*.conf' had hyprlang glob and inline-expand the
+ -- matches. require() can't glob, so we shell out to ls (matching
+ -- the user's brace-expansion behaviour) and dofile each result.
+ -- Paths with spaces or shell metacharacters in the directory
+ -- portion will misparse; typical ~/.config/hypr/ layouts don't
+ -- hit this. Swap to lfs.dir() or find -name if you need fancier.
+ --
+ -- Both failure paths are surfaced loudly and neither is fatal. hyprlang did
+ -- neither, and the asymmetry matters in opposite directions: a syntax error
+ -- that only warns means velox comes up with no force_zero_scaling and no
+ -- monitor scale, which looks like the port failed rather than like one file
+ -- failed; and an uncaught runtime error inside the chunk would take the
+ -- entire config down over a single host override. So: report both, continue
+ -- past both.
+ local p = io.popen("ls " .. pattern .. " 2>/dev/null")
+ if not p then return end
+ local matched = 0
+ for f in p:lines() do
+ matched = matched + 1
+ local chunk, err = loadfile(f)
+ if not chunk then
+ io.stderr:write("hl_source_glob: SKIPPED " .. f ..
+ " -- it did not parse, so nothing in it applied: " ..
+ tostring(err) .. "\n")
+ else
+ local ok, rerr = pcall(chunk)
+ if not ok then
+ io.stderr:write("hl_source_glob: PARTIAL " .. f ..
+ " -- it errored partway, so some of it applied " ..
+ "and the rest did not: " .. tostring(rerr) .. "\n")
+ end
+ end
+ end
+ p:close()
+ if matched == 0 then
+ io.stderr:write("hl_source_glob: NO MATCH for " .. pattern ..
+ " -- every host override is missing. On a machine that " ..
+ "has a conf.d file this means the glob is wrong.\n")
+ end
+end
+
+-- Autostart collectors. Each command stays under the comment that explains it,
+-- in the order hyprlang ran them; the hl.on() handlers at the bottom replay the
+-- lists. Written this way because the generated form hoisted every command into
+-- one block at the end of the file and left the comments stranded where the
+-- commands had been -- in this config that means paragraphs of rationale with
+-- no code under them, and one cross-reference ("the two exec-once lines above")
+-- that had become false.
+local autostart, atshutdown, atreload = {}, {}, {}
+local function at_start(cmd) autostart[#autostart + 1] = cmd end
+local function at_shutdown(cmd) atshutdown[#atshutdown + 1] = cmd end
+local function at_reload(cmd) atreload[#atreload + 1] = cmd end
+
+hl.monitor({
+ output = "",
+ mode = "preferred",
+ position = "auto",
+ scale = "auto",
+})
+
+-- Waybar's strip (6px top margin + 54px bar) is reserved statically by
+-- waybar-reserve, and waybar runs with "exclusive": false. The bar's own
+-- exclusive zone would vanish and reappear on every SIGUSR2 reload (the
+-- collapse mechanism) and on hide/crash/relaunch, snapping every tiled window
+-- up and back down. The static reservation holds the clients in place; only
+-- the bar itself changes. `exec` (not exec-once) reruns it on every config
+-- reload, which is exactly when Hyprland resets dynamic reservations. The
+-- script is idempotent, and a catch-all `monitor=,addreserved,...` rule can't
+-- replace it (empty-name addreserved silently no-ops).
+--
+-- Run three times over ~0.6s, not once: on reload Hyprland clears the
+-- reservation AND re-fires this exec, and the two race. A single run that
+-- fires before the clear no-ops (reserved still looks correct), the clear then
+-- wins, and the non-exclusive bar drops off-screen. Re-applying past the clear
+-- window makes the restore reliable; the script is idempotent so extra runs are
+-- free. Applying a monitor rule (e.g. the DP-4 pin) also clears the reservation,
+-- so this covers a reload that re-asserts monitors too.
+at_reload("for i in 1 2 3; do sleep 0.2; waybar-reserve; done")
+
+-- ============================================================================
+-- Startup Applications
+-- ============================================================================
+-- Portal and D-Bus setup FIRST, then waybar (needs portal for appearance query)
+at_start("dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP HYPRLAND_INSTANCE_SIGNATURE")
+-- Start hyprland-session.target FIRST: it pulls up graphical-session.target,
+-- which xdg-desktop-portal 1.22+ hard-requires (Requisite=). A bare-exec Hyprland
+-- session has no session manager to raise that target, so without this the portal
+-- fails its dependency at every login (screen-share + file pickers dead).
+-- 'systemctl start' blocks until active, so the ';' sequence guarantees the target
+-- is up before the portal restart runs.
+--
+-- Portal restart (not start) reconnects stale portals on Hyprland restart.
+-- Backend portals (GTK, Hyprland) restart BEFORE the main portal to avoid a 50s
+-- GTK settings proxy timeout; the sequence keeps that ordering. Separated by ';'
+-- not '&&' so a failing portal restart can't stop waybar from launching — waybar
+-- degrades gracefully without the portal (only the appearance query is missed),
+-- and gating the bar behind the portal left the desktop bar-less whenever
+-- xdg-desktop-portal failed its dependency at login. Waybar stays gated on its
+-- own config generation (waybar-active-config && waybar).
+at_start("systemctl --user start hyprland-session.target; systemctl --user restart xdg-desktop-portal-hyprland xdg-desktop-portal-gtk; systemctl --user restart xdg-desktop-portal; waybar-active-config && waybar -c \"$XDG_RUNTIME_DIR/waybar/config\" -s ~/.config/waybar/style.css 2>&1 | grep -v \"LIBDBUSMENU-GLIB-WARNING\" > ~/.local/var/log/waybar-$(date +%Y-%m-%d-%H%M%S).log")
+
+-- Core services
+at_start("/usr/lib/polkit-kde-authentication-agent-1")
+at_start("/usr/bin/gnome-keyring-daemon --start --components=pkcs11,secrets,ssh")
+at_start("dunst > ~/.local/var/log/dunst-$(date +%Y-%m-%d-%H%M%S).log 2>&1")
+
+-- Desktop appearance
+-- `settings restore` replays the remembered toggles and reapplies the stored
+-- wallpaper. It replaced `waypaper --restore` on 2026-08-14: waypaper keeps
+-- its own config.ini and the settings store keeps another, neither knew about
+-- the other, and the login replay always won — so a wallpaper chosen in the
+-- panel came back as whatever the shell had last set. The store is the only
+-- one of the two that can hold a sun pair, a video or a projected face, so it
+-- owns the restore. set-wallpaper records into it for choices made outside
+-- the panel.
+--
+-- waypaper --restore stays as the fallback, not the owner. If the stored
+-- wallpaper cannot be applied (an image deleted, a drive not mounted yet),
+-- `settings restore` exits 3 and waypaper's independent copy still puts
+-- something on the screen. Dropping it outright would trade this bug for a
+-- bare desktop.
+--
+-- The wallpaper half only. The toggle half runs from its own exec-once further
+-- down, after hypridle and dunst — caffeine *is* "hypridle isn't running" and
+-- DND *is* dunst's pause level, so replaying them here would spend the whole
+-- re-assert budget correcting backings that have not launched yet, and would
+-- replay them a second time besides. This slot exists for awww's timing, not
+-- theirs.
+at_start("awww-daemon & sleep 1 && { settings restore-wallpaper || waypaper --restore; }")
+
+-- Background services
+at_start("touchpad-auto")
+-- hypridle is reaped on both exit paths, because it outlives its compositor
+-- otherwise. An orphaned daemon keeps firing idle actions at whatever session
+-- is live next, and it holds its old logind session scope open (the scope can't
+-- close while a process sits in it), so orphans accumulate one per abnormal
+-- session death. On 2026-07-22 velox reached five concurrent hypridle daemons;
+-- two of them racing to lock produced "Cannot re-lock" and a session wedged
+-- locked with no client able to draw a password prompt — recoverable only from
+-- another console. exec-shutdown covers a clean compositor exit; the pkill in
+-- exec-once covers the paths where it never runs (crash, SIGKILL, TTY logout).
+at_shutdown("pkill -x hypridle")
+-- hypridle.conf is rendered here rather than tracked, because its contents
+-- are this machine's stage times and hibernate setting. Tracking the render
+-- meant every panel change dirtied the repo, and whichever machine
+-- committed last imposed its policy on the others: a desktop ended up
+-- carrying a laptop's suspend-then-hibernate line that it cannot run.
+-- Rendering at session start makes the store the only source of truth and
+-- the file a build artifact.
+--
+-- hypridle-start owns the render, the fallback, and the ordering between
+-- them, because that ordering is subtle enough to get wrong in a config
+-- line nothing can test: a render can fail on purpose (a damaged store, to
+-- avoid overwriting a real policy with defaults), and a fallback that
+-- fired there would perform exactly the overwrite the render refused.
+at_start("pkill -x hypridle; hypridle-start > ~/.local/var/log/hypridle-$(date +%Y-%m-%d-%H%M%S).log 2>&1")
+at_start("/usr/lib/geoclue-2.0/demos/agent")
+at_start("gammastep > ~/.local/var/log/gammastep-$(date +%Y-%m-%d-%H%M%S).log 2>&1")
+at_start("mpd")
+-- Replay the toggles that have no durable state of their own. Caffeine *is*
+-- "hypridle isn't running" and DND *is* dunst's pause level, so the two
+-- exec-once lines above (and dunst's) recreate both at a fixed default every
+-- start — a deliberately-set caffeine was silently discarded on every login.
+-- Ordered after those launches so it corrects a backing that exists; it also
+-- re-asserts for a few seconds, which covers a backing that comes up late.
+-- Logged like its neighbours: a silent exec-once failure here would look
+-- exactly like the bug it fixes, and gammastep is the standing proof that a
+-- launch dying quietly at session start can go unnoticed for a long time.
+at_start("settings restore > ~/.local/var/log/settings-restore-$(date +%Y-%m-%d-%H%M%S).log 2>&1")
+
+-- Pyprland (scratchpads, magnify, etc.)
+at_start("pypr > ~/.local/var/log/pypr-$(date +%Y-%m-%d-%H%M%S).log 2>&1")
+at_start("hypr-refocus-scratchpad")
+
+-- Tray apps. wait-for-tray blocks until waybar's systray host is up (a fixed
+-- sleep can't cover a slow cold-start waybar), so these register their icons
+-- instead of opening as windows. Caps at ~30s, then launches anyway.
+at_start("wait-for-tray && signal-desktop --start-in-tray --ozone-platform=wayland")
+-- QT_FONT_DPI bumps the bridge's QML UI font (qt6ct General font is ignored by Qt Quick)
+at_start("env QT_FONT_DPI=108 protonmail-bridge --no-window")
+
+-- ============================================================================
+-- Environment Variables
+-- ============================================================================
+hl.env("XCURSOR_SIZE", "24")
+hl.env("XCURSOR_THEME", "Bibata-Modern-Ice")
+hl.env("XDG_CURRENT_DESKTOP", "Hyprland")
+hl.env("XDG_SESSION_TYPE", "wayland")
+hl.env("XDG_SESSION_DESKTOP", "Hyprland")
+hl.env("_JAVA_AWT_WM_NONREPARENTING", "1")
+
+-- ============================================================================
+-- Appearance (matching DWM colors)
+-- ============================================================================
+-- DWM colors: gray1=#222222, gray2=#444444, gray3=#bbbbbb, gray4=#eeeeee, cyan=#daa520
+
+hl.config({
+ general = {
+ gaps_in = 25,
+ gaps_out = 30,
+ border_size = 2,
+ col = {
+ active_border = "rgba(daa520ff)",
+ inactive_border = "rgba(444444ff)",
+ -- Pyprland 3.4+ applies `group deny` to scratchpads, which routes
+ -- their border through col.nogroup_border* instead of col.*_border.
+ -- Without these overrides Hyprland's defaults paint scratchpads
+ -- bright magenta.
+ nogroup_border_active = "rgba(daa520ff)",
+ nogroup_border = "rgba(444444ff)",
+ },
+ layout = "master",
+ resize_on_border = true,
+ },
+})
+
+hl.config({
+ decoration = {
+ rounding = 10,
+ dim_inactive = true,
+ dim_strength = 0.4,
+ dim_special = 0.2,
+ blur = {
+ enabled = false,
+ },
+ shadow = {
+ enabled = false,
+ },
+ },
+})
+
+hl.config({
+ animations = {
+ enabled = true,
+ },
+})
+
+hl.curve("myBezier", { type = "bezier", points = { { 0.05, 0.9 }, { 0.1, 1.05 } } })
+hl.animation({
+ leaf = "windows",
+ enabled = true,
+ speed = 2,
+ bezier = "myBezier",
+})
+hl.animation({
+ leaf = "windowsOut",
+ enabled = true,
+ speed = 2,
+ bezier = "default",
+ style = "popin 80%",
+})
+hl.animation({
+ leaf = "fade",
+ enabled = true,
+ speed = 2,
+ bezier = "default",
+})
+hl.animation({
+ leaf = "workspaces",
+ enabled = true,
+ speed = 2,
+ bezier = "default",
+})
+hl.animation({
+ leaf = "specialWorkspace",
+ enabled = true,
+ speed = 2,
+ bezier = "default",
+ style = "slidevert",
+})
+
+-- ============================================================================
+-- Layout (master-stack like DWM tile)
+-- ============================================================================
+
+hl.config({
+ master = {
+ new_status = "master",
+ new_on_top = true,
+ mfact = 0.55,
+ },
+})
+
+hl.config({
+ dwindle = {
+ preserve_split = true,
+ },
+})
+
+-- ============================================================================
+-- Input
+-- ============================================================================
+
+hl.config({
+ cursor = {
+ no_warps = true,
+ inactive_timeout = 2.0,
+ },
+})
+
+hl.config({
+ input = {
+ kb_layout = "us",
+ kb_options = "ctrl:nocaps",
+ numlock_by_default = true,
+ follow_mouse = 0,
+ -- 0, not the default 1: with follow_mouse off we never want focus to follow
+ -- the cursor. At 1, focus still jumps to the window under the pointer when it
+ -- crosses a floating<->tiled boundary, so launching a floating scratchpad (or
+ -- the org-capture popup) re-enabled focus-follows-mouse onto tiled windows.
+ float_switch_override_focus = 0,
+ mouse_refocus = false,
+ natural_scroll = true,
+ touchpad = {
+ natural_scroll = false,
+ },
+ },
+})
+
+-- ============================================================================
+-- Misc
+-- ============================================================================
+
+hl.config({
+ misc = {
+ force_default_wallpaper = 0,
+ disable_hyprland_logo = true,
+ -- false so apps can't pull focus via activation requests. New windows still
+ -- focus on open (separate path); this stops e.g. a browser yanking focus
+ -- back off a freshly opened emacs frame.
+ focus_on_activate = false,
+ -- Let a fresh lock client adopt a session whose previous one died. The
+ -- default (off) is the strict reading of ext-session-lock: a dead lock
+ -- client leaves the session locked forever and refuses every replacement
+ -- ("Cannot re-lock"), so the screen stays up with nothing able to draw a
+ -- password prompt and the only way back in is another console. That is a
+ -- hard lockout, and it cost a session on velox 2026-07-22. On means a
+ -- replacement hyprlock re-attaches and prompts normally. The screen stays
+ -- locked either way — this decides whether the lock is recoverable, never
+ -- whether it holds.
+ allow_session_lock_restore = true,
+ },
+})
+
+-- ============================================================================
+-- Debug (temporary - disable when stable)
+-- ============================================================================
+
+hl.config({
+ debug = {
+ disable_logs = false,
+ },
+})
+
+-- ============================================================================
+-- XWayland
+-- ============================================================================
+
+hl.config({
+ xwayland = {
+ force_zero_scaling = true,
+ },
+})
+
+-- ============================================================================
+-- Window Rules (Hyprland 0.53+ syntax: match:CONDITION, RULE)
+-- ============================================================================
+-- Floating windows (from DWM rules)
+hl.window_rule({
+ match = {
+ class = "^(xdg-desktop-portal-gtk)$",
+ },
+ float = true,
+})
+
+hl.window_rule({
+ match = {
+ class = "^(Gimp)$",
+ },
+ float = true,
+})
+
+hl.window_rule({
+ match = {
+ class = "^(caffeine)$",
+ },
+ float = true,
+})
+
+hl.window_rule({
+ match = {
+ class = "^(qalculate-gtk)$",
+ },
+ float = true,
+})
+
+hl.window_rule({
+ match = {
+ title = "^(Event Tester)$",
+ },
+ float = true,
+})
+
+-- net / bluetooth instrument-console panels. Normal floating windows (formerly
+-- gtk4-layer-shell overlays) so they drag to move and corner-drag to resize.
+-- Opened top-right to match their old anchored spot: the panel is right-aligned
+-- with a 44px gap, so x = 100% - (window width + 44). net is 420 wide, bt 380.
+hl.window_rule({
+ match = {
+ class = "^(net\\.cjennings\\.netpanel)$",
+ },
+ float = true,
+})
+
+hl.window_rule({
+ match = {
+ class = "^(net\\.cjennings\\.netpanel)$",
+ },
+ move = "100%-464 50",
+})
+
+hl.window_rule({
+ match = {
+ class = "^(net\\.cjennings\\.btpanel)$",
+ },
+ float = true,
+})
+
+hl.window_rule({
+ match = {
+ class = "^(net\\.cjennings\\.btpanel)$",
+ },
+ move = "100%-424 50",
+})
+
+hl.window_rule({
+ match = {
+ class = "^(net\\.cjennings\\.audiopanel)$",
+ },
+ float = true,
+})
+
+hl.window_rule({
+ match = {
+ class = "^(net\\.cjennings\\.audiopanel)$",
+ },
+ move = "100%-444 50",
+})
+
+hl.window_rule({
+ match = {
+ class = "^(net\\.cjennings\\.timerpanel)$",
+ },
+ float = true,
+})
+
+hl.window_rule({
+ match = {
+ class = "^(net\\.cjennings\\.timerpanel)$",
+ },
+ move = "100%-444 50",
+})
+
+hl.window_rule({
+ match = {
+ class = "^(net\\.cjennings\\.settingspanel)$",
+ },
+ float = true,
+})
+
+hl.window_rule({
+ match = {
+ class = "^(net\\.cjennings\\.settingspanel)$",
+ },
+ move = "100%-584 50",
+})
+
+hl.window_rule({
+ match = {
+ class = "^(net\\.cjennings\\.weatherpanel)$",
+ },
+ float = true,
+})
+
+hl.window_rule({
+ match = {
+ class = "^(net\\.cjennings\\.weatherpanel)$",
+ },
+ move = "100%-464 50",
+})
+
+-- maintenance console: the wide board (960), same right-aligned convention.
+hl.window_rule({
+ match = {
+ class = "^(net\\.cjennings\\.maintpanel)$",
+ },
+ float = true,
+})
+
+hl.window_rule({
+ match = {
+ class = "^(net\\.cjennings\\.maintpanel)$",
+ },
+ move = "100%-1004 50",
+})
+
+-- org-capture popup frame (quick-capture script names the frame)
+-- Size is per-host in <host>/conf.d/local.lua: native window rules ignore
+-- percentages (only pyprland honors them), so the popup is sized in absolute
+-- pixels matching that host's terminal scratchpad. No size rule here means a
+-- host without an override falls back to the script's char-cell geometry.
+hl.window_rule({
+ match = {
+ title = "^(org-capture)$",
+ },
+ float = true,
+})
+
+hl.window_rule({
+ match = {
+ title = "^(org-capture)$",
+ },
+ center = true,
+})
+
+-- dirvish popup frame (dirvish-popup script names the frame). No stay_focused —
+-- it's a file manager that launches files into other apps, so focus must be free
+-- to follow; q (cj/dirvish-popup-quit) closes the frame.
+hl.window_rule({
+ match = {
+ title = "^(dirvish)$",
+ },
+ float = true,
+})
+
+hl.window_rule({
+ match = {
+ title = "^(dirvish)$",
+ },
+ size = "1100 700",
+})
+
+hl.window_rule({
+ match = {
+ title = "^(dirvish)$",
+ },
+ center = true,
+})
+
+-- NOTE: center windowrules removed 2026-03-04 per pyprland maintainer suggestion
+-- Testing whether pyprland handles scratchpad re-centering natively (issue #211)
+
+-- Gaming
+hl.window_rule({
+ match = {
+ class = "^(Civ5XP)$",
+ },
+ fullscreen = true,
+})
+
+-- ============================================================================
+-- Key Bindings
+-- ============================================================================
+local mod = "SUPER"
+
+-- Terminal and core apps (from DWM)
+hl.bind(mod .. " + T", hl.dsp.exec_cmd("foot"))
+hl.bind(mod .. " + E", hl.dsp.exec_cmd("emacsclient -c -a \"\" || emacs"))
+-- Standalone emacs: its own process, not a frame on the daemon, so killing it
+-- takes no other frame with it. init.el guards server-start on server-running-p,
+-- so while the daemon holds the socket this process leaves it alone. With no
+-- daemon up it finds no server and becomes one -- the guard lives in init.el, and
+-- a keybind can't override it, since --eval runs after init.
+hl.bind(mod .. " + SHIFT + E", hl.dsp.exec_cmd("emacs"))
+hl.bind(mod .. " + N", hl.dsp.exec_cmd("quick-capture"))
+hl.bind(mod .. " + W", hl.dsp.exec_cmd("$BROWSER"))
+hl.bind(mod .. " + F", hl.dsp.exec_cmd("dirvish-popup"))
+hl.bind(mod .. " + SHIFT + F", hl.dsp.exec_cmd("layout-cycle float-toggle"))
+
+-- From sxhkdrc
+hl.bind(mod .. " + SPACE", hl.dsp.exec_cmd("fuzzel-toggle"))
+hl.bind(mod .. " + SHIFT + W", hl.dsp.exec_cmd("$ALTBROWSER"))
+hl.bind(mod .. " + P", hl.dsp.exec_cmd("media-toggle-all"))
+hl.bind(mod .. " + SHIFT + L", hl.dsp.exec_cmd("calibre"))
+hl.bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("toggle-touchpad"))
+
+-- Window management (from DWM)
+-- Layout-aware navigation (works across master, scrolling)
+hl.bind(mod .. " + J", hl.dsp.exec_cmd("layout-navigate next"))
+hl.bind(mod .. " + K", hl.dsp.exec_cmd("layout-navigate prev"))
+hl.bind(mod .. " + SHIFT + J", hl.dsp.exec_cmd("layout-navigate next move"))
+hl.bind(mod .. " + SHIFT + K", hl.dsp.exec_cmd("layout-navigate prev move"))
+hl.bind(mod .. " + H", hl.dsp.exec_cmd("layout-resize shrink"))
+hl.bind(mod .. " + L", hl.dsp.exec_cmd("layout-resize grow"))
+-- Swap focused window with master, then force focus onto the master slot.
+-- swapwithmaster's own `master` focus param doesn't stick when invoked from
+-- the master, so focusmaster master pins focus afterward. The 50ms sleep is
+-- load-bearing: swapwithmaster fires an async focus event ~1-2ms after it
+-- returns; without the delay that event lands AFTER focusmaster and flips
+-- focus back to the detail. The sleep lets the swap's focus settle so
+-- focusmaster runs last and wins. Proven via instrumented capture (19/19).
+hl.bind(mod .. " + RETURN", hl.dsp.exec_cmd("hyprctl dispatch layoutmsg swapwithmaster && sleep 0.05 && hyprctl dispatch layoutmsg focusmaster master"))
+hl.bind(mod .. " + G", hl.dsp.window.center())
+hl.bind(mod .. " + TAB", hl.dsp.focus({ workspace = "previous" }))
+hl.bind(mod .. " + SHIFT + C", hl.dsp.window.close())
+
+-- Layouts: master -> monocle
+-- Cycle with Shift+arrows, or jump directly with Shift+T/M
+-- (scrolling layout disabled until frame-fit + wrap-around work lands)
+hl.bind(mod .. " + SHIFT + RIGHT", hl.dsp.exec_cmd("layout-cycle next"))
+hl.bind(mod .. " + SHIFT + LEFT", hl.dsp.exec_cmd("layout-cycle prev"))
+hl.bind(mod .. " + SHIFT + T", hl.dsp.exec_cmd("hyprctl keyword general:layout master && hyprctl keyword master:orientation left"))
+hl.bind(mod .. " + SHIFT + M", hl.dsp.exec_cmd("hyprctl keyword general:layout monocle"))
+hl.bind(mod .. " + SHIFT + SPACE", hl.dsp.window.float({ action = "toggle" }))
+
+-- Master layout adjustments
+hl.bind(mod .. " + U", hl.dsp.layout("addmaster"))
+hl.bind(mod .. " + D", hl.dsp.layout("removemaster"))
+
+-- Stash windows (hide to special workspace)
+-- O = stash focused / Alt+O = stash others / Shift+O = restore all
+hl.bind(mod .. " + O", hl.dsp.exec_cmd("stash-window"))
+hl.bind(mod .. " + ALT + O", hl.dsp.exec_cmd("stash-others"))
+hl.bind(mod .. " + SHIFT + O", hl.dsp.exec_cmd("stash-restore"))
+
+-- Gaps between windows only; window-gaps leaves the monitor-edge gap
+-- (general:gaps_out) fixed, so widening/narrowing moves the space between
+-- windows, not the screen-edge margin.
+hl.bind(mod .. " + MINUS", hl.dsp.exec_cmd("window-gaps narrow"))
+hl.bind(mod .. " + EQUAL", hl.dsp.exec_cmd("window-gaps widen"))
+hl.bind(mod .. " + SHIFT + EQUAL", hl.dsp.exec_cmd("window-gaps reset"))
+hl.bind(mod .. " + SHIFT + MINUS", hl.dsp.exec_cmd("window-gaps zero"))
+
+-- Auto-dim toggle (D = dim). Same action as clicking the waybar custom/dim icon.
+hl.bind(mod .. " + SHIFT + D", hl.dsp.exec_cmd("dim-toggle"))
+hl.bind(mod .. " + SHIFT + G", hl.dsp.exec_cmd("settings-panel"))
+
+-- Caffeine (keep-awake) toggle. Same action as clicking the waybar
+-- custom/caffeine icon — flips the hypridle daemon so the screen will / won't
+-- lock. Stays on $mod+I ($mod+C is taken by hyprpicker; no free caffeine key).
+hl.bind(mod .. " + I", hl.dsp.exec_cmd("caffeine-toggle"))
+
+-- Airplane mode (low-power: wifi off + CPU/brightness/services). A deliberate
+-- keybind, not a bar click — engaging it disconnects you, so it shouldn't be a
+-- misclick away. The custom/net module shows the state; this toggles it.
+-- On Super+Shift+X ("X" = everything off); Super+Shift+A toggles push-to-talk.
+hl.bind(mod .. " + SHIFT + X", hl.dsp.exec_cmd("airplane-mode"))
+
+-- Toggle bar visibility, or relaunch waybar if it crashed (no exec-once respawn).
+hl.bind(mod .. " + B", hl.dsp.exec_cmd("waybar-toggle"))
+
+-- Collapse / expand the left or right side of the bar to its base set
+-- (same action as clicking the side's arrowhead). [ = left, ] = right.
+hl.bind(mod .. " + bracketleft", hl.dsp.exec_cmd("waybar-collapse left"))
+hl.bind(mod .. " + bracketright", hl.dsp.exec_cmd("waybar-collapse right"))
+
+-- Fullscreen
+hl.bind(mod .. " + F11", hl.dsp.window.fullscreen({ mode = "fullscreen", action = "toggle" }))
+
+-- Workspaces 1-9 (from DWM TAGKEYS)
+hl.bind(mod .. " + 1", hl.dsp.focus({ workspace = 1 }))
+hl.bind(mod .. " + 2", hl.dsp.focus({ workspace = 2 }))
+hl.bind(mod .. " + 3", hl.dsp.focus({ workspace = 3 }))
+hl.bind(mod .. " + 4", hl.dsp.focus({ workspace = 4 }))
+hl.bind(mod .. " + 5", hl.dsp.focus({ workspace = 5 }))
+hl.bind(mod .. " + 6", hl.dsp.focus({ workspace = 6 }))
+hl.bind(mod .. " + 7", hl.dsp.focus({ workspace = 7 }))
+hl.bind(mod .. " + 8", hl.dsp.focus({ workspace = 8 }))
+hl.bind(mod .. " + 9", hl.dsp.focus({ workspace = 9 }))
+
+-- Move window to workspace (from DWM tag)
+hl.bind(mod .. " + SHIFT + 1", hl.dsp.window.move({ workspace = 1, follow = false }))
+hl.bind(mod .. " + SHIFT + 2", hl.dsp.window.move({ workspace = 2, follow = false }))
+hl.bind(mod .. " + SHIFT + 3", hl.dsp.window.move({ workspace = 3, follow = false }))
+hl.bind(mod .. " + SHIFT + 4", hl.dsp.window.move({ workspace = 4, follow = false }))
+hl.bind(mod .. " + SHIFT + 5", hl.dsp.window.move({ workspace = 5, follow = false }))
+hl.bind(mod .. " + SHIFT + 6", hl.dsp.window.move({ workspace = 6, follow = false }))
+hl.bind(mod .. " + SHIFT + 7", hl.dsp.window.move({ workspace = 7, follow = false }))
+hl.bind(mod .. " + SHIFT + 8", hl.dsp.window.move({ workspace = 8, follow = false }))
+hl.bind(mod .. " + SHIFT + 9", hl.dsp.window.move({ workspace = 9, follow = false }))
+
+-- Monitor focus (from DWM focusmon)
+hl.bind(mod .. " + COMMA", hl.dsp.focus({ monitor = "-1" }))
+hl.bind(mod .. " + PERIOD", hl.dsp.focus({ monitor = "+1" }))
+hl.bind(mod .. " + SHIFT + COMMA", hl.dsp.window.move({ monitor = "-1" }))
+hl.bind(mod .. " + SHIFT + PERIOD", hl.dsp.window.move({ monitor = "+1" }))
+
+-- ============================================================================
+-- Scratchpads (via pyprland)
+-- ============================================================================
+-- Configured in ~/.config/hypr/pyprland.toml
+-- Uses normal workspaces (not special), so new windows won't be captured
+hl.bind(mod .. " + SHIFT + RETURN", hl.dsp.exec_cmd("pypr toggle term"))
+hl.bind(mod .. " + A", hl.dsp.exec_cmd("audio-panel"))
+hl.bind(mod .. " + R", hl.dsp.exec_cmd("pypr toggle monitor"))
+hl.bind(mod .. " + SHIFT + N", hl.dsp.exec_cmd("net panel"))
+hl.bind(mod .. " + SLASH", hl.dsp.exec_cmd("pypr toggle music"))
+
+-- Magnify (zoom)
+-- mod+Z zooms and enters the "zoom" submap; inside it, Escape or mod+Z
+-- unzooms and returns to the normal keymap. Exit forces `pypr zoom 1`
+-- (factor 1) so submap state and zoom state can't desync. Note: while
+-- zoomed, other Hyprland binds pause until you exit the submap.
+hl.bind(mod .. " + Z", hl.dsp.exec_cmd("pypr zoom"))
+hl.bind(mod .. " + Z", hl.dsp.submap("zoom"))
+
+hl.define_submap("zoom", function()
+ hl.bind("ESCAPE", hl.dsp.exec_cmd("pypr zoom 1"))
+ hl.bind("ESCAPE", hl.dsp.submap("reset"))
+ hl.bind(mod .. " + Z", hl.dsp.exec_cmd("pypr zoom 1"))
+ hl.bind(mod .. " + Z", hl.dsp.submap("reset"))
+end)
+
+-- Calculator (not a scratchpad, just launches app)
+hl.bind(mod .. " + X", hl.dsp.exec_cmd("calc-toggle"))
+hl.bind(mod .. " + C", hl.dsp.exec_cmd("hyprpicker -a"))
+hl.bind(mod .. " + CONTROL + C", hl.dsp.exec_cmd("clock-panel toggle"))
+
+-- Media/hardware keys
+hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("pactl set-sink-volume @DEFAULT_SINK@ +5%"), { locked = true, repeating = true })
+hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("pactl set-sink-volume @DEFAULT_SINK@ -5%"), { locked = true, repeating = true })
+hl.bind("XF86AudioMute", hl.dsp.exec_cmd("audio quick-mute"), { locked = true })
+hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl s +10%"), { locked = true, repeating = true })
+hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl s 10%-"), { locked = true, repeating = true })
+
+-- Microphone mute toggle (waybar pulseaudio#mic indicator follows via PipeWire events).
+-- On the hardware mic-mute key. Super+Shift+A used to duplicate this; it now
+-- toggles push-to-talk mode instead (mic-toggle stays reachable on the hw key).
+hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("mic-toggle"), { locked = true })
+
+-- Push-to-talk toggle: enter PTT (mic muted, hold key armed) / exit (restore).
+-- The hold key is configurable (audio config ptt_key = Control_R or mouse:NNN).
+hl.bind(mod .. " + SHIFT + A", hl.dsp.exec_cmd("audio ptt-toggle"))
+
+-- Bluetooth panel (blueman retired in favor of the bt panel)
+hl.bind(mod .. " + SHIFT + B", hl.dsp.exec_cmd("bt-panel"))
+
+-- Screenshots (grim + slurp + fuzzel menu)
+-- Shift+S captures the whole desktop with no pointer interaction, so it
+-- works on scratchpads and popups that region-select would dismiss
+hl.bind(mod .. " + S", hl.dsp.exec_cmd("screenshot region"))
+hl.bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd("screenshot fullscreen"))
+hl.bind("CTRL + " .. mod .. " + S", hl.dsp.exec_cmd("screenshot fullscreen"))
+
+-- Lock screen
+hl.bind(mod .. " + ESCAPE", hl.dsp.exec_cmd("hyprlock"))
+
+-- Audio mute cycle (M for Mute): one key walks volume/mic through all four
+-- on/off combinations. The waybar pulseaudio modules follow via PipeWire.
+hl.bind(mod .. " + M", hl.dsp.exec_cmd("audio-cycle"))
+
+-- Exit/session
+hl.bind(mod .. " + SHIFT + Q", hl.dsp.exec_cmd("pgrep -x wlogout || wlogout-menu"))
+-- mod+Shift+Backspace no longer exits outright — it enters the "exitconfirm"
+-- submap, where a second Backspace confirms and anything else backs out.
+-- The bare bind killed two live sessions on 2026-07-22: it sits one key from
+-- the mod+Shift cluster (Q wlogout, C killactive, Return terminal, G settings)
+-- and took the whole session down with no prompt. wlogout owns the normal
+-- exit path; this stays as the keyboard escape hatch for when wlogout won't
+-- come up, so it's gated rather than removed.
+hl.bind(mod .. " + SHIFT + BACKSPACE", hl.dsp.submap("exitconfirm"))
+
+hl.define_submap("exitconfirm", function()
+ hl.bind("BACKSPACE", hl.dsp.exit())
+ hl.bind("ESCAPE", hl.dsp.submap("reset"))
+ hl.bind("catchall", hl.dsp.submap("reset"))
+end)
+
+hl.bind(mod .. " + SHIFT + ESCAPE", hl.dsp.exec_cmd("hyprctl reload"))
+hl.bind("CTRL + ALT + " .. mod .. " + K", hl.dsp.exec_cmd("hyprctl kill"))
+
+-- Mouse bindings (from DWM buttons)
+hl.bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true })
+hl.bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true })
+hl.bind(mod .. " + SHIFT + mouse:272", hl.dsp.window.resize(), { mouse = true })
+
+-- ============================================================================
+-- Machine-local overrides
+-- ============================================================================
+-- Sourced last so machine-specific settings (monitor scale, gaps, keybinds)
+-- override the defaults above. See conf.d/local.lua.
+
+-- Replay the collected autostart commands.
+hl.on("hyprland.start", function()
+ for _, cmd in ipairs(autostart) do hl.exec_cmd(cmd) end
+end)
+
+hl.on("hyprland.shutdown", function()
+ for _, cmd in ipairs(atshutdown) do hl.exec_cmd(cmd) end
+end)
+
+-- hyprlang's `exec` ran at startup AND on every reload. config.reloaded fires on
+-- the initial load too (verified in a nested Hyprland 0.56.2 on 2026-08-24: a
+-- marker written from this handler appears at session start), so this single
+-- handler covers both cases, which is what waybar-reserve needs.
+hl.on("config.reloaded", function()
+ for _, cmd in ipairs(atreload) do hl.exec_cmd(cmd) end
+end)
+
+hl_source_glob("$HOME/.config/hypr/conf.d/*.lua")
diff --git a/working/hyprland-lua-port/hyprland.lua.generated b/working/hyprland-lua-port/hyprland.lua.generated
new file mode 100644
index 0000000..04b30b9
--- /dev/null
+++ b/working/hyprland-lua-port/hyprland.lua.generated
@@ -0,0 +1,674 @@
+-- Hyprland Configuration
+-- Translated from DWM config.def.h and sxhkdrc
+-- Craig Jennings <c@cjennings.net>
+
+-- ============================================================================
+-- Monitor Configuration
+-- ============================================================================
+-- Generated by hyprlang2lua. Review TODOs before reloading Hyprland.
+
+-- hyprlang2lua polyfills — runtime helpers reproducing
+-- hyprlang behaviour the typed Lua API doesn't expose directly.
+
+local function hl_source_glob(pattern)
+ -- 'source = path/*.conf' had hyprlang glob and inline-expand the
+ -- matches. require() can't glob, so we shell out to ls (matching
+ -- the user's brace-expansion behaviour) and dofile each result.
+ -- Paths with spaces or shell metacharacters in the directory
+ -- portion will misparse; typical ~/.config/hypr/ layouts don't
+ -- hit this. Swap to lfs.dir() or find -name if you need fancier.
+ local p = io.popen("ls " .. pattern .. " 2>/dev/null")
+ if not p then return end
+ for f in p:lines() do
+ local chunk, err = loadfile(f)
+ if chunk then chunk()
+ else io.stderr:write("hl_source_glob: " .. tostring(err) .. "\n") end
+ end
+ p:close()
+end
+
+hl.monitor({
+ output = "",
+ mode = "preferred",
+ position = "auto",
+ scale = "auto",
+})
+
+-- Waybar's strip (6px top margin + 54px bar) is reserved statically by
+-- waybar-reserve, and waybar runs with "exclusive": false. The bar's own
+-- exclusive zone would vanish and reappear on every SIGUSR2 reload (the
+-- collapse mechanism) and on hide/crash/relaunch, snapping every tiled window
+-- up and back down. The static reservation holds the clients in place; only
+-- the bar itself changes. `exec` (not exec-once) reruns it on every config
+-- reload, which is exactly when Hyprland resets dynamic reservations. The
+-- script is idempotent, and a catch-all `monitor=,addreserved,...` rule can't
+-- replace it (empty-name addreserved silently no-ops).
+--
+-- Run three times over ~0.6s, not once: on reload Hyprland clears the
+-- reservation AND re-fires this exec, and the two race. A single run that
+-- fires before the clear no-ops (reserved still looks correct), the clear then
+-- wins, and the non-exclusive bar drops off-screen. Re-applying past the clear
+-- window makes the restore reliable; the script is idempotent so extra runs are
+-- free. Applying a monitor rule (e.g. the DP-4 pin) also clears the reservation,
+-- so this covers a reload that re-asserts monitors too.
+
+-- ============================================================================
+-- Startup Applications
+-- ============================================================================
+-- Portal and D-Bus setup FIRST, then waybar (needs portal for appearance query)
+-- Start hyprland-session.target FIRST: it pulls up graphical-session.target,
+-- which xdg-desktop-portal 1.22+ hard-requires (Requisite=). A bare-exec Hyprland
+-- session has no session manager to raise that target, so without this the portal
+-- fails its dependency at every login (screen-share + file pickers dead).
+-- 'systemctl start' blocks until active, so the ';' sequence guarantees the target
+-- is up before the portal restart runs.
+--
+-- Portal restart (not start) reconnects stale portals on Hyprland restart.
+-- Backend portals (GTK, Hyprland) restart BEFORE the main portal to avoid a 50s
+-- GTK settings proxy timeout; the sequence keeps that ordering. Separated by ';'
+-- not '&&' so a failing portal restart can't stop waybar from launching — waybar
+-- degrades gracefully without the portal (only the appearance query is missed),
+-- and gating the bar behind the portal left the desktop bar-less whenever
+-- xdg-desktop-portal failed its dependency at login. Waybar stays gated on its
+-- own config generation (waybar-active-config && waybar).
+
+-- Core services
+
+-- Desktop appearance
+-- `settings restore` replays the remembered toggles and reapplies the stored
+-- wallpaper. It replaced `waypaper --restore` on 2026-08-14: waypaper keeps
+-- its own config.ini and the settings store keeps another, neither knew about
+-- the other, and the login replay always won — so a wallpaper chosen in the
+-- panel came back as whatever the shell had last set. The store is the only
+-- one of the two that can hold a sun pair, a video or a projected face, so it
+-- owns the restore. set-wallpaper records into it for choices made outside
+-- the panel.
+--
+-- waypaper --restore stays as the fallback, not the owner. If the stored
+-- wallpaper cannot be applied (an image deleted, a drive not mounted yet),
+-- `settings restore` exits 3 and waypaper's independent copy still puts
+-- something on the screen. Dropping it outright would trade this bug for a
+-- bare desktop.
+--
+-- The wallpaper half only. The toggle half runs from its own exec-once further
+-- down, after hypridle and dunst — caffeine *is* "hypridle isn't running" and
+-- DND *is* dunst's pause level, so replaying them here would spend the whole
+-- re-assert budget correcting backings that have not launched yet, and would
+-- replay them a second time besides. This slot exists for awww's timing, not
+-- theirs.
+
+-- Background services
+-- hypridle is reaped on both exit paths, because it outlives its compositor
+-- otherwise. An orphaned daemon keeps firing idle actions at whatever session
+-- is live next, and it holds its old logind session scope open (the scope can't
+-- close while a process sits in it), so orphans accumulate one per abnormal
+-- session death. On 2026-07-22 velox reached five concurrent hypridle daemons;
+-- two of them racing to lock produced "Cannot re-lock" and a session wedged
+-- locked with no client able to draw a password prompt — recoverable only from
+-- another console. exec-shutdown covers a clean compositor exit; the pkill in
+-- exec-once covers the paths where it never runs (crash, SIGKILL, TTY logout).
+hl.on("hyprland.shutdown", function()
+ hl.exec_cmd("pkill -x hypridle")
+end)
+
+-- hypridle.conf is rendered here rather than tracked, because its contents
+-- are this machine's stage times and hibernate setting. Tracking the render
+-- meant every panel change dirtied the repo, and whichever machine
+-- committed last imposed its policy on the others: a desktop ended up
+-- carrying a laptop's suspend-then-hibernate line that it cannot run.
+-- Rendering at session start makes the store the only source of truth and
+-- the file a build artifact.
+--
+-- hypridle-start owns the render, the fallback, and the ordering between
+-- them, because that ordering is subtle enough to get wrong in a config
+-- line nothing can test: a render can fail on purpose (a damaged store, to
+-- avoid overwriting a real policy with defaults), and a fallback that
+-- fired there would perform exactly the overwrite the render refused.
+-- Replay the toggles that have no durable state of their own. Caffeine *is*
+-- "hypridle isn't running" and DND *is* dunst's pause level, so the two
+-- exec-once lines above (and dunst's) recreate both at a fixed default every
+-- start — a deliberately-set caffeine was silently discarded on every login.
+-- Ordered after those launches so it corrects a backing that exists; it also
+-- re-asserts for a few seconds, which covers a backing that comes up late.
+-- Logged like its neighbours: a silent exec-once failure here would look
+-- exactly like the bug it fixes, and gammastep is the standing proof that a
+-- launch dying quietly at session start can go unnoticed for a long time.
+
+-- Pyprland (scratchpads, magnify, etc.)
+
+-- Tray apps. wait-for-tray blocks until waybar's systray host is up (a fixed
+-- sleep can't cover a slow cold-start waybar), so these register their icons
+-- instead of opening as windows. Caps at ~30s, then launches anyway.
+-- QT_FONT_DPI bumps the bridge's QML UI font (qt6ct General font is ignored by Qt Quick)
+
+-- ============================================================================
+-- Environment Variables
+-- ============================================================================
+hl.env("XCURSOR_SIZE", "24")
+hl.env("XCURSOR_THEME", "Bibata-Modern-Ice")
+hl.env("XDG_CURRENT_DESKTOP", "Hyprland")
+hl.env("XDG_SESSION_TYPE", "wayland")
+hl.env("XDG_SESSION_DESKTOP", "Hyprland")
+hl.env("_JAVA_AWT_WM_NONREPARENTING", "1")
+
+-- ============================================================================
+-- Appearance (matching DWM colors)
+-- ============================================================================
+-- DWM colors: gray1=#222222, gray2=#444444, gray3=#bbbbbb, gray4=#eeeeee, cyan=#daa520
+
+hl.curve("myBezier", { type = "bezier", points = { { 0.05, 0.9 }, { 0.1, 1.05 } } })
+hl.animation({
+ leaf = "windows",
+ enabled = true,
+ speed = 2,
+ bezier = "myBezier",
+})
+hl.animation({
+ leaf = "windowsOut",
+ enabled = true,
+ speed = 2,
+ bezier = "default",
+ style = "popin 80%",
+})
+hl.animation({
+ leaf = "fade",
+ enabled = true,
+ speed = 2,
+ bezier = "default",
+})
+hl.animation({
+ leaf = "workspaces",
+ enabled = true,
+ speed = 2,
+ bezier = "default",
+})
+hl.animation({
+ leaf = "specialWorkspace",
+ enabled = true,
+ speed = 2,
+ bezier = "default",
+ style = "slidevert",
+})
+
+hl.window_rule({
+ match = {
+ class = "^(xdg-desktop-portal-gtk)$",
+ },
+ float = true,
+})
+
+hl.window_rule({
+ match = {
+ class = "^(Gimp)$",
+ },
+ float = true,
+})
+
+hl.window_rule({
+ match = {
+ class = "^(caffeine)$",
+ },
+ float = true,
+})
+
+hl.window_rule({
+ match = {
+ class = "^(qalculate-gtk)$",
+ },
+ float = true,
+})
+
+hl.window_rule({
+ match = {
+ title = "^(Event Tester)$",
+ },
+ float = true,
+})
+
+hl.window_rule({
+ match = {
+ class = "^(net\\.cjennings\\.netpanel)$",
+ },
+ float = true,
+ move = "100%-464 50",
+})
+
+hl.window_rule({
+ match = {
+ class = "^(net\\.cjennings\\.btpanel)$",
+ },
+ float = true,
+ move = "100%-424 50",
+})
+
+hl.window_rule({
+ match = {
+ class = "^(net\\.cjennings\\.audiopanel)$",
+ },
+ float = true,
+ move = "100%-444 50",
+})
+
+hl.window_rule({
+ match = {
+ class = "^(net\\.cjennings\\.timerpanel)$",
+ },
+ float = true,
+ move = "100%-444 50",
+})
+
+hl.window_rule({
+ match = {
+ class = "^(net\\.cjennings\\.settingspanel)$",
+ },
+ float = true,
+ move = "100%-584 50",
+})
+
+hl.window_rule({
+ match = {
+ class = "^(net\\.cjennings\\.weatherpanel)$",
+ },
+ float = true,
+ move = "100%-464 50",
+})
+
+hl.window_rule({
+ match = {
+ class = "^(net\\.cjennings\\.maintpanel)$",
+ },
+ float = true,
+ move = "100%-1004 50",
+})
+
+hl.window_rule({
+ match = {
+ title = "^(org-capture)$",
+ },
+ float = true,
+ center = true,
+})
+
+hl.window_rule({
+ match = {
+ title = "^(dirvish)$",
+ },
+ float = true,
+ size = "1100 700",
+ center = true,
+})
+
+hl.window_rule({
+ match = {
+ class = "^(Civ5XP)$",
+ },
+ fullscreen = true,
+})
+
+local mod = "SUPER"
+
+hl.bind(mod .. " + T", hl.dsp.exec_cmd("foot"))
+hl.bind(mod .. " + E", hl.dsp.exec_cmd("emacsclient -c -a \"\" || emacs"))
+hl.bind(mod .. " + SHIFT + E", hl.dsp.exec_cmd("emacs"))
+hl.bind(mod .. " + N", hl.dsp.exec_cmd("quick-capture"))
+hl.bind(mod .. " + W", hl.dsp.exec_cmd("$BROWSER"))
+hl.bind(mod .. " + F", hl.dsp.exec_cmd("dirvish-popup"))
+hl.bind(mod .. " + SHIFT + F", hl.dsp.exec_cmd("layout-cycle float-toggle"))
+
+hl.bind(mod .. " + SPACE", hl.dsp.exec_cmd("fuzzel-toggle"))
+hl.bind(mod .. " + SHIFT + W", hl.dsp.exec_cmd("$ALTBROWSER"))
+hl.bind(mod .. " + P", hl.dsp.exec_cmd("media-toggle-all"))
+hl.bind(mod .. " + SHIFT + L", hl.dsp.exec_cmd("calibre"))
+hl.bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("toggle-touchpad"))
+
+hl.bind(mod .. " + J", hl.dsp.exec_cmd("layout-navigate next"))
+hl.bind(mod .. " + K", hl.dsp.exec_cmd("layout-navigate prev"))
+hl.bind(mod .. " + SHIFT + J", hl.dsp.exec_cmd("layout-navigate next move"))
+hl.bind(mod .. " + SHIFT + K", hl.dsp.exec_cmd("layout-navigate prev move"))
+hl.bind(mod .. " + H", hl.dsp.exec_cmd("layout-resize shrink"))
+hl.bind(mod .. " + L", hl.dsp.exec_cmd("layout-resize grow"))
+hl.bind(mod .. " + RETURN", hl.dsp.layout("swapwithmaster && sleep 0.05 && hyprctl dispatch layoutmsg focusmaster master"))
+hl.bind(mod .. " + G", hl.dsp.window.center())
+hl.bind(mod .. " + TAB", hl.dsp.focus({ workspace = "previous" }))
+hl.bind(mod .. " + SHIFT + C", hl.dsp.window.close())
+
+hl.bind(mod .. " + SHIFT + RIGHT", hl.dsp.exec_cmd("layout-cycle next"))
+hl.bind(mod .. " + SHIFT + LEFT", hl.dsp.exec_cmd("layout-cycle prev"))
+hl.bind(mod .. " + SHIFT + T", hl.dsp.exec_cmd("hyprctl keyword general:layout master && hyprctl keyword master:orientation left"))
+hl.bind(mod .. " + SHIFT + M", hl.dsp.exec_cmd("hyprctl keyword general:layout monocle"))
+hl.bind(mod .. " + SHIFT + SPACE", hl.dsp.window.float({ action = "toggle" }))
+
+hl.bind(mod .. " + U", hl.dsp.layout("addmaster"))
+hl.bind(mod .. " + D", hl.dsp.layout("removemaster"))
+
+hl.bind(mod .. " + O", hl.dsp.exec_cmd("stash-window"))
+hl.bind(mod .. " + ALT + O", hl.dsp.exec_cmd("stash-others"))
+hl.bind(mod .. " + SHIFT + O", hl.dsp.exec_cmd("stash-restore"))
+
+hl.bind(mod .. " + MINUS", hl.dsp.exec_cmd("window-gaps narrow"))
+hl.bind(mod .. " + EQUAL", hl.dsp.exec_cmd("window-gaps widen"))
+hl.bind(mod .. " + SHIFT + EQUAL", hl.dsp.exec_cmd("window-gaps reset"))
+hl.bind(mod .. " + SHIFT + MINUS", hl.dsp.exec_cmd("window-gaps zero"))
+
+hl.bind(mod .. " + SHIFT + D", hl.dsp.exec_cmd("dim-toggle"))
+hl.bind(mod .. " + SHIFT + G", hl.dsp.exec_cmd("settings-panel"))
+
+hl.bind(mod .. " + I", hl.dsp.exec_cmd("caffeine-toggle"))
+
+hl.bind(mod .. " + SHIFT + X", hl.dsp.exec_cmd("airplane-mode"))
+
+hl.bind(mod .. " + B", hl.dsp.exec_cmd("waybar-toggle"))
+
+hl.bind(mod .. " + bracketleft", hl.dsp.exec_cmd("waybar-collapse left"))
+hl.bind(mod .. " + bracketright", hl.dsp.exec_cmd("waybar-collapse right"))
+
+hl.bind(mod .. " + F11", hl.dsp.window.fullscreen({ mode = "fullscreen", action = "toggle" }))
+
+hl.bind(mod .. " + 1", hl.dsp.focus({ workspace = 1 }))
+hl.bind(mod .. " + 2", hl.dsp.focus({ workspace = 2 }))
+hl.bind(mod .. " + 3", hl.dsp.focus({ workspace = 3 }))
+hl.bind(mod .. " + 4", hl.dsp.focus({ workspace = 4 }))
+hl.bind(mod .. " + 5", hl.dsp.focus({ workspace = 5 }))
+hl.bind(mod .. " + 6", hl.dsp.focus({ workspace = 6 }))
+hl.bind(mod .. " + 7", hl.dsp.focus({ workspace = 7 }))
+hl.bind(mod .. " + 8", hl.dsp.focus({ workspace = 8 }))
+hl.bind(mod .. " + 9", hl.dsp.focus({ workspace = 9 }))
+
+hl.bind(mod .. " + SHIFT + 1", hl.dsp.window.move({ workspace = 1, follow = false }))
+hl.bind(mod .. " + SHIFT + 2", hl.dsp.window.move({ workspace = 2, follow = false }))
+hl.bind(mod .. " + SHIFT + 3", hl.dsp.window.move({ workspace = 3, follow = false }))
+hl.bind(mod .. " + SHIFT + 4", hl.dsp.window.move({ workspace = 4, follow = false }))
+hl.bind(mod .. " + SHIFT + 5", hl.dsp.window.move({ workspace = 5, follow = false }))
+hl.bind(mod .. " + SHIFT + 6", hl.dsp.window.move({ workspace = 6, follow = false }))
+hl.bind(mod .. " + SHIFT + 7", hl.dsp.window.move({ workspace = 7, follow = false }))
+hl.bind(mod .. " + SHIFT + 8", hl.dsp.window.move({ workspace = 8, follow = false }))
+hl.bind(mod .. " + SHIFT + 9", hl.dsp.window.move({ workspace = 9, follow = false }))
+
+hl.bind(mod .. " + COMMA", hl.dsp.focus({ monitor = -1 }))
+hl.bind(mod .. " + PERIOD", hl.dsp.focus({ monitor = "+1" }))
+hl.bind(mod .. " + SHIFT + COMMA", hl.dsp.window.move({ monitor = "-1" }))
+hl.bind(mod .. " + SHIFT + PERIOD", hl.dsp.window.move({ monitor = "+1" }))
+
+hl.bind(mod .. " + SHIFT + RETURN", hl.dsp.exec_cmd("pypr toggle term"))
+hl.bind(mod .. " + A", hl.dsp.exec_cmd("audio-panel"))
+hl.bind(mod .. " + R", hl.dsp.exec_cmd("pypr toggle monitor"))
+hl.bind(mod .. " + SHIFT + N", hl.dsp.exec_cmd("net panel"))
+hl.bind(mod .. " + SLASH", hl.dsp.exec_cmd("pypr toggle music"))
+
+hl.bind(mod .. " + Z", hl.dsp.exec_cmd("pypr zoom"))
+hl.bind(mod .. " + Z", hl.dsp.submap("zoom"))
+
+hl.define_submap("zoom", function()
+ hl.bind("ESCAPE", hl.dsp.exec_cmd("pypr zoom 1"))
+ hl.bind("ESCAPE", hl.dsp.submap("reset"))
+ hl.bind(mod .. " + Z", hl.dsp.exec_cmd("pypr zoom 1"))
+ hl.bind(mod .. " + Z", hl.dsp.submap("reset"))
+end)
+
+hl.bind(mod .. " + X", hl.dsp.exec_cmd("calc-toggle"))
+hl.bind(mod .. " + C", hl.dsp.exec_cmd("hyprpicker -a"))
+hl.bind(mod .. " + CONTROL + C", hl.dsp.exec_cmd("clock-panel toggle"))
+
+hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("pactl set-sink-volume @DEFAULT_SINK@ +5%"), { locked = true, repeating = true })
+hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("pactl set-sink-volume @DEFAULT_SINK@ -5%"), { locked = true, repeating = true })
+hl.bind("XF86AudioMute", hl.dsp.exec_cmd("audio quick-mute"), { locked = true })
+hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl s +10%"), { locked = true, repeating = true })
+hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl s 10%-"), { locked = true, repeating = true })
+
+hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("mic-toggle"), { locked = true })
+
+hl.bind(mod .. " + SHIFT + A", hl.dsp.exec_cmd("audio ptt-toggle"))
+
+hl.bind(mod .. " + SHIFT + B", hl.dsp.exec_cmd("bt-panel"))
+
+hl.bind(mod .. " + S", hl.dsp.exec_cmd("screenshot region"))
+hl.bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd("screenshot fullscreen"))
+hl.bind("CTRL" .. mod .. " + S", hl.dsp.exec_cmd("screenshot fullscreen"))
+
+hl.bind(mod .. " + ESCAPE", hl.dsp.exec_cmd("hyprlock"))
+
+hl.bind(mod .. " + M", hl.dsp.exec_cmd("audio-cycle"))
+
+hl.bind(mod .. " + SHIFT + Q", hl.dsp.exec_cmd("pgrep -x wlogout || wlogout-menu"))
+hl.bind(mod .. " + SHIFT + BACKSPACE", hl.dsp.submap("exitconfirm"))
+
+hl.define_submap("exitconfirm", function()
+ hl.bind("BACKSPACE", hl.dsp.exit())
+ hl.bind("ESCAPE", hl.dsp.submap("reset"))
+ hl.bind("catchall", hl.dsp.submap("reset"))
+end)
+
+hl.bind(mod .. " + SHIFT + ESCAPE", hl.dsp.exec_cmd("hyprctl reload"))
+hl.bind("CTRL + ALT" .. mod .. " + K", hl.dsp.exec_cmd("hyprctl kill"))
+
+hl.bind(mod .. " + mouse:272", hl.dsp.window.drag())
+hl.bind(mod .. " + mouse:273", hl.dsp.window.resize())
+hl.bind(mod .. " + SHIFT + mouse:272", hl.dsp.window.resize())
+
+-- Source: $HOME/.config/hypr/conf.d/*.conf (glob; resolved at runtime). Each matched .conf must be converted to .lua.
+hl_source_glob("$HOME/.config/hypr/conf.d/*.lua")
+hl.config({
+ general = {
+ gaps_in = 25,
+ gaps_out = 30,
+ border_size = 2,
+ col = {
+ active_border = "rgba(daa520ff)",
+ inactive_border = "rgba(444444ff)",
+ nogroup_border_active = "rgba(daa520ff)",
+ nogroup_border = "rgba(444444ff)",
+ },
+ -- Pyprland 3.4+ applies `group deny` to scratchpads, which routes their
+ -- border through col.nogroup_border* instead of col.*_border. Without
+ -- these overrides Hyprland's defaults paint scratchpads bright magenta.
+ layout = "master",
+ resize_on_border = true,
+ },
+ decoration = {
+ rounding = 10,
+ dim_inactive = true,
+ dim_strength = 0.4,
+ dim_special = 0.2,
+ blur = {
+ enabled = false,
+ },
+ shadow = {
+ enabled = false,
+ },
+ },
+ animations = {
+ enabled = true,
+ },
+ -- ============================================================================
+ -- Layout (master-stack like DWM tile)
+ -- ============================================================================
+ master = {
+ new_status = "master",
+ new_on_top = true,
+ mfact = 0.55,
+ },
+ dwindle = {
+ preserve_split = true,
+ },
+ -- ============================================================================
+ -- Input
+ -- ============================================================================
+ cursor = {
+ no_warps = true,
+ inactive_timeout = 2.0,
+ },
+ input = {
+ kb_layout = "us",
+ kb_options = "ctrl:nocaps",
+ numlock_by_default = true,
+ follow_mouse = 0,
+ -- 0, not the default 1: with follow_mouse off we never want focus to follow
+ -- the cursor. At 1, focus still jumps to the window under the pointer when it
+ -- crosses a floating<->tiled boundary, so launching a floating scratchpad (or
+ -- the org-capture popup) re-enabled focus-follows-mouse onto tiled windows.
+ float_switch_override_focus = 0,
+ mouse_refocus = false,
+ natural_scroll = true,
+ touchpad = {
+ natural_scroll = false,
+ },
+ },
+ -- ============================================================================
+ -- Misc
+ -- ============================================================================
+ misc = {
+ force_default_wallpaper = 0,
+ disable_hyprland_logo = true,
+ -- false so apps can't pull focus via activation requests. New windows still
+ -- focus on open (separate path); this stops e.g. a browser yanking focus
+ -- back off a freshly opened emacs frame.
+ focus_on_activate = false,
+ -- Let a fresh lock client adopt a session whose previous one died. The
+ -- default (off) is the strict reading of ext-session-lock: a dead lock
+ -- client leaves the session locked forever and refuses every replacement
+ -- ("Cannot re-lock"), so the screen stays up with nothing able to draw a
+ -- password prompt and the only way back in is another console. That is a
+ -- hard lockout, and it cost a session on velox 2026-07-22. On means a
+ -- replacement hyprlock re-attaches and prompts normally. The screen stays
+ -- locked either way — this decides whether the lock is recoverable, never
+ -- whether it holds.
+ allow_session_lock_restore = true,
+ },
+ -- ============================================================================
+ -- Debug (temporary - disable when stable)
+ -- ============================================================================
+ debug = {
+ disable_logs = false,
+ },
+ -- ============================================================================
+ -- XWayland
+ -- ============================================================================
+ xwayland = {
+ force_zero_scaling = true,
+ },
+ -- ============================================================================
+ -- Window Rules (Hyprland 0.53+ syntax: match:CONDITION, RULE)
+ -- ============================================================================
+ -- Floating windows (from DWM rules)
+ -- net / bluetooth instrument-console panels. Normal floating windows (formerly
+ -- gtk4-layer-shell overlays) so they drag to move and corner-drag to resize.
+ -- Opened top-right to match their old anchored spot: the panel is right-aligned
+ -- with a 44px gap, so x = 100% - (window width + 44). net is 420 wide, bt 380.
+ -- maintenance console: the wide board (960), same right-aligned convention.
+ -- org-capture popup frame (quick-capture script names the frame)
+ -- Size is per-host in <host>/conf.d/local.conf: native window rules ignore
+ -- percentages (only pyprland honors them), so the popup is sized in absolute
+ -- pixels matching that host's terminal scratchpad. No size rule here means a
+ -- host without an override falls back to the script's char-cell geometry.
+ -- dirvish popup frame (dirvish-popup script names the frame). No stay_focused —
+ -- it's a file manager that launches files into other apps, so focus must be free
+ -- to follow; q (cj/dirvish-popup-quit) closes the frame.
+ -- NOTE: center windowrules removed 2026-03-04 per pyprland maintainer suggestion
+ -- Testing whether pyprland handles scratchpad re-centering natively (issue #211)
+ -- Gaming
+ -- ============================================================================
+ -- Key Bindings
+ -- ============================================================================
+ -- Terminal and core apps (from DWM)
+ -- Standalone emacs: its own process, not a frame on the daemon, so killing it
+ -- takes no other frame with it. init.el guards server-start on server-running-p,
+ -- so while the daemon holds the socket this process leaves it alone. With no
+ -- daemon up it finds no server and becomes one -- the guard lives in init.el, and
+ -- a keybind can't override it, since --eval runs after init.
+ -- From sxhkdrc
+ -- Window management (from DWM)
+ -- Layout-aware navigation (works across master, scrolling)
+ -- Swap focused window with master, then force focus onto the master slot.
+ -- swapwithmaster's own `master` focus param doesn't stick when invoked from
+ -- the master, so focusmaster master pins focus afterward. The 50ms sleep is
+ -- load-bearing: swapwithmaster fires an async focus event ~1-2ms after it
+ -- returns; without the delay that event lands AFTER focusmaster and flips
+ -- focus back to the detail. The sleep lets the swap's focus settle so
+ -- focusmaster runs last and wins. Proven via instrumented capture (19/19).
+ -- Layouts: master -> monocle
+ -- Cycle with Shift+arrows, or jump directly with Shift+T/M
+ -- (scrolling layout disabled until frame-fit + wrap-around work lands)
+ -- Master layout adjustments
+ -- Stash windows (hide to special workspace)
+ -- O = stash focused / Alt+O = stash others / Shift+O = restore all
+ -- Gaps between windows only; window-gaps leaves the monitor-edge gap
+ -- (general:gaps_out) fixed, so widening/narrowing moves the space between
+ -- windows, not the screen-edge margin.
+ -- Auto-dim toggle (D = dim). Same action as clicking the waybar custom/dim icon.
+ -- Caffeine (keep-awake) toggle. Same action as clicking the waybar
+ -- custom/caffeine icon — flips the hypridle daemon so the screen will / won't
+ -- lock. Stays on $mod+I ($mod+C is taken by hyprpicker; no free caffeine key).
+ -- Airplane mode (low-power: wifi off + CPU/brightness/services). A deliberate
+ -- keybind, not a bar click — engaging it disconnects you, so it shouldn't be a
+ -- misclick away. The custom/net module shows the state; this toggles it.
+ -- On Super+Shift+X ("X" = everything off); Super+Shift+A toggles push-to-talk.
+ -- Toggle bar visibility, or relaunch waybar if it crashed (no exec-once respawn).
+ -- Collapse / expand the left or right side of the bar to its base set
+ -- (same action as clicking the side's arrowhead). [ = left, ] = right.
+ -- Fullscreen
+ -- Workspaces 1-9 (from DWM TAGKEYS)
+ -- Move window to workspace (from DWM tag)
+ -- Monitor focus (from DWM focusmon)
+ -- ============================================================================
+ -- Scratchpads (via pyprland)
+ -- ============================================================================
+ -- Configured in ~/.config/hypr/pyprland.toml
+ -- Uses normal workspaces (not special), so new windows won't be captured
+ -- Magnify (zoom)
+ -- mod+Z zooms and enters the "zoom" submap; inside it, Escape or mod+Z
+ -- unzooms and returns to the normal keymap. Exit forces `pypr zoom 1`
+ -- (factor 1) so submap state and zoom state can't desync. Note: while
+ -- zoomed, other Hyprland binds pause until you exit the submap.
+ -- Calculator (not a scratchpad, just launches app)
+ -- Media/hardware keys
+ -- Microphone mute toggle (waybar pulseaudio#mic indicator follows via PipeWire events).
+ -- On the hardware mic-mute key. Super+Shift+A used to duplicate this; it now
+ -- toggles push-to-talk mode instead (mic-toggle stays reachable on the hw key).
+ -- Push-to-talk toggle: enter PTT (mic muted, hold key armed) / exit (restore).
+ -- The hold key is configurable (audio config ptt_key = Control_R or mouse:NNN).
+ -- Bluetooth panel (blueman retired in favor of the bt panel)
+ -- Screenshots (grim + slurp + fuzzel menu)
+ -- Shift+S captures the whole desktop with no pointer interaction, so it
+ -- works on scratchpads and popups that region-select would dismiss
+ -- Lock screen
+ -- Audio mute cycle (M for Mute): one key walks volume/mic through all four
+ -- on/off combinations. The waybar pulseaudio modules follow via PipeWire.
+ -- Exit/session
+ -- mod+Shift+Backspace no longer exits outright — it enters the "exitconfirm"
+ -- submap, where a second Backspace confirms and anything else backs out.
+ -- The bare bind killed two live sessions on 2026-07-22: it sits one key from
+ -- the mod+Shift cluster (Q wlogout, C killactive, Return terminal, G settings)
+ -- and took the whole session down with no prompt. wlogout owns the normal
+ -- exit path; this stays as the keyboard escape hatch for when wlogout won't
+ -- come up, so it's gated rather than removed.
+ -- Mouse bindings (from DWM buttons)
+ -- ============================================================================
+ -- Machine-local overrides
+ -- ============================================================================
+ -- Sourced last so machine-specific settings (monitor scale, gaps, keybinds)
+ -- override the defaults above. See conf.d/local.conf.
+})
+
+hl.on("hyprland.start", function()
+ hl.exec_cmd("dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP HYPRLAND_INSTANCE_SIGNATURE")
+ hl.exec_cmd("systemctl --user start hyprland-session.target; systemctl --user restart xdg-desktop-portal-hyprland xdg-desktop-portal-gtk; systemctl --user restart xdg-desktop-portal; waybar-active-config && waybar -c \"$XDG_RUNTIME_DIR/waybar/config\" -s ~/.config/waybar/style.css 2>&1 | grep -v \"LIBDBUSMENU-GLIB-WARNING\" > ~/.local/var/log/waybar-$(date +%Y-%m-%d-%H%M%S).log")
+ hl.exec_cmd("/usr/lib/polkit-kde-authentication-agent-1")
+ hl.exec_cmd("/usr/bin/gnome-keyring-daemon --start --components=pkcs11,secrets,ssh")
+ hl.exec_cmd("dunst > ~/.local/var/log/dunst-$(date +%Y-%m-%d-%H%M%S).log 2>&1")
+ hl.exec_cmd("awww-daemon & sleep 1 && { settings restore-wallpaper || waypaper --restore; }")
+ hl.exec_cmd("touchpad-auto")
+ hl.exec_cmd("pkill -x hypridle; hypridle-start > ~/.local/var/log/hypridle-$(date +%Y-%m-%d-%H%M%S).log 2>&1")
+ hl.exec_cmd("/usr/lib/geoclue-2.0/demos/agent")
+ hl.exec_cmd("gammastep > ~/.local/var/log/gammastep-$(date +%Y-%m-%d-%H%M%S).log 2>&1")
+ hl.exec_cmd("mpd")
+ hl.exec_cmd("settings restore > ~/.local/var/log/settings-restore-$(date +%Y-%m-%d-%H%M%S).log 2>&1")
+ hl.exec_cmd("pypr > ~/.local/var/log/pypr-$(date +%Y-%m-%d-%H%M%S).log 2>&1")
+ hl.exec_cmd("hypr-refocus-scratchpad")
+ hl.exec_cmd("wait-for-tray && signal-desktop --start-in-tray --ozone-platform=wayland")
+ hl.exec_cmd("env QT_FONT_DPI=108 protonmail-bridge --no-window")
+end)
+
+hl.on("config.reloaded", function()
+ hl.exec_cmd("for i in 1 2 3; do sleep 0.2; waybar-reserve; done")
+end)
+
diff --git a/working/hyprland-lua-port/nomerge.lua b/working/hyprland-lua-port/nomerge.lua
new file mode 100644
index 0000000..c85fd24
--- /dev/null
+++ b/working/hyprland-lua-port/nomerge.lua
@@ -0,0 +1,768 @@
+-- Hyprland Configuration
+-- Translated from DWM config.def.h and sxhkdrc
+-- Craig Jennings <c@cjennings.net>
+
+-- ============================================================================
+-- Monitor Configuration
+-- ============================================================================
+-- Generated by hyprlang2lua. Review TODOs before reloading Hyprland.
+
+-- hyprlang2lua polyfills — runtime helpers reproducing
+-- hyprlang behaviour the typed Lua API doesn't expose directly.
+
+local function hl_source_glob(pattern)
+ -- 'source = path/*.conf' had hyprlang glob and inline-expand the
+ -- matches. require() can't glob, so we shell out to ls (matching
+ -- the user's brace-expansion behaviour) and dofile each result.
+ -- Paths with spaces or shell metacharacters in the directory
+ -- portion will misparse; typical ~/.config/hypr/ layouts don't
+ -- hit this. Swap to lfs.dir() or find -name if you need fancier.
+ local p = io.popen("ls " .. pattern .. " 2>/dev/null")
+ if not p then return end
+ for f in p:lines() do
+ local chunk, err = loadfile(f)
+ if chunk then chunk()
+ else io.stderr:write("hl_source_glob: " .. tostring(err) .. "\n") end
+ end
+ p:close()
+end
+
+hl.monitor({
+ output = "",
+ mode = "preferred",
+ position = "auto",
+ scale = "auto",
+})
+
+-- Waybar's strip (6px top margin + 54px bar) is reserved statically by
+-- waybar-reserve, and waybar runs with "exclusive": false. The bar's own
+-- exclusive zone would vanish and reappear on every SIGUSR2 reload (the
+-- collapse mechanism) and on hide/crash/relaunch, snapping every tiled window
+-- up and back down. The static reservation holds the clients in place; only
+-- the bar itself changes. `exec` (not exec-once) reruns it on every config
+-- reload, which is exactly when Hyprland resets dynamic reservations. The
+-- script is idempotent, and a catch-all `monitor=,addreserved,...` rule can't
+-- replace it (empty-name addreserved silently no-ops).
+--
+-- Run three times over ~0.6s, not once: on reload Hyprland clears the
+-- reservation AND re-fires this exec, and the two race. A single run that
+-- fires before the clear no-ops (reserved still looks correct), the clear then
+-- wins, and the non-exclusive bar drops off-screen. Re-applying past the clear
+-- window makes the restore reliable; the script is idempotent so extra runs are
+-- free. Applying a monitor rule (e.g. the DP-4 pin) also clears the reservation,
+-- so this covers a reload that re-asserts monitors too.
+
+-- ============================================================================
+-- Startup Applications
+-- ============================================================================
+-- Portal and D-Bus setup FIRST, then waybar (needs portal for appearance query)
+-- Start hyprland-session.target FIRST: it pulls up graphical-session.target,
+-- which xdg-desktop-portal 1.22+ hard-requires (Requisite=). A bare-exec Hyprland
+-- session has no session manager to raise that target, so without this the portal
+-- fails its dependency at every login (screen-share + file pickers dead).
+-- 'systemctl start' blocks until active, so the ';' sequence guarantees the target
+-- is up before the portal restart runs.
+--
+-- Portal restart (not start) reconnects stale portals on Hyprland restart.
+-- Backend portals (GTK, Hyprland) restart BEFORE the main portal to avoid a 50s
+-- GTK settings proxy timeout; the sequence keeps that ordering. Separated by ';'
+-- not '&&' so a failing portal restart can't stop waybar from launching — waybar
+-- degrades gracefully without the portal (only the appearance query is missed),
+-- and gating the bar behind the portal left the desktop bar-less whenever
+-- xdg-desktop-portal failed its dependency at login. Waybar stays gated on its
+-- own config generation (waybar-active-config && waybar).
+
+-- Core services
+
+-- Desktop appearance
+-- `settings restore` replays the remembered toggles and reapplies the stored
+-- wallpaper. It replaced `waypaper --restore` on 2026-08-14: waypaper keeps
+-- its own config.ini and the settings store keeps another, neither knew about
+-- the other, and the login replay always won — so a wallpaper chosen in the
+-- panel came back as whatever the shell had last set. The store is the only
+-- one of the two that can hold a sun pair, a video or a projected face, so it
+-- owns the restore. set-wallpaper records into it for choices made outside
+-- the panel.
+--
+-- waypaper --restore stays as the fallback, not the owner. If the stored
+-- wallpaper cannot be applied (an image deleted, a drive not mounted yet),
+-- `settings restore` exits 3 and waypaper's independent copy still puts
+-- something on the screen. Dropping it outright would trade this bug for a
+-- bare desktop.
+--
+-- The wallpaper half only. The toggle half runs from its own exec-once further
+-- down, after hypridle and dunst — caffeine *is* "hypridle isn't running" and
+-- DND *is* dunst's pause level, so replaying them here would spend the whole
+-- re-assert budget correcting backings that have not launched yet, and would
+-- replay them a second time besides. This slot exists for awww's timing, not
+-- theirs.
+
+-- Background services
+-- hypridle is reaped on both exit paths, because it outlives its compositor
+-- otherwise. An orphaned daemon keeps firing idle actions at whatever session
+-- is live next, and it holds its old logind session scope open (the scope can't
+-- close while a process sits in it), so orphans accumulate one per abnormal
+-- session death. On 2026-07-22 velox reached five concurrent hypridle daemons;
+-- two of them racing to lock produced "Cannot re-lock" and a session wedged
+-- locked with no client able to draw a password prompt — recoverable only from
+-- another console. exec-shutdown covers a clean compositor exit; the pkill in
+-- exec-once covers the paths where it never runs (crash, SIGKILL, TTY logout).
+hl.on("hyprland.shutdown", function()
+ hl.exec_cmd("pkill -x hypridle")
+end)
+
+-- hypridle.conf is rendered here rather than tracked, because its contents
+-- are this machine's stage times and hibernate setting. Tracking the render
+-- meant every panel change dirtied the repo, and whichever machine
+-- committed last imposed its policy on the others: a desktop ended up
+-- carrying a laptop's suspend-then-hibernate line that it cannot run.
+-- Rendering at session start makes the store the only source of truth and
+-- the file a build artifact.
+--
+-- hypridle-start owns the render, the fallback, and the ordering between
+-- them, because that ordering is subtle enough to get wrong in a config
+-- line nothing can test: a render can fail on purpose (a damaged store, to
+-- avoid overwriting a real policy with defaults), and a fallback that
+-- fired there would perform exactly the overwrite the render refused.
+-- Replay the toggles that have no durable state of their own. Caffeine *is*
+-- "hypridle isn't running" and DND *is* dunst's pause level, so the two
+-- exec-once lines above (and dunst's) recreate both at a fixed default every
+-- start — a deliberately-set caffeine was silently discarded on every login.
+-- Ordered after those launches so it corrects a backing that exists; it also
+-- re-asserts for a few seconds, which covers a backing that comes up late.
+-- Logged like its neighbours: a silent exec-once failure here would look
+-- exactly like the bug it fixes, and gammastep is the standing proof that a
+-- launch dying quietly at session start can go unnoticed for a long time.
+
+-- Pyprland (scratchpads, magnify, etc.)
+
+-- Tray apps. wait-for-tray blocks until waybar's systray host is up (a fixed
+-- sleep can't cover a slow cold-start waybar), so these register their icons
+-- instead of opening as windows. Caps at ~30s, then launches anyway.
+-- QT_FONT_DPI bumps the bridge's QML UI font (qt6ct General font is ignored by Qt Quick)
+
+-- ============================================================================
+-- Environment Variables
+-- ============================================================================
+hl.env("XCURSOR_SIZE", "24")
+hl.env("XCURSOR_THEME", "Bibata-Modern-Ice")
+hl.env("XDG_CURRENT_DESKTOP", "Hyprland")
+hl.env("XDG_SESSION_TYPE", "wayland")
+hl.env("XDG_SESSION_DESKTOP", "Hyprland")
+hl.env("_JAVA_AWT_WM_NONREPARENTING", "1")
+
+-- ============================================================================
+-- Appearance (matching DWM colors)
+-- ============================================================================
+-- DWM colors: gray1=#222222, gray2=#444444, gray3=#bbbbbb, gray4=#eeeeee, cyan=#daa520
+
+hl.config({
+ general = {
+ gaps_in = 25,
+ gaps_out = 30,
+ border_size = 2,
+ col = {
+ active_border = "rgba(daa520ff)",
+ inactive_border = "rgba(444444ff)",
+ nogroup_border_active = "rgba(daa520ff)",
+ nogroup_border = "rgba(444444ff)",
+ },
+ -- Pyprland 3.4+ applies `group deny` to scratchpads, which routes their
+ -- border through col.nogroup_border* instead of col.*_border. Without
+ -- these overrides Hyprland's defaults paint scratchpads bright magenta.
+ layout = "master",
+ resize_on_border = true,
+ },
+})
+
+hl.config({
+ decoration = {
+ rounding = 10,
+ dim_inactive = true,
+ dim_strength = 0.4,
+ dim_special = 0.2,
+ blur = {
+ enabled = false,
+ },
+ shadow = {
+ enabled = false,
+ },
+ },
+})
+
+hl.config({
+ animations = {
+ enabled = true,
+ },
+})
+
+hl.curve("myBezier", { type = "bezier", points = { { 0.05, 0.9 }, { 0.1, 1.05 } } })
+hl.animation({
+ leaf = "windows",
+ enabled = true,
+ speed = 2,
+ bezier = "myBezier",
+})
+hl.animation({
+ leaf = "windowsOut",
+ enabled = true,
+ speed = 2,
+ bezier = "default",
+ style = "popin 80%",
+})
+hl.animation({
+ leaf = "fade",
+ enabled = true,
+ speed = 2,
+ bezier = "default",
+})
+hl.animation({
+ leaf = "workspaces",
+ enabled = true,
+ speed = 2,
+ bezier = "default",
+})
+hl.animation({
+ leaf = "specialWorkspace",
+ enabled = true,
+ speed = 2,
+ bezier = "default",
+ style = "slidevert",
+})
+
+-- ============================================================================
+-- Layout (master-stack like DWM tile)
+-- ============================================================================
+
+hl.config({
+ master = {
+ new_status = "master",
+ new_on_top = true,
+ mfact = 0.55,
+ },
+})
+
+hl.config({
+ dwindle = {
+ preserve_split = true,
+ },
+})
+
+-- ============================================================================
+-- Input
+-- ============================================================================
+
+hl.config({
+ cursor = {
+ no_warps = true,
+ inactive_timeout = 2.0,
+ },
+})
+
+hl.config({
+ input = {
+ kb_layout = "us",
+ kb_options = "ctrl:nocaps",
+ numlock_by_default = true,
+ follow_mouse = 0,
+ -- 0, not the default 1: with follow_mouse off we never want focus to follow
+ -- the cursor. At 1, focus still jumps to the window under the pointer when it
+ -- crosses a floating<->tiled boundary, so launching a floating scratchpad (or
+ -- the org-capture popup) re-enabled focus-follows-mouse onto tiled windows.
+ float_switch_override_focus = 0,
+ mouse_refocus = false,
+ natural_scroll = true,
+ touchpad = {
+ natural_scroll = false,
+ },
+ },
+})
+
+-- ============================================================================
+-- Misc
+-- ============================================================================
+
+hl.config({
+ misc = {
+ force_default_wallpaper = 0,
+ disable_hyprland_logo = true,
+ -- false so apps can't pull focus via activation requests. New windows still
+ -- focus on open (separate path); this stops e.g. a browser yanking focus
+ -- back off a freshly opened emacs frame.
+ focus_on_activate = false,
+ -- Let a fresh lock client adopt a session whose previous one died. The
+ -- default (off) is the strict reading of ext-session-lock: a dead lock
+ -- client leaves the session locked forever and refuses every replacement
+ -- ("Cannot re-lock"), so the screen stays up with nothing able to draw a
+ -- password prompt and the only way back in is another console. That is a
+ -- hard lockout, and it cost a session on velox 2026-07-22. On means a
+ -- replacement hyprlock re-attaches and prompts normally. The screen stays
+ -- locked either way — this decides whether the lock is recoverable, never
+ -- whether it holds.
+ allow_session_lock_restore = true,
+ },
+})
+
+-- ============================================================================
+-- Debug (temporary - disable when stable)
+-- ============================================================================
+
+hl.config({
+ debug = {
+ disable_logs = false,
+ },
+})
+
+-- ============================================================================
+-- XWayland
+-- ============================================================================
+
+hl.config({
+ xwayland = {
+ force_zero_scaling = true,
+ },
+})
+
+-- ============================================================================
+-- Window Rules (Hyprland 0.53+ syntax: match:CONDITION, RULE)
+-- ============================================================================
+-- Floating windows (from DWM rules)
+hl.window_rule({
+ match = {
+ class = "^(xdg-desktop-portal-gtk)$",
+ },
+ float = true,
+})
+
+hl.window_rule({
+ match = {
+ class = "^(Gimp)$",
+ },
+ float = true,
+})
+
+hl.window_rule({
+ match = {
+ class = "^(caffeine)$",
+ },
+ float = true,
+})
+
+hl.window_rule({
+ match = {
+ class = "^(qalculate-gtk)$",
+ },
+ float = true,
+})
+
+hl.window_rule({
+ match = {
+ title = "^(Event Tester)$",
+ },
+ float = true,
+})
+
+-- net / bluetooth instrument-console panels. Normal floating windows (formerly
+-- gtk4-layer-shell overlays) so they drag to move and corner-drag to resize.
+-- Opened top-right to match their old anchored spot: the panel is right-aligned
+-- with a 44px gap, so x = 100% - (window width + 44). net is 420 wide, bt 380.
+hl.window_rule({
+ match = {
+ class = "^(net\\.cjennings\\.netpanel)$",
+ },
+ float = true,
+})
+
+hl.window_rule({
+ match = {
+ class = "^(net\\.cjennings\\.netpanel)$",
+ },
+ move = "100%-464 50",
+})
+
+hl.window_rule({
+ match = {
+ class = "^(net\\.cjennings\\.btpanel)$",
+ },
+ float = true,
+})
+
+hl.window_rule({
+ match = {
+ class = "^(net\\.cjennings\\.btpanel)$",
+ },
+ move = "100%-424 50",
+})
+
+hl.window_rule({
+ match = {
+ class = "^(net\\.cjennings\\.audiopanel)$",
+ },
+ float = true,
+})
+
+hl.window_rule({
+ match = {
+ class = "^(net\\.cjennings\\.audiopanel)$",
+ },
+ move = "100%-444 50",
+})
+
+hl.window_rule({
+ match = {
+ class = "^(net\\.cjennings\\.timerpanel)$",
+ },
+ float = true,
+})
+
+hl.window_rule({
+ match = {
+ class = "^(net\\.cjennings\\.timerpanel)$",
+ },
+ move = "100%-444 50",
+})
+
+hl.window_rule({
+ match = {
+ class = "^(net\\.cjennings\\.settingspanel)$",
+ },
+ float = true,
+})
+
+hl.window_rule({
+ match = {
+ class = "^(net\\.cjennings\\.settingspanel)$",
+ },
+ move = "100%-584 50",
+})
+
+hl.window_rule({
+ match = {
+ class = "^(net\\.cjennings\\.weatherpanel)$",
+ },
+ float = true,
+})
+
+hl.window_rule({
+ match = {
+ class = "^(net\\.cjennings\\.weatherpanel)$",
+ },
+ move = "100%-464 50",
+})
+
+-- maintenance console: the wide board (960), same right-aligned convention.
+hl.window_rule({
+ match = {
+ class = "^(net\\.cjennings\\.maintpanel)$",
+ },
+ float = true,
+})
+
+hl.window_rule({
+ match = {
+ class = "^(net\\.cjennings\\.maintpanel)$",
+ },
+ move = "100%-1004 50",
+})
+
+-- org-capture popup frame (quick-capture script names the frame)
+-- Size is per-host in <host>/conf.d/local.conf: native window rules ignore
+-- percentages (only pyprland honors them), so the popup is sized in absolute
+-- pixels matching that host's terminal scratchpad. No size rule here means a
+-- host without an override falls back to the script's char-cell geometry.
+hl.window_rule({
+ match = {
+ title = "^(org-capture)$",
+ },
+ float = true,
+})
+
+hl.window_rule({
+ match = {
+ title = "^(org-capture)$",
+ },
+ center = true,
+})
+
+-- dirvish popup frame (dirvish-popup script names the frame). No stay_focused —
+-- it's a file manager that launches files into other apps, so focus must be free
+-- to follow; q (cj/dirvish-popup-quit) closes the frame.
+hl.window_rule({
+ match = {
+ title = "^(dirvish)$",
+ },
+ float = true,
+})
+
+hl.window_rule({
+ match = {
+ title = "^(dirvish)$",
+ },
+ size = "1100 700",
+})
+
+hl.window_rule({
+ match = {
+ title = "^(dirvish)$",
+ },
+ center = true,
+})
+
+-- NOTE: center windowrules removed 2026-03-04 per pyprland maintainer suggestion
+-- Testing whether pyprland handles scratchpad re-centering natively (issue #211)
+
+-- Gaming
+hl.window_rule({
+ match = {
+ class = "^(Civ5XP)$",
+ },
+ fullscreen = true,
+})
+
+-- ============================================================================
+-- Key Bindings
+-- ============================================================================
+local mod = "SUPER"
+
+-- Terminal and core apps (from DWM)
+hl.bind(mod .. " + T", hl.dsp.exec_cmd("foot"))
+hl.bind(mod .. " + E", hl.dsp.exec_cmd("emacsclient -c -a \"\" || emacs"))
+-- Standalone emacs: its own process, not a frame on the daemon, so killing it
+-- takes no other frame with it. init.el guards server-start on server-running-p,
+-- so while the daemon holds the socket this process leaves it alone. With no
+-- daemon up it finds no server and becomes one -- the guard lives in init.el, and
+-- a keybind can't override it, since --eval runs after init.
+hl.bind(mod .. " + SHIFT + E", hl.dsp.exec_cmd("emacs"))
+hl.bind(mod .. " + N", hl.dsp.exec_cmd("quick-capture"))
+hl.bind(mod .. " + W", hl.dsp.exec_cmd("$BROWSER"))
+hl.bind(mod .. " + F", hl.dsp.exec_cmd("dirvish-popup"))
+hl.bind(mod .. " + SHIFT + F", hl.dsp.exec_cmd("layout-cycle float-toggle"))
+
+-- From sxhkdrc
+hl.bind(mod .. " + SPACE", hl.dsp.exec_cmd("fuzzel-toggle"))
+hl.bind(mod .. " + SHIFT + W", hl.dsp.exec_cmd("$ALTBROWSER"))
+hl.bind(mod .. " + P", hl.dsp.exec_cmd("media-toggle-all"))
+hl.bind(mod .. " + SHIFT + L", hl.dsp.exec_cmd("calibre"))
+hl.bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("toggle-touchpad"))
+
+-- Window management (from DWM)
+-- Layout-aware navigation (works across master, scrolling)
+hl.bind(mod .. " + J", hl.dsp.exec_cmd("layout-navigate next"))
+hl.bind(mod .. " + K", hl.dsp.exec_cmd("layout-navigate prev"))
+hl.bind(mod .. " + SHIFT + J", hl.dsp.exec_cmd("layout-navigate next move"))
+hl.bind(mod .. " + SHIFT + K", hl.dsp.exec_cmd("layout-navigate prev move"))
+hl.bind(mod .. " + H", hl.dsp.exec_cmd("layout-resize shrink"))
+hl.bind(mod .. " + L", hl.dsp.exec_cmd("layout-resize grow"))
+-- Swap focused window with master, then force focus onto the master slot.
+-- swapwithmaster's own `master` focus param doesn't stick when invoked from
+-- the master, so focusmaster master pins focus afterward. The 50ms sleep is
+-- load-bearing: swapwithmaster fires an async focus event ~1-2ms after it
+-- returns; without the delay that event lands AFTER focusmaster and flips
+-- focus back to the detail. The sleep lets the swap's focus settle so
+-- focusmaster runs last and wins. Proven via instrumented capture (19/19).
+hl.bind(mod .. " + RETURN", hl.dsp.layout("swapwithmaster && sleep 0.05 && hyprctl dispatch layoutmsg focusmaster master"))
+hl.bind(mod .. " + G", hl.dsp.window.center())
+hl.bind(mod .. " + TAB", hl.dsp.focus({ workspace = "previous" }))
+hl.bind(mod .. " + SHIFT + C", hl.dsp.window.close())
+
+-- Layouts: master -> monocle
+-- Cycle with Shift+arrows, or jump directly with Shift+T/M
+-- (scrolling layout disabled until frame-fit + wrap-around work lands)
+hl.bind(mod .. " + SHIFT + RIGHT", hl.dsp.exec_cmd("layout-cycle next"))
+hl.bind(mod .. " + SHIFT + LEFT", hl.dsp.exec_cmd("layout-cycle prev"))
+hl.bind(mod .. " + SHIFT + T", hl.dsp.exec_cmd("hyprctl keyword general:layout master && hyprctl keyword master:orientation left"))
+hl.bind(mod .. " + SHIFT + M", hl.dsp.exec_cmd("hyprctl keyword general:layout monocle"))
+hl.bind(mod .. " + SHIFT + SPACE", hl.dsp.window.float({ action = "toggle" }))
+
+-- Master layout adjustments
+hl.bind(mod .. " + U", hl.dsp.layout("addmaster"))
+hl.bind(mod .. " + D", hl.dsp.layout("removemaster"))
+
+-- Stash windows (hide to special workspace)
+-- O = stash focused / Alt+O = stash others / Shift+O = restore all
+hl.bind(mod .. " + O", hl.dsp.exec_cmd("stash-window"))
+hl.bind(mod .. " + ALT + O", hl.dsp.exec_cmd("stash-others"))
+hl.bind(mod .. " + SHIFT + O", hl.dsp.exec_cmd("stash-restore"))
+
+-- Gaps between windows only; window-gaps leaves the monitor-edge gap
+-- (general:gaps_out) fixed, so widening/narrowing moves the space between
+-- windows, not the screen-edge margin.
+hl.bind(mod .. " + MINUS", hl.dsp.exec_cmd("window-gaps narrow"))
+hl.bind(mod .. " + EQUAL", hl.dsp.exec_cmd("window-gaps widen"))
+hl.bind(mod .. " + SHIFT + EQUAL", hl.dsp.exec_cmd("window-gaps reset"))
+hl.bind(mod .. " + SHIFT + MINUS", hl.dsp.exec_cmd("window-gaps zero"))
+
+-- Auto-dim toggle (D = dim). Same action as clicking the waybar custom/dim icon.
+hl.bind(mod .. " + SHIFT + D", hl.dsp.exec_cmd("dim-toggle"))
+hl.bind(mod .. " + SHIFT + G", hl.dsp.exec_cmd("settings-panel"))
+
+-- Caffeine (keep-awake) toggle. Same action as clicking the waybar
+-- custom/caffeine icon — flips the hypridle daemon so the screen will / won't
+-- lock. Stays on $mod+I ($mod+C is taken by hyprpicker; no free caffeine key).
+hl.bind(mod .. " + I", hl.dsp.exec_cmd("caffeine-toggle"))
+
+-- Airplane mode (low-power: wifi off + CPU/brightness/services). A deliberate
+-- keybind, not a bar click — engaging it disconnects you, so it shouldn't be a
+-- misclick away. The custom/net module shows the state; this toggles it.
+-- On Super+Shift+X ("X" = everything off); Super+Shift+A toggles push-to-talk.
+hl.bind(mod .. " + SHIFT + X", hl.dsp.exec_cmd("airplane-mode"))
+
+-- Toggle bar visibility, or relaunch waybar if it crashed (no exec-once respawn).
+hl.bind(mod .. " + B", hl.dsp.exec_cmd("waybar-toggle"))
+
+-- Collapse / expand the left or right side of the bar to its base set
+-- (same action as clicking the side's arrowhead). [ = left, ] = right.
+hl.bind(mod .. " + bracketleft", hl.dsp.exec_cmd("waybar-collapse left"))
+hl.bind(mod .. " + bracketright", hl.dsp.exec_cmd("waybar-collapse right"))
+
+-- Fullscreen
+hl.bind(mod .. " + F11", hl.dsp.window.fullscreen({ mode = "fullscreen", action = "toggle" }))
+
+-- Workspaces 1-9 (from DWM TAGKEYS)
+hl.bind(mod .. " + 1", hl.dsp.focus({ workspace = 1 }))
+hl.bind(mod .. " + 2", hl.dsp.focus({ workspace = 2 }))
+hl.bind(mod .. " + 3", hl.dsp.focus({ workspace = 3 }))
+hl.bind(mod .. " + 4", hl.dsp.focus({ workspace = 4 }))
+hl.bind(mod .. " + 5", hl.dsp.focus({ workspace = 5 }))
+hl.bind(mod .. " + 6", hl.dsp.focus({ workspace = 6 }))
+hl.bind(mod .. " + 7", hl.dsp.focus({ workspace = 7 }))
+hl.bind(mod .. " + 8", hl.dsp.focus({ workspace = 8 }))
+hl.bind(mod .. " + 9", hl.dsp.focus({ workspace = 9 }))
+
+-- Move window to workspace (from DWM tag)
+hl.bind(mod .. " + SHIFT + 1", hl.dsp.window.move({ workspace = 1, follow = false }))
+hl.bind(mod .. " + SHIFT + 2", hl.dsp.window.move({ workspace = 2, follow = false }))
+hl.bind(mod .. " + SHIFT + 3", hl.dsp.window.move({ workspace = 3, follow = false }))
+hl.bind(mod .. " + SHIFT + 4", hl.dsp.window.move({ workspace = 4, follow = false }))
+hl.bind(mod .. " + SHIFT + 5", hl.dsp.window.move({ workspace = 5, follow = false }))
+hl.bind(mod .. " + SHIFT + 6", hl.dsp.window.move({ workspace = 6, follow = false }))
+hl.bind(mod .. " + SHIFT + 7", hl.dsp.window.move({ workspace = 7, follow = false }))
+hl.bind(mod .. " + SHIFT + 8", hl.dsp.window.move({ workspace = 8, follow = false }))
+hl.bind(mod .. " + SHIFT + 9", hl.dsp.window.move({ workspace = 9, follow = false }))
+
+-- Monitor focus (from DWM focusmon)
+hl.bind(mod .. " + COMMA", hl.dsp.focus({ monitor = -1 }))
+hl.bind(mod .. " + PERIOD", hl.dsp.focus({ monitor = "+1" }))
+hl.bind(mod .. " + SHIFT + COMMA", hl.dsp.window.move({ monitor = "-1" }))
+hl.bind(mod .. " + SHIFT + PERIOD", hl.dsp.window.move({ monitor = "+1" }))
+
+-- ============================================================================
+-- Scratchpads (via pyprland)
+-- ============================================================================
+-- Configured in ~/.config/hypr/pyprland.toml
+-- Uses normal workspaces (not special), so new windows won't be captured
+hl.bind(mod .. " + SHIFT + RETURN", hl.dsp.exec_cmd("pypr toggle term"))
+hl.bind(mod .. " + A", hl.dsp.exec_cmd("audio-panel"))
+hl.bind(mod .. " + R", hl.dsp.exec_cmd("pypr toggle monitor"))
+hl.bind(mod .. " + SHIFT + N", hl.dsp.exec_cmd("net panel"))
+hl.bind(mod .. " + SLASH", hl.dsp.exec_cmd("pypr toggle music"))
+
+-- Magnify (zoom)
+-- mod+Z zooms and enters the "zoom" submap; inside it, Escape or mod+Z
+-- unzooms and returns to the normal keymap. Exit forces `pypr zoom 1`
+-- (factor 1) so submap state and zoom state can't desync. Note: while
+-- zoomed, other Hyprland binds pause until you exit the submap.
+hl.bind(mod .. " + Z", hl.dsp.exec_cmd("pypr zoom"))
+hl.bind(mod .. " + Z", hl.dsp.submap("zoom"))
+
+hl.define_submap("zoom", function()
+ hl.bind("ESCAPE", hl.dsp.exec_cmd("pypr zoom 1"))
+ hl.bind("ESCAPE", hl.dsp.submap("reset"))
+ hl.bind(mod .. " + Z", hl.dsp.exec_cmd("pypr zoom 1"))
+ hl.bind(mod .. " + Z", hl.dsp.submap("reset"))
+end)
+
+-- Calculator (not a scratchpad, just launches app)
+hl.bind(mod .. " + X", hl.dsp.exec_cmd("calc-toggle"))
+hl.bind(mod .. " + C", hl.dsp.exec_cmd("hyprpicker -a"))
+hl.bind(mod .. " + CONTROL + C", hl.dsp.exec_cmd("clock-panel toggle"))
+
+-- Media/hardware keys
+hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("pactl set-sink-volume @DEFAULT_SINK@ +5%"), { locked = true, repeating = true })
+hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("pactl set-sink-volume @DEFAULT_SINK@ -5%"), { locked = true, repeating = true })
+hl.bind("XF86AudioMute", hl.dsp.exec_cmd("audio quick-mute"), { locked = true })
+hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl s +10%"), { locked = true, repeating = true })
+hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl s 10%-"), { locked = true, repeating = true })
+
+-- Microphone mute toggle (waybar pulseaudio#mic indicator follows via PipeWire events).
+-- On the hardware mic-mute key. Super+Shift+A used to duplicate this; it now
+-- toggles push-to-talk mode instead (mic-toggle stays reachable on the hw key).
+hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("mic-toggle"), { locked = true })
+
+-- Push-to-talk toggle: enter PTT (mic muted, hold key armed) / exit (restore).
+-- The hold key is configurable (audio config ptt_key = Control_R or mouse:NNN).
+hl.bind(mod .. " + SHIFT + A", hl.dsp.exec_cmd("audio ptt-toggle"))
+
+-- Bluetooth panel (blueman retired in favor of the bt panel)
+hl.bind(mod .. " + SHIFT + B", hl.dsp.exec_cmd("bt-panel"))
+
+-- Screenshots (grim + slurp + fuzzel menu)
+-- Shift+S captures the whole desktop with no pointer interaction, so it
+-- works on scratchpads and popups that region-select would dismiss
+hl.bind(mod .. " + S", hl.dsp.exec_cmd("screenshot region"))
+hl.bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd("screenshot fullscreen"))
+hl.bind("CTRL" .. mod .. " + S", hl.dsp.exec_cmd("screenshot fullscreen"))
+
+-- Lock screen
+hl.bind(mod .. " + ESCAPE", hl.dsp.exec_cmd("hyprlock"))
+
+-- Audio mute cycle (M for Mute): one key walks volume/mic through all four
+-- on/off combinations. The waybar pulseaudio modules follow via PipeWire.
+hl.bind(mod .. " + M", hl.dsp.exec_cmd("audio-cycle"))
+
+-- Exit/session
+hl.bind(mod .. " + SHIFT + Q", hl.dsp.exec_cmd("pgrep -x wlogout || wlogout-menu"))
+-- mod+Shift+Backspace no longer exits outright — it enters the "exitconfirm"
+-- submap, where a second Backspace confirms and anything else backs out.
+-- The bare bind killed two live sessions on 2026-07-22: it sits one key from
+-- the mod+Shift cluster (Q wlogout, C killactive, Return terminal, G settings)
+-- and took the whole session down with no prompt. wlogout owns the normal
+-- exit path; this stays as the keyboard escape hatch for when wlogout won't
+-- come up, so it's gated rather than removed.
+hl.bind(mod .. " + SHIFT + BACKSPACE", hl.dsp.submap("exitconfirm"))
+
+hl.define_submap("exitconfirm", function()
+ hl.bind("BACKSPACE", hl.dsp.exit())
+ hl.bind("ESCAPE", hl.dsp.submap("reset"))
+ hl.bind("catchall", hl.dsp.submap("reset"))
+end)
+
+hl.bind(mod .. " + SHIFT + ESCAPE", hl.dsp.exec_cmd("hyprctl reload"))
+hl.bind("CTRL + ALT" .. mod .. " + K", hl.dsp.exec_cmd("hyprctl kill"))
+
+-- Mouse bindings (from DWM buttons)
+hl.bind(mod .. " + mouse:272", hl.dsp.window.drag())
+hl.bind(mod .. " + mouse:273", hl.dsp.window.resize())
+hl.bind(mod .. " + SHIFT + mouse:272", hl.dsp.window.resize())
+
+-- ============================================================================
+-- Machine-local overrides
+-- ============================================================================
+-- Sourced last so machine-specific settings (monitor scale, gaps, keybinds)
+-- override the defaults above. See conf.d/local.conf.
+-- Source: $HOME/.config/hypr/conf.d/*.conf (glob; resolved at runtime). Each matched .conf must be converted to .lua.
+hl_source_glob("$HOME/.config/hypr/conf.d/*.lua")
+
+hl.on("hyprland.start", function()
+ hl.exec_cmd("dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP HYPRLAND_INSTANCE_SIGNATURE")
+ hl.exec_cmd("systemctl --user start hyprland-session.target; systemctl --user restart xdg-desktop-portal-hyprland xdg-desktop-portal-gtk; systemctl --user restart xdg-desktop-portal; waybar-active-config && waybar -c \"$XDG_RUNTIME_DIR/waybar/config\" -s ~/.config/waybar/style.css 2>&1 | grep -v \"LIBDBUSMENU-GLIB-WARNING\" > ~/.local/var/log/waybar-$(date +%Y-%m-%d-%H%M%S).log")
+ hl.exec_cmd("/usr/lib/polkit-kde-authentication-agent-1")
+ hl.exec_cmd("/usr/bin/gnome-keyring-daemon --start --components=pkcs11,secrets,ssh")
+ hl.exec_cmd("dunst > ~/.local/var/log/dunst-$(date +%Y-%m-%d-%H%M%S).log 2>&1")
+ hl.exec_cmd("awww-daemon & sleep 1 && { settings restore-wallpaper || waypaper --restore; }")
+ hl.exec_cmd("touchpad-auto")
+ hl.exec_cmd("pkill -x hypridle; hypridle-start > ~/.local/var/log/hypridle-$(date +%Y-%m-%d-%H%M%S).log 2>&1")
+ hl.exec_cmd("/usr/lib/geoclue-2.0/demos/agent")
+ hl.exec_cmd("gammastep > ~/.local/var/log/gammastep-$(date +%Y-%m-%d-%H%M%S).log 2>&1")
+ hl.exec_cmd("mpd")
+ hl.exec_cmd("settings restore > ~/.local/var/log/settings-restore-$(date +%Y-%m-%d-%H%M%S).log 2>&1")
+ hl.exec_cmd("pypr > ~/.local/var/log/pypr-$(date +%Y-%m-%d-%H%M%S).log 2>&1")
+ hl.exec_cmd("hypr-refocus-scratchpad")
+ hl.exec_cmd("wait-for-tray && signal-desktop --start-in-tray --ozone-platform=wayland")
+ hl.exec_cmd("env QT_FONT_DPI=108 protonmail-bridge --no-window")
+end)
+
+hl.on("config.reloaded", function()
+ hl.exec_cmd("for i in 1 2 3; do sleep 0.2; waybar-reserve; done")
+end)
+
diff --git a/working/hyprland-lua-port/ratio-local.lua b/working/hyprland-lua-port/ratio-local.lua
new file mode 100644
index 0000000..de190d5
--- /dev/null
+++ b/working/hyprland-lua-port/ratio-local.lua
@@ -0,0 +1,43 @@
+-- ratio — desktop, 1x scaling. Defaults in hyprland.lua are correct.
+-- Sourced via conf.d/*.lua glob (last wins).
+--
+-- Examples:
+-- hl.monitor({ output = "DP-1", mode = "3440x1440@144", position = "auto", scale = 1 })
+-- hl.bind("SUPER + L", hl.dsp.exec_cmd("hyprlock"))
+--
+-- Spell the modifier out. A sourced file is loaded by hl_source_glob via
+-- loadfile, which gives the chunk globals only, so the shared config's `mod`
+-- (and `at_start`, and the other locals) are NOT in scope here.
+
+-- DP-4 (Dell U3419W ultrawide) pinned to its native mode. Without an explicit
+-- pin the shared catch-all monitor=,preferred,auto,auto lets an XWayland surface
+-- (emacs runs X11-only on ratio) drive the mode down to 1280x720 at login. That
+-- low mode also wipes DP-4's reserved area, which drops the non-exclusive waybar
+-- off-screen (margin-top:-54 needs the 60px top reserve). Pinning holds both.
+
+hl.monitor({
+ output = "DP-4",
+ mode = "3440x1440@60",
+ position = "0x0",
+ scale = "1",
+})
+
+-- org-capture popup: capped at 120 Emacs columns wide, height proportional
+-- (Craig, 2026-07-14). 120 cols x 11 px/col = 1320 wide; height keeps the old
+-- rule's aspect (1892:936) = 653 px (~27 lines at 24 px). The old 55% x 65%
+-- scratchpad match (1892 x 936) was the "grows too large" complaint. max_size
+-- holds the cap even if the frame tries to grow with capture content.
+hl.window_rule({
+ match = {
+ title = "^(org-capture)$",
+ },
+ size = "1320 653",
+})
+
+hl.window_rule({
+ match = {
+ title = "^(org-capture)$",
+ },
+ max_size = "1320 653",
+})
+
diff --git a/working/hyprland-lua-port/reader-changes-for-lua.patch b/working/hyprland-lua-port/reader-changes-for-lua.patch
new file mode 100644
index 0000000..747b047
--- /dev/null
+++ b/working/hyprland-lua-port/reader-changes-for-lua.patch
@@ -0,0 +1,241 @@
+diff --git a/common/.local/bin/dotfiles-validate b/common/.local/bin/dotfiles-validate
+index 57a7505..d5e6695 100755
+--- a/common/.local/bin/dotfiles-validate
++++ b/common/.local/bin/dotfiles-validate
+@@ -3,6 +3,7 @@
+ #
+ # Walks the tree and extracts the commands that configs promise to launch:
+ # - hypr conf files: exec-once = CMD / exec = CMD / bind* = ..., exec, CMD
++# - hypr lua configs: at_start/at_shutdown/at_reload("CMD"), exec_cmd("CMD")
+ # - waybar config: "exec(-if)", "on-click*", "on-scroll-*",
+ # "on-double-click" values
+ # - systemd user units: Exec*= lines (leading -/@ modifiers stripped)
+@@ -45,6 +46,30 @@ find "$root" -path '*/.config/hypr/*.conf' -type f 2>/dev/null | while read -r f
+ ' "$f"
+ done >> "$refs_file"
+
++# --- hypr lua configs: autostart collectors and exec_cmd dispatchers ---
++# The Lua config manager (Hyprland 0.55+) spells the same two things as function
++# calls rather than assignments, so the .conf walk above sees none of them. Only
++# the first word is taken, as everywhere else here. The two matches are written
++# out rather than folded into a helper: awk cannot take a regex literal as a
++# function parameter -- it collapses to a boolean match against $0, which
++# silently turns every line into a bogus reference.
++find "$root" -path '*/.config/hypr/*.lua' -type f 2>/dev/null | while read -r f; do
++ awk -v file="$f" '
++ match($0, /at_(start|shutdown|reload)\("/) {
++ rest = substr($0, RSTART + RLENGTH)
++ sub(/".*$/, "", rest)
++ n = split(rest, w, /[ \t]+/)
++ if (n > 0 && w[1] != "") print file ":" FNR ":" w[1]
++ }
++ match($0, /hl\.dsp\.exec_cmd\("/) {
++ rest = substr($0, RSTART + RLENGTH)
++ sub(/".*$/, "", rest)
++ n = split(rest, w, /[ \t]+/)
++ if (n > 0 && w[1] != "") print file ":" FNR ":" w[1]
++ }
++ ' "$f"
++done >> "$refs_file"
++
+ # --- waybar configs: command-bearing JSON values ---
+ find "$root" -path '*/.config/waybar/*' -type f \( -name config -o -name '*.json' -o -name '*.jsonc' \) 2>/dev/null | while read -r f; do
+ awk -v file="$f" '
+diff --git a/tests/layout-cycle/test_layout_cycle.py b/tests/layout-cycle/test_layout_cycle.py
+index 1454817..3740024 100644
+--- a/tests/layout-cycle/test_layout_cycle.py
++++ b/tests/layout-cycle/test_layout_cycle.py
+@@ -18,6 +18,7 @@ Run from repo root:
+
+ import json
+ import os
++import re
+ import subprocess
+ import tempfile
+ import unittest
+@@ -25,7 +26,7 @@ import unittest
+ REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ SCRIPT = os.path.join(REPO_ROOT, "hyprland/.local/bin/layout-cycle")
+ FAKE_HYPRCTL = os.path.join(os.path.dirname(__file__), "fake-hyprctl")
+-HYPRLAND_CONF = os.path.join(REPO_ROOT, "hyprland/.config/hypr/hyprland.conf")
++HYPRLAND_CFG = os.path.join(REPO_ROOT, "hyprland/.config/hypr/hyprland.lua")
+
+ FLASH = "rgba(ffd24aff)"
+ ACTIVE = "rgba(daa520ff)"
+@@ -326,7 +327,7 @@ class TestFlashAllBorders(LayoutCycleHarness):
+
+
+ class TestScrollLayoutDisabledInConfig(unittest.TestCase):
+- """Pin the hyprland.conf half of the scrolling disable.
++ """Pin the hyprland.lua half of the scrolling disable.
+
+ The script tests above prove the ring skips scrolling; these prove no
+ keybinding reaches it either, and that the freed Super+Shift+S chord
+@@ -335,10 +336,20 @@ class TestScrollLayoutDisabledInConfig(unittest.TestCase):
+
+ @classmethod
+ def setUpClass(cls):
+- with open(HYPRLAND_CONF) as f:
++ with open(HYPRLAND_CFG) as f:
+ cls.conf = f.read()
++ # Every bind in the Lua config is a top-level hl.bind() call; the
++ # locked/mouse/repeat variants that hyprlang spelled bindl/bindm/binde
++ # are the same call with an options table, so one prefix covers them.
+ cls.binds = [l for l in cls.conf.splitlines()
+- if l.strip().startswith(("bind", "bindl", "bindm"))]
++ if l.strip().startswith("hl.bind")]
++
++ def test_the_binds_were_actually_found(self):
++ """Guard the guard: a renamed call would empty the list and make both
++ assertions below pass against nothing."""
++ self.assertGreater(len(self.binds), 50,
++ "found almost no hl.bind lines -- the two assertions "
++ "below would pass vacuously")
+
+ def test_no_bind_selects_scrolling_layout(self):
+ offenders = [l for l in self.binds if "general:layout scrolling" in l]
+@@ -346,7 +357,7 @@ class TestScrollLayoutDisabledInConfig(unittest.TestCase):
+
+ def test_super_shift_s_is_fullscreen_screenshot(self):
+ shift_s = [l for l in self.binds
+- if "$mod SHIFT, S," in l]
++ if re.search(r'\bmod\s*\.\.\s*"\s*\+\s*SHIFT\s*\+\s*S"', l)]
+ self.assertEqual(len(shift_s), 1, msg=f"binds found: {shift_s}")
+ self.assertIn("screenshot fullscreen", shift_s[0])
+
+diff --git a/tests/settings/test_session_restore.py b/tests/settings/test_session_restore.py
+index a989d52..b2a2721 100644
+--- a/tests/settings/test_session_restore.py
++++ b/tests/settings/test_session_restore.py
+@@ -585,12 +585,32 @@ class TestCompositorWiring(unittest.TestCase):
+ script and passing tests while never being placed in the bar.
+ """
+
+- CONF = os.path.join(REPO_ROOT, "hyprland/.config/hypr/hyprland.conf")
++ CONF = os.path.join(REPO_ROOT, "hyprland/.config/hypr/hyprland.lua")
+
+ def _lines(self):
++ """The autostart commands, in launch order.
++
++ The Lua config collects startup commands with at_start("CMD") and one
++ hl.on("hyprland.start") handler at the bottom replays the list in order,
++ so position in this file is still position at launch -- which is what
++ the ordering assertion below reads. Returning the command strings rather
++ than raw lines means every entry here is by construction an autostart
++ command, so the old startswith("exec-once") filter has nothing left to do.
++ """
++ out = []
+ with open(self.CONF) as f:
+- return [ln.strip() for ln in f
+- if ln.strip() and not ln.strip().startswith("#")]
++ for ln in f:
++ m = re.search(r'at_start\("(.*)"\)', ln.strip())
++ if m:
++ out.append(m.group(1))
++ return out
++
++ def test_the_autostart_commands_were_actually_found(self):
++ """Guard the guard: a renamed collector would empty the list and make
++ every assertion below pass against nothing."""
++ self.assertGreater(len(self._lines()), 10,
++ "found almost no at_start commands -- the assertions "
++ "below would pass vacuously")
+
+ @staticmethod
+ def _is_toggle_restore(line):
+@@ -607,22 +627,19 @@ class TestCompositorWiring(unittest.TestCase):
+
+ def test_restore_runs_at_session_start(self):
+ self.assertTrue(
+- any(ln.startswith("exec-once") and self._is_toggle_restore(ln)
+- for ln in self._lines()),
+- "hyprland.conf has no exec-once running `settings restore` — "
++ any(self._is_toggle_restore(ln) for ln in self._lines()),
++ "hyprland.lua has no at_start running `settings restore` — "
+ "remembered toggles would never be replayed")
+
+ def test_wallpaper_restore_runs_at_session_start(self):
+ self.assertTrue(
+- any(ln.startswith("exec-once") and "settings restore-wallpaper" in ln
+- for ln in self._lines()),
+- "hyprland.conf has no exec-once running `settings restore-wallpaper` "
++ any("settings restore-wallpaper" in ln for ln in self._lines()),
++ "hyprland.lua has no at_start running `settings restore-wallpaper` "
+ "— the stored wallpaper would never be put back")
+
+ def test_the_toggle_restore_runs_only_once(self):
+ """Twice means the whole re-assert budget is spent twice per login."""
+- lines = [ln for ln in self._lines()
+- if ln.startswith("exec-once") and self._is_toggle_restore(ln)]
++ lines = [ln for ln in self._lines() if self._is_toggle_restore(ln)]
+ self.assertEqual(len(lines), 1, lines)
+
+ def test_restore_is_ordered_after_the_backings_it_corrects(self):
+@@ -631,17 +648,15 @@ class TestCompositorWiring(unittest.TestCase):
+ # burn attempts on backings that aren't up yet.
+ lines = self._lines()
+ restore = next(i for i, ln in enumerate(lines)
+- if ln.startswith("exec-once")
+- and self._is_toggle_restore(ln))
++ if self._is_toggle_restore(ln))
+ for backing in ("hypridle", "dunst"):
+- launch = next(i for i, ln in enumerate(lines)
+- if ln.startswith("exec-once") and backing in ln)
++ launch = next(i for i, ln in enumerate(lines) if backing in ln)
+ self.assertLess(launch, restore,
+ f"`settings restore` is ordered before {backing}")
+
+
+ class DimEnv(TempEnv):
+- """Adds the hyprctl fake, whose dim default is 1 -- as hyprland.conf's is."""
++ """Adds the hyprctl fake, whose dim default is 1 -- as hyprland.lua's is."""
+
+ def setUp(self):
+ super().setUp()
+diff --git a/tests/waybar-reserve/test_reserve_pairing.py b/tests/waybar-reserve/test_reserve_pairing.py
+index 7421692..fbeb95d 100644
+--- a/tests/waybar-reserve/test_reserve_pairing.py
++++ b/tests/waybar-reserve/test_reserve_pairing.py
+@@ -26,7 +26,7 @@ import unittest
+
+ REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+ WAYBAR = os.path.join(REPO_ROOT, "hyprland/.config/waybar/config")
+-HYPR = os.path.join(REPO_ROOT, "hyprland/.config/hypr/hyprland.conf")
++HYPR = os.path.join(REPO_ROOT, "hyprland/.config/hypr/hyprland.lua")
+
+
+ def waybar_config():
+@@ -35,13 +35,19 @@ def waybar_config():
+
+
+ def reserve_exec_wired():
+- """True when hyprland.conf runs waybar-reserve via exec (not exec-once).
++ """True when the config re-runs waybar-reserve on every config reload.
+
+- Matches waybar-reserve invoked anywhere in an ``exec =`` line, so the
+- reload-race-safe retry-loop form (``exec = for i in 1 2 3; do sleep 0.2;
+- waybar-reserve; done``) counts the same as a bare ``exec = waybar-reserve``."""
++ Matches waybar-reserve invoked anywhere in an ``at_reload(...)`` line, so the
++ reload-race-safe retry-loop form (``at_reload("for i in 1 2 3; do sleep 0.2;
++ waybar-reserve; done")``) counts the same as a bare ``at_reload("waybar-reserve")``.
++
++ at_reload and not at_start, because hyprlang's ``exec`` ran at startup AND on
++ every reload, and the Lua port splits those two jobs. at_reload's handler is
++ ``hl.on("config.reloaded")``, which fires on the initial load as well, so it
++ alone covers both. Matching at_start instead would pass while reservations
++ died on the next reload -- which is exactly the defect this guards."""
+ with open(HYPR) as f:
+- return any(re.match(r"\s*exec\s*=.*\bwaybar-reserve\b", ln) for ln in f)
++ return any(re.match(r'\s*at_reload\(".*\bwaybar-reserve\b', ln) for ln in f)
+
+
+ def reserve_target():
+@@ -61,7 +67,7 @@ class ReservePairingHarness(unittest.TestCase):
+
+ def test_hyprland_wires_the_reserve_script(self):
+ self.assertTrue(reserve_exec_wired(),
+- "hyprland.conf lacks `exec = waybar-reserve`: reservations "
++ "hyprland.lua lacks `at_reload(... waybar-reserve ...)`: reservations "
+ "die on the next config reload")
+
+ def test_reserve_target_covers_the_bar(self):
diff --git a/working/hyprland-lua-port/test-desktop-for-lua.patch b/working/hyprland-lua-port/test-desktop-for-lua.patch
new file mode 100644
index 0000000..5bf7612
--- /dev/null
+++ b/working/hyprland-lua-port/test-desktop-for-lua.patch
@@ -0,0 +1,50 @@
+diff --git a/scripts/testing/tests/test_desktop.py b/scripts/testing/tests/test_desktop.py
+index 1538d6a..ca2f266 100644
+--- a/scripts/testing/tests/test_desktop.py
++++ b/scripts/testing/tests/test_desktop.py
+@@ -11,6 +11,8 @@ installs `awww` (swww successor) and `pacman -Q swww` no longer matches — so
+ this checks awww. That divergence from the shell sweep is a correctness fix.
+ """
+
++import re
++
+ import pytest
+
+
+@@ -19,8 +21,12 @@ HYPRLAND_TOOLS = [
+ "awww", "grim", "slurp", "gammastep", "foot",
+ ]
+
++# Note: hyprland.lua sits beside two .conf files on purpose. Only Hyprland's own
++# config format is deprecated (removed in 0.57); hypridle and hyprlock are
++# separate projects still on hyprlang, so their .conf names are current. Don't
++# "fix" this list for consistency -- that reverts the Lua port.
+ HYPRLAND_CONFIGS = [
+- ".config/hypr/hyprland.conf",
++ ".config/hypr/hyprland.lua",
+ ".config/hypr/hypridle.conf",
+ ".config/hypr/hyprlock.conf",
+ ".config/waybar/config",
+@@ -139,8 +145,19 @@ def test_bt_panel_wired(host, hyprland_installed, home):
+ waybar = host.file("%s/.config/waybar/config" % home)
+ assert "custom/bluetooth" in waybar.content_string, \
+ "waybar config lacks the custom/bluetooth module"
+- hyprconf = host.file("%s/.config/hypr/hyprland.conf" % home)
+- assert "bt-panel" in hyprconf.content_string, \
+- "hyprland.conf lacks the bt-panel keybind"
++ hypr = host.file("%s/.config/hypr/hyprland.lua" % home).content_string
++ # Assert the dispatcher and the chord, not the bare binary name. "bt-panel"
++ # on its own still appears inside exec_cmd() when the chord that reaches it
++ # is mangled, so the weak form passes on a config where Super+Shift+B does
++ # nothing. That is not hypothetical: converting this config to Lua produced
++ # exactly that defect elsewhere ("CTRL" .. mod .. " + S" collapsing to an
++ # unparseable CTRLSUPER + S, which Hyprland rejects at parse and leaves dead).
++ # One pattern spanning the whole bind, not two independent checks: asserting
++ # the chord and the dispatcher separately passes when they sit on different
++ # lines, and a planned reshuffle of the panel keybinding family is exactly
++ # the change that would separate them.
++ assert re.search(
++ r'hl\.bind\(\s*mod\s*\.\.\s*" \+ SHIFT \+ B",\s*hl\.dsp\.exec_cmd\("bt-panel"\)',
++ hypr), "hyprland.lua lacks the Super+Shift+B bind reaching bt-panel"
+ assert host.file("%s/.config/themes/dupre/panel.css" % home).exists, \
+ "shared panel css missing from the stowed theme"
diff --git a/working/hyprland-lua-port/velox-local.lua b/working/hyprland-lua-port/velox-local.lua
new file mode 100644
index 0000000..1da7359
--- /dev/null
+++ b/working/hyprland-lua-port/velox-local.lua
@@ -0,0 +1,64 @@
+-- velox — Framework 13, HiDPI 2256x1504. Sourced via conf.d/*.lua glob;
+-- values here override hyprland.lua (last wins).
+
+hl.monitor({
+ output = "eDP-1",
+ mode = "preferred",
+ position = "auto",
+ scale = "1.566667",
+})
+
+-- Scaling on this panel comes from the compositor alone.
+--
+-- There used to be `env = GDK_SCALE,1.5` and `env = QT_SCALE_FACTOR,1.5` here,
+-- to compensate for `xwayland:force_zero_scaling = true` in hyprland.lua, which
+-- makes XWayland clients render unscaled and therefore tiny. That worked for
+-- XWayland and broke everything else, because env vars reach every app, not just
+-- the XWayland ones. Qt 6 and GTK on Wayland already take their scale from the
+-- compositor, so the factor multiplied the monitor scale above: Qt saw a 960x640
+-- logical screen instead of 1440x960 and drew half again too large. Measured
+-- with QScreen.geometry on 2026-08-19; 2256/1.566667 is exactly 1440, which is
+-- what Qt reports once nothing overrides it.
+--
+-- Turning force_zero_scaling off for this host is the other half. XWayland then
+-- scales through the compositor, so those apps come out the right size and a
+-- little soft rather than sharp and tiny. That cost lands only on XWayland,
+-- which is the thing I avoid anyway, and correctly-sized beats crisp for the
+-- occasional Zoom window the browser spawns.
+--
+-- ratio needs neither: its monitor runs at scale 1, so nothing multiplies and
+-- XWayland has nothing to compensate for.
+hl.config({
+ xwayland = {
+ force_zero_scaling = false,
+ },
+})
+
+-- calibre renders oversized at the 1.57 compositor scale. Pin its own DPI so its
+-- UI is comfortable without touching the desktop or other apps (validated at 96,
+-- 2026-06-27). CALIBRE_OVERRIDE_DPI is calibre-only, so a session-wide env is safe.
+hl.env("CALIBRE_OVERRIDE_DPI", "96")
+-- No XCURSOR_SIZE here: Hyprland scales the native cursor by the monitor scale
+-- already, so the shared default (24) renders correctly on HiDPI. Pre-scaling it
+-- (the old 36 = 24 x 1.5) double-applied on top of the compositor's scale.
+
+-- org-capture popup: match the terminal scratchpad (75% x 70% of the 1437x958
+-- logical desktop = 1078 x 671 px). Native window rules ignore percentages, so
+-- it's pinned in pixels here rather than in the shared hyprland.lua.
+hl.window_rule({
+ match = {
+ title = "^(org-capture)$",
+ },
+ size = "1078 671",
+})
+
+-- Growth cap (Craig, 2026-07-14): the frame must never outgrow its pinned
+-- size even when capture content pushes it. 1078 px is ~98 Emacs columns,
+-- already under the 120-column cap ratio uses.
+hl.window_rule({
+ match = {
+ title = "^(org-capture)$",
+ },
+ max_size = "1078 671",
+})
+
diff --git a/working/keyring-crash/deploy-ratio.sh b/working/keyring-crash/deploy-ratio.sh
new file mode 100644
index 0000000..f86e290
--- /dev/null
+++ b/working/keyring-crash/deploy-ratio.sh
@@ -0,0 +1,65 @@
+#!/bin/bash
+# Apply the patched gnome-keyring package to a live host (ratio, later velox).
+#
+# Runs only after the VM gates passed and only when Craig says go. Every step
+# is reversible: the encrypted keyring files are backed up with their modes,
+# the stock package stays in the pacman cache for a one-command rollback, and
+# only the keyring service is restarted (one unlock prompt is expected, to be
+# answered in the normal system dialog, never in chat).
+#
+# deploy-ratio.sh build build the package from the repo recipe (as the user)
+# deploy-ratio.sh install back up, install, restart the daemon, verify
+# deploy-ratio.sh rollback reinstall the cached stock 1:51.1-1 and restart
+# deploy-ratio.sh verify show versions, owner, restart counter, recent crashes
+set -euo pipefail
+
+repo="${ARCHSETUP_DIR:-$HOME/code/archsetup}"
+recipe="$repo/packages/gnome-keyring"
+build_dir="${BUILD_DIR:-$HOME/.cache/archsetup-build/gnome-keyring}"
+stock_pkg="/var/cache/pacman/pkg/gnome-keyring-1:51.1-1-x86_64.pkg.tar.zst"
+backup_root="$HOME/.local/share/keyrings-backup"
+
+verify() {
+ echo "host: $(uname -n)"
+ pacman -Q gnome-keyring glib2
+ systemctl --user show gnome-keyring-daemon.service -p ActiveState -p NRestarts -p MainPID -p ExecMainStartTimestamp
+ busctl --user status org.freedesktop.secrets 2>/dev/null | grep -E '^(PID|Comm)=' || echo "no Secret Service owner"
+ echo "aborts in the last hour: $(journalctl --user -u gnome-keyring-daemon.service --since '1 hour ago' --no-pager 2>/dev/null | grep -c 'Scheduled restart job')"
+ sha256sum /usr/bin/gnome-keyring-daemon
+}
+
+case "${1:-}" in
+build)
+ [ -f "$recipe/PKGBUILD" ] || { echo "recipe missing: $recipe" >&2; exit 1; }
+ rm -rf "$build_dir"; mkdir -p "$build_dir"; cp -a "$recipe/." "$build_dir/"
+ (cd "$build_dir" && makepkg --noconfirm -s)
+ ls -la "$build_dir"/*.pkg.tar.zst
+ ;;
+install)
+ pkg=$(ls "$build_dir"/gnome-keyring-1:51.1-1.1-x86_64.pkg.tar.zst)
+ [ -f "$stock_pkg" ] || { echo "stock package not in cache for rollback: $stock_pkg" >&2; exit 1; }
+ # Permission-protected backup of the encrypted keyring files, modes kept.
+ stamp=$(date +%Y%m%d-%H%M%S)
+ mkdir -p "$backup_root"; chmod 700 "$backup_root"
+ cp -a "$HOME/.local/share/keyrings" "$backup_root/keyrings-$stamp"
+ chmod -R go-rwx "$backup_root"
+ echo "backup: $backup_root/keyrings-$stamp"
+ sudo pacman -U --noconfirm "$pkg"
+ # Only the keyring service restarts; the socket unit stays. One unlock
+ # prompt is expected afterwards.
+ systemctl --user restart gnome-keyring-daemon.service
+ sleep 2
+ verify
+ ;;
+rollback)
+ sudo pacman -U --noconfirm "$stock_pkg"
+ systemctl --user restart gnome-keyring-daemon.service
+ sleep 2
+ verify
+ ;;
+verify)
+ verify
+ ;;
+*)
+ sed -n '2,13p' "$0"; exit 2 ;;
+esac
diff --git a/working/keyring-crash/gnome-keyring-51.1-secret-service-missing-client.patch b/working/keyring-crash/gnome-keyring-51.1-secret-service-missing-client.patch
new file mode 100644
index 0000000..9ef298b
--- /dev/null
+++ b/working/keyring-crash/gnome-keyring-51.1-secret-service-missing-client.patch
@@ -0,0 +1,275 @@
+diff -ruN a/daemon/dbus/gkd-secret-objects.c b/daemon/dbus/gkd-secret-objects.c
+--- a/daemon/dbus/gkd-secret-objects.c 2026-09-17 15:41:00.000000000 -0500
++++ b/daemon/dbus/gkd-secret-objects.c 2026-10-17 06:47:19.275009074 -0500
+@@ -430,7 +430,11 @@
+
+ /* The session we're using to access the object */
+ session = gkd_secret_service_get_pkcs11_session (self->service, sender);
+- g_return_val_if_fail (session, FALSE);
++ if (session == NULL) {
++ g_free (c_ident);
++ g_free (i_ident);
++ goto out;
++ }
+
+ if (i_ident) {
+ gck_builder_add_ulong (&builder, CKA_CLASS, CKO_SECRET_KEY);
+@@ -1055,17 +1059,23 @@
+ if (!gkd_secret_util_parse_path (path, &identifier, NULL))
+ return NULL;
+
+- if (g_str_has_prefix (path, SECRET_ALIAS_PREFIX))
+- real_identifier = gkd_secret_service_get_alias (self->service, identifier);
+- else
+- real_identifier = identifier;
+-
+- /* The session we're using to access the object */
++ /* The session we're using to access the object. Resolve it before the
++ * alias: fetching a caller's session may register the caller, which
++ * refreshes the default alias and frees the string the alias table
++ * held, so an alias looked up earlier would dangle here. */
+ if (caller == NULL)
+ session = gkd_secret_service_internal_pkcs11_session (self->service);
+ else
+ session = gkd_secret_service_get_pkcs11_session (self->service, caller);
+- g_return_val_if_fail (session, NULL);
++ if (session == NULL) {
++ g_free (identifier);
++ return NULL;
++ }
++
++ if (g_str_has_prefix (path, SECRET_ALIAS_PREFIX))
++ real_identifier = gkd_secret_service_get_alias (self->service, identifier);
++ else
++ real_identifier = identifier;
+
+ gck_builder_add_ulong (&builder, CKA_CLASS, CKO_G_COLLECTION);
+ gck_builder_add_string (&builder, CKA_ID, real_identifier);
+diff -ruN a/daemon/dbus/gkd-secret-service.c b/daemon/dbus/gkd-secret-service.c
+--- a/daemon/dbus/gkd-secret-service.c 2026-09-17 15:41:00.000000000 -0500
++++ b/daemon/dbus/gkd-secret-service.c 2026-10-17 06:47:19.274715149 -0500
+@@ -137,6 +137,9 @@
+ GHashTable *clients;
+ GkdSecretObjects *objects;
+ GHashTable *aliases;
++ /* The message filter reads aliases on the GDBus worker thread while
++ * the main thread updates them; every access goes through this lock. */
++ GMutex aliases_lock;
+ GckSession *internal_session;
+ gchar *default_path;
+ };
+@@ -190,6 +193,18 @@
+ return g_build_filename (alias_directory, "default", NULL);
+ }
+
++/* An owned copy of an alias, safe to call from any thread. */
++static gchar *
++lookup_alias_copy (GkdSecretService *self, const gchar *alias)
++{
++ gchar *copy;
++
++ g_mutex_lock (&self->aliases_lock);
++ copy = g_strdup (g_hash_table_lookup (self->aliases, alias));
++ g_mutex_unlock (&self->aliases_lock);
++ return copy;
++}
++
+ static void
+ update_default (GkdSecretService *self)
+ {
+@@ -206,21 +221,31 @@
+ /* Default to to 'login' if no default keyring */
+ if (contents == NULL)
+ contents = g_strdup ("login");
+- g_hash_table_replace (self->aliases, g_strdup ("default"), contents);
++
++ /* Replace only on a real change: callers on the main thread hold the
++ * previous value borrowed, and the common case (another client
++ * registering) must not free it out from under them. */
++ g_mutex_lock (&self->aliases_lock);
++ if (g_strcmp0 (g_hash_table_lookup (self->aliases, "default"), contents) == 0)
++ g_free (contents);
++ else
++ g_hash_table_replace (self->aliases, g_strdup ("default"), contents);
++ g_mutex_unlock (&self->aliases_lock);
+ }
+
+ static void
+ store_default (GkdSecretService *self)
+ {
+ GError *error = NULL;
+- const gchar *identifier;
++ gchar *identifier;
+
+- identifier = g_hash_table_lookup (self->aliases, "default");
++ identifier = lookup_alias_copy (self, "default");
+ if (!identifier)
+ return;
+
+ if (!g_file_set_contents (self->default_path, identifier, -1, &error))
+ g_message ("couldn't store default keyring: %s", egg_error_message (error));
++ g_free (identifier);
+ }
+
+ static gboolean
+@@ -331,7 +356,7 @@
+ GDBusMessage *message)
+ {
+ const char *path = g_dbus_message_get_path (message);
+- const char *replace;
++ char *replace;
+ char *collection = NULL, *item = NULL;
+ char *collection_path, *item_path;
+ GDBusMessage *rewritten;
+@@ -346,7 +371,9 @@
+ if (!gkd_secret_util_parse_path (path, &collection, &item))
+ return message;
+
+- replace = gkd_secret_service_get_alias (self, collection);
++ /* This runs on the GDBus worker thread: take a copy under the lock
++ * rather than borrowing from a table the main thread may be updating. */
++ replace = lookup_alias_copy (self, collection);
+ if (!replace) {
+ g_free (item);
+ g_free (collection);
+@@ -356,6 +383,9 @@
+ rewritten = g_dbus_message_copy (message, &error);
+ if (error != NULL) {
+ g_error_free (error);
++ g_free (replace);
++ g_free (item);
++ g_free (collection);
+ return message;
+ }
+
+@@ -372,6 +402,7 @@
+ }
+
+ g_free (collection_path);
++ g_free (replace);
+ g_free (item);
+ g_free (collection);
+ g_object_unref (message);
+@@ -424,18 +455,30 @@
+ GError *error = NULL;
+ const gchar *caller;
+ GVariant *input_payload;
++ gboolean opened;
+
+ caller = g_dbus_method_invocation_get_sender (invocation);
+
+ /* Now we can create a session with this information */
+ session = gkd_secret_session_new (self, caller);
+ input_payload = g_variant_get_variant (input);
+- gkd_secret_session_handle_open (session, algorithm, input_payload,
+- &output, &result,
+- &error);
++ opened = gkd_secret_session_handle_open (session, algorithm, input_payload,
++ &output, &result,
++ &error);
+ g_variant_unref (input_payload);
+
+- if (error != NULL) {
++ /* Branch on the return value, not on the error alone: a negotiate helper
++ * that bails out through g_return_val_if_fail() reports failure without
++ * ever setting one, and completing the call with a NULL body makes
++ * g_variant_new() abort the whole daemon. */
++ if (!opened || output == NULL || result == NULL) {
++ if (error == NULL)
++ g_set_error_literal (&error, G_DBUS_ERROR,
++ G_DBUS_ERROR_FAILED,
++ "Couldn't open a session for this caller.");
++ if (output != NULL)
++ g_variant_unref (g_variant_ref_sink (output));
++ g_free (result);
+ g_dbus_method_invocation_take_error (invocation, error);
+ } else {
+ gkd_secret_service_publish_dispatch (self, caller,
+@@ -986,6 +1029,7 @@
+ static void
+ gkd_secret_service_init_aliases (GkdSecretService *self)
+ {
++ g_mutex_init (&self->aliases_lock);
+ self->aliases = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, g_free);
+ g_hash_table_insert (self->aliases, g_strdup ("session"), g_strdup ("session"));
+ /* TODO: We should be using CKA_G_LOGIN_COLLECTION */
+@@ -1151,6 +1195,7 @@
+
+ g_hash_table_destroy (self->aliases);
+ self->aliases = NULL;
++ g_mutex_clear (&self->aliases_lock);
+
+ g_free (self->default_path);
+ self->default_path = NULL;
+@@ -1283,7 +1328,18 @@
+ g_return_val_if_fail (caller, NULL);
+
+ client = g_hash_table_lookup (self->clients, caller);
+- g_return_val_if_fail (client, NULL);
++ if (client == NULL) {
++ /* The record is normally created by a high priority idle queued from
++ * the message filter, but that idle is not ordered against the method
++ * call that queued it: a peer which connects and calls straight away
++ * can reach us first. Create it here instead of failing the call --
++ * it is a hash table insert, not I/O. Callers must not hold borrowed
++ * alias strings across this call: registering a client refreshes the
++ * default alias. */
++ gkd_secret_service_ensure_client (self, caller);
++ client = g_hash_table_lookup (self->clients, caller);
++ g_return_val_if_fail (client, NULL);
++ }
+
+ /* Open a new session if necessary */
+ if (!client->pkcs11_session) {
+@@ -1399,7 +1455,9 @@
+ g_return_if_fail (GKD_SECRET_IS_SERVICE (self));
+ g_return_if_fail (alias);
+
++ g_mutex_lock (&self->aliases_lock);
+ g_hash_table_replace (self->aliases, g_strdup (alias), g_strdup (identifier));
++ g_mutex_unlock (&self->aliases_lock);
+
+ if (g_str_equal (alias, "default"))
+ store_default (self);
+@@ -1418,6 +1476,10 @@
+
+ /* Take ownership of the session */
+ client = g_hash_table_lookup (self->clients, caller);
++ if (client == NULL) {
++ gkd_secret_service_ensure_client (self, caller);
++ client = g_hash_table_lookup (self->clients, caller);
++ }
+ g_return_if_fail (client);
+ path = gkd_secret_dispatch_get_object_path (object);
+ g_return_if_fail (!g_hash_table_lookup (client->dispatch, path));
+diff -ruN a/daemon/dbus/gkd-secret-session.c b/daemon/dbus/gkd-secret-session.c
+--- a/daemon/dbus/gkd-secret-session.c 2026-09-17 15:41:00.000000000 -0500
++++ b/daemon/dbus/gkd-secret-session.c 2026-10-17 06:47:19.270295281 -0500
+@@ -183,7 +183,12 @@
+ gboolean ret;
+
+ session = gkd_secret_service_get_pkcs11_session (self->service, self->caller);
+- g_return_val_if_fail (session, FALSE);
++ if (session == NULL) {
++ g_set_error_literal (error_out, G_DBUS_ERROR,
++ G_DBUS_ERROR_FAILED,
++ "Couldn't open a session for this caller.");
++ return FALSE;
++ }
+
+ if (!aes_create_dh_keys (session, "ietf-ike-grp-modp-1024", &pub, &priv)) {
+ g_set_error_literal (error_out, G_DBUS_ERROR,
+@@ -250,7 +255,12 @@
+ GckSession *session;
+
+ session = gkd_secret_service_get_pkcs11_session (self->service, self->caller);
+- g_return_val_if_fail (session, FALSE);
++ if (session == NULL) {
++ g_set_error_literal (error_out, G_DBUS_ERROR,
++ G_DBUS_ERROR_FAILED,
++ "Couldn't open a session for this caller.");
++ return FALSE;
++ }
+
+ gck_builder_add_ulong (&builder, CKA_CLASS, CKO_SECRET_KEY);
+ gck_builder_add_ulong (&builder, CKA_KEY_TYPE, CKK_G_NULL);
diff --git a/working/keyring-crash/probe.py b/working/keyring-crash/probe.py
new file mode 100644
index 0000000..b18f7a7
--- /dev/null
+++ b/working/keyring-crash/probe.py
@@ -0,0 +1,300 @@
+#!/usr/bin/env python3
+"""Secret Service stress probe for the disposable keyring guest only.
+
+Every mode drives real GIO D-Bus clients against org.freedesktop.secrets and
+reports what the daemon actually answered, not just that messages were sent.
+The guard at the top refuses to run anywhere but the disposable guest.
+
+Modes:
+ plain OpenSession(plain) then Close, synchronous, one per client
+ dh OpenSession(dh-ietf1024-sha256-aes128-cbc-pkcs7) then Close
+ invalid OpenSession with an unsupported algorithm; expects NotSupported
+ disconnect fire --burst OpenSession calls and drop the connection, no reply read
+ pipeline fire --burst OpenSession calls, await and validate every reply,
+ then Close every session
+ alias a fresh client's first calls are Unlock, Lock and ReadAlias on the
+ default alias path (the lazy client registration happens inside
+ these calls; exercises the alias-string lifetime)
+ isolation client A opens a session; client B must not be able to Close it;
+ A then closes it and B's own session still works
+
+Each batch of --batch clients is followed by a daemon round trip (ReadAlias plus
+the collection's Locked property), so the counters reflect requests the daemon
+actually processed. Exit status is 1 on any error or on a change of bus owner.
+"""
+import argparse
+import collections
+import concurrent.futures
+import json
+import os
+import socket
+import sys
+import time
+
+import gi
+
+gi.require_version("Gio", "2.0")
+from gi.repository import Gio, GLib # noqa: E402
+
+SERVICE = "org.freedesktop.secrets"
+SERVICE_PATH = "/org/freedesktop/secrets"
+SERVICE_IFACE = "org.freedesktop.Secret.Service"
+SESSION_IFACE = "org.freedesktop.Secret.Session"
+COLLECTION_IFACE = "org.freedesktop.Secret.Collection"
+ALIAS_PATH = "/org/freedesktop/secrets/aliases/default"
+LOGIN_PATH = "/org/freedesktop/secrets/collection/login"
+DH_ALGORITHM = "dh-ietf1024-sha256-aes128-cbc-pkcs7"
+TIMEOUT_MS = 10000
+
+
+def connect():
+ conn = Gio.DBusConnection.new_for_address_sync(
+ os.environ["DBUS_SESSION_BUS_ADDRESS"],
+ Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT
+ | Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION,
+ None,
+ None,
+ )
+ conn.set_exit_on_close(False)
+ return conn
+
+
+def call(conn, path, iface, method, args, reply_type):
+ return conn.call_sync(
+ SERVICE, path, iface, method, args,
+ GLib.VariantType.new(reply_type) if reply_type else None,
+ Gio.DBusCallFlags.NONE, TIMEOUT_MS, None,
+ )
+
+
+def owner(conn):
+ return conn.call_sync(
+ "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus",
+ "GetNameOwner", GLib.Variant("(s)", (SERVICE,)),
+ GLib.VariantType.new("(s)"), Gio.DBusCallFlags.NONE, 5000, None,
+ ).unpack()[0]
+
+
+def barrier(conn):
+ """Await the daemon itself, not only the message bus, after each batch."""
+ path = call(conn, SERVICE_PATH, SERVICE_IFACE, "ReadAlias",
+ GLib.Variant("(s)", ("default",)), "(o)").unpack()[0]
+ if path != LOGIN_PATH:
+ raise RuntimeError("Synthetic login collection disappeared")
+ locked = conn.call_sync(
+ SERVICE, path, "org.freedesktop.DBus.Properties", "Get",
+ GLib.Variant("(ss)", (COLLECTION_IFACE, "Locked")),
+ GLib.VariantType.new("(v)"), Gio.DBusCallFlags.NONE, TIMEOUT_MS, None,
+ ).unpack()[0]
+ if locked:
+ raise RuntimeError("Synthetic login collection became locked")
+
+
+def open_session_body(mode):
+ algorithm = "plain" if mode in ("plain", "disconnect", "pipeline", "isolation") else DH_ALGORITHM
+ payload = GLib.Variant("s", "") if algorithm == "plain" else GLib.Variant("ay", [2])
+ if mode == "invalid":
+ algorithm = "intentionally-unsupported"
+ return GLib.Variant("(sv)", (algorithm, payload))
+
+
+def check_open_reply(reply):
+ """An OpenSession reply must carry a session object path under the service."""
+ _output, path = reply.unpack()
+ if not isinstance(path, str) or not path.startswith(SERVICE_PATH + "/session/"):
+ raise RuntimeError(f"OpenSession returned an invalid session path: {path!r}")
+ return path
+
+
+def close_session(conn, path):
+ call(conn, path, SESSION_IFACE, "Close", None, None)
+
+
+def run_sync_client(conn, mode):
+ try:
+ reply = call(conn, SERVICE_PATH, SERVICE_IFACE, "OpenSession",
+ open_session_body(mode), "(vo)")
+ except GLib.Error as exc:
+ if mode == "invalid" and Gio.DBusError.get_remote_error(exc) == "org.freedesktop.DBus.Error.NotSupported":
+ return "expected-not-supported"
+ raise
+ if mode == "invalid":
+ raise RuntimeError("Unsupported algorithm unexpectedly accepted")
+ close_session(conn, check_open_reply(reply))
+ return "ok"
+
+
+def run_disconnect_client(conn, burst):
+ for _ in range(burst):
+ message = Gio.DBusMessage.new_method_call(SERVICE, SERVICE_PATH, SERVICE_IFACE, "OpenSession")
+ message.set_body(open_session_body("disconnect"))
+ conn.send_message(message, Gio.DBusSendMessageFlags.NONE)
+ conn.flush_sync(None)
+ return "ok"
+
+
+def run_pipeline_client(conn, burst):
+ """Queue --burst calls without waiting, then collect and validate every reply."""
+ loop = GLib.MainLoop()
+ pending = {"left": burst, "paths": [], "errors": []}
+
+ def done(source, result):
+ try:
+ pending["paths"].append(check_open_reply(source.call_finish(result)))
+ except Exception as exc: # noqa: BLE001 - recorded, then raised below
+ pending["errors"].append(str(exc))
+ pending["left"] -= 1
+ if pending["left"] == 0:
+ loop.quit()
+
+ for _ in range(burst):
+ conn.call(SERVICE, SERVICE_PATH, SERVICE_IFACE, "OpenSession",
+ open_session_body("pipeline"), GLib.VariantType.new("(vo)"),
+ Gio.DBusCallFlags.NONE, TIMEOUT_MS, None, done)
+ GLib.timeout_add(TIMEOUT_MS + 2000, loop.quit)
+ loop.run()
+ if pending["left"]:
+ raise RuntimeError(f"{pending['left']} pipelined OpenSession calls never answered")
+ if pending["errors"]:
+ raise RuntimeError("pipelined OpenSession failed: " + pending["errors"][0])
+ if len(set(pending["paths"])) != burst:
+ raise RuntimeError("pipelined sessions did not get distinct paths")
+ for path in pending["paths"]:
+ close_session(conn, path)
+ return "ok"
+
+
+def run_alias_client(conn):
+ """First calls on a fresh connection all go through the alias path.
+
+ Unlock on an already-unlocked collection is a no-op that still resolves
+ the alias inside the daemon (the alias-in-arguments path the message
+ filter does not rewrite). Lock is deliberately not used: it would lock the
+ fixture's login collection, and unlocking again needs a password prompt
+ the headless guest cannot show.
+ """
+ unlocked, prompt = call(conn, SERVICE_PATH, SERVICE_IFACE, "Unlock",
+ GLib.Variant("(ao)", ([ALIAS_PATH],)), "(aoo)").unpack()
+ if unlocked != [ALIAS_PATH] or prompt != "/":
+ raise RuntimeError(f"Unlock via alias answered {unlocked!r}, prompt {prompt!r}")
+ path = call(conn, SERVICE_PATH, SERVICE_IFACE, "ReadAlias",
+ GLib.Variant("(s)", ("default",)), "(o)").unpack()[0]
+ if path != LOGIN_PATH:
+ raise RuntimeError(f"ReadAlias answered {path!r}")
+ # A property read addressed to the alias path: the filter rewrites the
+ # object path, then the property getter looks the collection up.
+ locked = conn.call_sync(
+ SERVICE, ALIAS_PATH, "org.freedesktop.DBus.Properties", "Get",
+ GLib.Variant("(ss)", (COLLECTION_IFACE, "Locked")),
+ GLib.VariantType.new("(v)"), Gio.DBusCallFlags.NONE, TIMEOUT_MS, None,
+ ).unpack()[0]
+ if locked:
+ raise RuntimeError("login collection reported locked through the alias path")
+ return "ok"
+
+
+def run_isolation_client(conn_a):
+ conn_b = connect()
+ try:
+ path_a = check_open_reply(call(conn_a, SERVICE_PATH, SERVICE_IFACE, "OpenSession",
+ open_session_body("isolation"), "(vo)"))
+ path_b = check_open_reply(call(conn_b, SERVICE_PATH, SERVICE_IFACE, "OpenSession",
+ open_session_body("isolation"), "(vo)"))
+ if path_a == path_b:
+ raise RuntimeError("two clients received the same session path")
+ try:
+ close_session(conn_b, path_a)
+ except GLib.Error:
+ pass
+ else:
+ raise RuntimeError("client B was allowed to Close client A's session")
+ # A's session must still be alive after B's attempt: a second Close by A
+ # must succeed once (the session exists), and B's own session still closes.
+ close_session(conn_a, path_a)
+ close_session(conn_b, path_b)
+ return "ok"
+ finally:
+ conn_b.close_sync(None)
+
+
+def run_client(mode, burst):
+ conn = connect()
+ try:
+ if mode in ("plain", "dh", "invalid"):
+ return run_sync_client(conn, mode)
+ if mode == "disconnect":
+ return run_disconnect_client(conn, burst)
+ if mode == "pipeline":
+ return run_pipeline_client(conn, burst)
+ if mode == "alias":
+ return run_alias_client(conn)
+ if mode == "isolation":
+ return run_isolation_client(conn)
+ raise ValueError(mode)
+ finally:
+ conn.close_sync(None)
+
+
+def daemon_rss_kib(conn):
+ """RSS of the current bus owner, read via its PID; None when unavailable."""
+ try:
+ pid = conn.call_sync(
+ "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus",
+ "GetConnectionUnixProcessID", GLib.Variant("(s)", (SERVICE,)),
+ GLib.VariantType.new("(u)"), Gio.DBusCallFlags.NONE, 5000, None,
+ ).unpack()[0]
+ with open(f"/proc/{pid}/status") as status:
+ for line in status:
+ if line.startswith("VmRSS:"):
+ return int(line.split()[1])
+ except (GLib.Error, OSError):
+ return None
+ return None
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ parser.add_argument("mode", choices=("plain", "dh", "invalid", "disconnect", "pipeline", "alias", "isolation"))
+ parser.add_argument("--clients", type=int, default=200)
+ parser.add_argument("--workers", type=int, default=16)
+ parser.add_argument("--burst", type=int, default=1)
+ parser.add_argument("--batch", type=int, default=100)
+ args = parser.parse_args()
+ if socket.gethostname() != "archsetup-test" or os.getuid() != 1000:
+ raise SystemExit("Refusing to run outside the disposable archsetup-test guest as UID 1000")
+ monitor = connect()
+ initial_owner = owner(monitor)
+ rss_before = daemon_rss_kib(monitor)
+ results = collections.Counter()
+ examples = []
+ start = time.monotonic()
+ with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as pool:
+ for offset in range(0, args.clients, args.batch):
+ futures = [pool.submit(run_client, args.mode, args.burst)
+ for _ in range(min(args.batch, args.clients - offset))]
+ for future in concurrent.futures.as_completed(futures):
+ try:
+ results[future.result()] += 1
+ except Exception as exc: # noqa: BLE001 - counted and reported
+ results[type(exc).__name__] += 1
+ if len(examples) < 5:
+ examples.append(str(exc))
+ barrier(monitor)
+ try:
+ final_owner = owner(monitor)
+ except GLib.Error as exc:
+ final_owner = str(exc)
+ rss_after = daemon_rss_kib(monitor)
+ monitor.close_sync(None)
+ print(json.dumps({
+ "mode": args.mode, "clients": args.clients, "workers": args.workers,
+ "burst": args.burst, "seconds": round(time.monotonic() - start, 2),
+ "initial_owner": initial_owner, "final_owner": final_owner,
+ "rss_kib_before": rss_before, "rss_kib_after": rss_after,
+ "results": dict(results), "errors": examples,
+ }), flush=True)
+ return 1 if examples or initial_owner != final_owner else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/working/keyring-crash/run-alias-only.sh b/working/keyring-crash/run-alias-only.sh
new file mode 100755
index 0000000..7816066
--- /dev/null
+++ b/working/keyring-crash/run-alias-only.sh
@@ -0,0 +1,23 @@
+#!/bin/bash
+# The sshpass password below is the disposable test guest's throwaway root
+# password (scripts/testing/archsetup-test.conf), reachable only on loopback.
+# It is not a credential for anything real.
+# Alias-path focus run: reset the fixture with the given daemon and drive only
+# the alias mode hard (the lazy-registration race under Lock/Unlock via alias).
+set -uo pipefail
+label="$1"; daemon="$2"; asan="${3:-}"; clients="${4:-8000}"
+here="$(cd "$(dirname "$0")" && pwd)"
+ssho=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -o LogLevel=ERROR -o PreferredAuthentications=password -o PubkeyAuthentication=no)
+g() { sshpass -p archsetup ssh "${ssho[@]}" -p 2237 root@127.0.0.1 "$@"; }
+gcp() { sshpass -p archsetup scp "${ssho[@]}" -P 2237 "$@"; }
+as_user='runuser -u keyring-test -- env XDG_RUNTIME_DIR=/run/user/1000 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus'
+echo "=== $label alias-only daemon=$daemon asan=${asan:-none} $(date -Is)"
+gcp "$here/probe.py" root@127.0.0.1:/opt/keyring-vm/probe.py >/dev/null
+g "rm -f /tmp/asan.log*; KEYRING_DAEMON_BINARY='$daemon' ASAN_OPTIONS='$asan' /root/setup-guest.sh" >/dev/null || { echo "FIXTURE FAILED"; exit 1; }
+since=$(g 'date "+%Y-%m-%d %H:%M:%S"')
+g "$as_user python3 /opt/keyring-vm/probe.py alias --clients $clients --workers 32 --batch 100" 2>&1 | tail -2; rc=${PIPESTATUS[0]}
+echo "alias rc=$rc"
+secret=$(g "$as_user secret-tool lookup purpose keyring-vm" 2>/dev/null); echo "secret after alias: ${secret:-empty}"
+echo "--- journal"; g "journalctl --since '$since' --no-pager -o short-iso 2>/dev/null | grep -E 'gnome-keyring' | grep -iE 'assert|abort|critical|sanitizer|dumped' | head -20"
+[ -n "$asan" ] && { echo "--- asan"; g 'cat /tmp/asan.log* 2>/dev/null | head -60 || echo "no asan log"'; }
+echo "=== $label alias-only result: $([ "$rc" = 0 ] && [ "$secret" = synthetic-vm-secret ] && echo PASS || echo FAIL)"
diff --git a/working/keyring-crash/run-package-test.sh b/working/keyring-crash/run-package-test.sh
new file mode 100755
index 0000000..06a661c
--- /dev/null
+++ b/working/keyring-crash/run-package-test.sh
@@ -0,0 +1,24 @@
+#!/bin/bash
+# The sshpass password below is the disposable test guest's throwaway root
+# password (scripts/testing/archsetup-test.conf), reachable only on loopback.
+# It is not a credential for anything real.
+# Build the repo's gnome-keyring package in the guest with makepkg, install it
+# with pacman, and run the regression through the installed daemon.
+set -uo pipefail
+here="$(cd "$(dirname "$0")" && pwd)"
+ssho=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -o LogLevel=ERROR -o PreferredAuthentications=password -o PubkeyAuthentication=no)
+g() { sshpass -p archsetup ssh "${ssho[@]}" -p 2237 root@127.0.0.1 "$@"; }
+gcp() { sshpass -p archsetup scp "${ssho[@]}" -P 2237 "$@"; }
+echo "=== package build $(date -Is)"
+g 'rm -rf /home/keyring-test/pkgbuild; mkdir -p /home/keyring-test/pkgbuild'
+gcp -r "$here/../../packages/gnome-keyring/." root@127.0.0.1:/home/keyring-test/pkgbuild/ >/dev/null
+# the tarball is already on the guest; makepkg verifies its checksum anyway
+g 'cp /root/gnome-keyring-51.1.tar.xz /home/keyring-test/pkgbuild/; chown -R keyring-test: /home/keyring-test/pkgbuild
+pacman -S --noconfirm --needed base-devel docbook-xsl git glib2-devel meson p11-kit python >/tmp/deps.log 2>&1 || { echo "DEPS FAILED"; tail -5 /tmp/deps.log; exit 1; }
+cd /home/keyring-test/pkgbuild && runuser -u keyring-test -- makepkg --noconfirm --skippgpcheck >/tmp/makepkg.log 2>&1 || { echo "MAKEPKG FAILED"; tail -30 /tmp/makepkg.log; exit 1; }
+ls -la /home/keyring-test/pkgbuild/*.pkg.tar.zst
+grep -E "^(Ok|Fail|Timeout|Expected Fail|Unexpected Pass|Skipped):" /tmp/makepkg.log | tr "\n" " "; echo
+pacman -U --noconfirm /home/keyring-test/pkgbuild/gnome-keyring-1:51.1-1.1-x86_64.pkg.tar.zst >/tmp/pacman-u.log 2>&1 || { echo "PACMAN -U FAILED"; tail -5 /tmp/pacman-u.log; exit 1; }
+pacman -Q gnome-keyring; sha256sum /usr/bin/gnome-keyring-daemon'
+echo "=== regression via installed daemon"
+"$here/run-regression.sh" installed /usr/bin/gnome-keyring-daemon
diff --git a/working/keyring-crash/run-regression.sh b/working/keyring-crash/run-regression.sh
new file mode 100755
index 0000000..a853e40
--- /dev/null
+++ b/working/keyring-crash/run-regression.sh
@@ -0,0 +1,50 @@
+#!/bin/bash
+# The sshpass password below is the disposable test guest's throwaway root
+# password (scripts/testing/archsetup-test.conf), reachable only on loopback.
+# It is not a credential for anything real.
+# Host-side regression driver for the disposable keyring guest.
+# run-regression.sh <label> <daemon-binary-in-guest> [ASAN_OPTIONS]
+# Resets the guest fixture with that daemon, runs every probe mode, verifies
+# the synthetic secret after each mode, and prints a summary. Output goes to
+# stdout; the caller redirects it to a log. Exit 1 on any failure.
+set -uo pipefail
+label="$1"; daemon="$2"; asan="${3:-}"
+here="$(cd "$(dirname "$0")" && pwd)"
+PORT="${PORT:-2237}"
+ssho=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -o LogLevel=ERROR -o PreferredAuthentications=password -o PubkeyAuthentication=no)
+g() { sshpass -p archsetup ssh "${ssho[@]}" -p "$PORT" root@127.0.0.1 "$@"; }
+gcp() { sshpass -p archsetup scp "${ssho[@]}" -P "$PORT" "$@"; }
+as_user='runuser -u keyring-test -- env XDG_RUNTIME_DIR=/run/user/1000 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus'
+fail=0
+echo "=== $label: daemon=$daemon asan=${asan:-none} $(date -Is)"
+gcp "$here/probe.py" root@127.0.0.1:/opt/keyring-vm/probe.py >/dev/null
+gcp "$here/setup-guest.sh" root@127.0.0.1:/root/setup-guest.sh >/dev/null
+g "chmod +x /root/setup-guest.sh; KEYRING_DAEMON_BINARY='$daemon' ASAN_OPTIONS='$asan' /root/setup-guest.sh" || { echo "FIXTURE FAILED"; exit 1; }
+since=$(g 'date "+%Y-%m-%d %H:%M:%S"')
+owner0=$(g "$as_user busctl --user status org.freedesktop.secrets 2>/dev/null | grep -E '^(PID|Comm)='" | tr '\n' ' ')
+echo "owner before: $owner0"
+run_mode() { # mode clients workers burst
+ local out
+ out=$(g "$as_user python3 /opt/keyring-vm/probe.py $1 --clients $2 --workers $3 --burst $4 --batch 100" 2>&1)
+ local rc=$?
+ echo "$out" | tail -3
+ [ $rc -eq 0 ] || { echo "MODE $1 FAILED (rc=$rc)"; fail=1; }
+ local secret
+ secret=$(g "$as_user secret-tool lookup purpose keyring-vm" 2>/dev/null)
+ if [ "$secret" = "synthetic-vm-secret" ]; then echo "secret after $1: ok"; else echo "secret after $1: FAILED (${secret:-empty})"; fail=1; fi
+}
+run_mode plain 4000 32 8
+run_mode dh 4000 32 8
+run_mode invalid 4000 32 8
+run_mode disconnect 4000 32 8
+run_mode pipeline 4000 32 8
+run_mode alias 2000 32 1
+run_mode isolation 2000 32 1
+owner1=$(g "$as_user busctl --user status org.freedesktop.secrets 2>/dev/null | grep -E '^(PID|Comm)='" | tr '\n' ' ')
+echo "owner after: $owner1"
+[ "$owner0" = "$owner1" ] || { echo "OWNER CHANGED"; fail=1; }
+echo "--- daemon journal since $since (assertions/aborts/criticals)"
+g "journalctl --since '$since' --no-pager -o short-iso 2>/dev/null | grep -E 'gnome-keyring|secrets|asan|AddressSanitizer' | grep -iE 'assert|abort|SIGABRT|critical|sanitizer|ERROR|dumped' | head -40"
+if [ -n "$asan" ]; then echo "--- asan logs"; g 'ls /tmp/asan.log* 2>/dev/null && cat /tmp/asan.log* | head -80 || echo "no asan log files"'; fi
+echo "=== $label result: $([ $fail -eq 0 ] && echo PASS || echo FAIL) $(date -Is)"
+exit $fail
diff --git a/working/keyring-crash/run-upstream-tests.sh b/working/keyring-crash/run-upstream-tests.sh
new file mode 100755
index 0000000..055dc7b
--- /dev/null
+++ b/working/keyring-crash/run-upstream-tests.sh
@@ -0,0 +1,11 @@
+#!/bin/bash
+# The sshpass password below is the disposable test guest's throwaway root
+# password (scripts/testing/archsetup-test.conf), reachable only on loopback.
+# It is not a credential for anything real.
+# Upstream meson test suites, stock vs patched, identical options, each in its
+# own private session bus. Prints the summary lines and the failing test names.
+set -uo pipefail
+ssho=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -o LogLevel=ERROR -o PreferredAuthentications=password -o PubkeyAuthentication=no)
+g() { sshpass -p archsetup ssh "${ssho[@]}" -p 2237 root@127.0.0.1 "$@"; }
+g 'set -e; cd /root; if [ ! -d build-stock-cmp ]; then rm -rf src-stock-cmp; mkdir src-stock-cmp; tar -xJf gnome-keyring-51.1.tar.xz -C src-stock-cmp; meson setup build-stock-cmp src-stock-cmp/gnome-keyring-51.1 -Dbuildtype=debugoptimized -Dprefix=/usr -Dlibdir=lib -Ddebug-mode=false -Dmanpage=false >/tmp/stock-cmp-setup.log 2>&1; ninja -C build-stock-cmp >/tmp/stock-cmp-build.log 2>&1; fi
+set +e; for b in build-stock-cmp build-v6; do echo "=== $b"; dbus-run-session -- meson test -C $b --no-rebuild --print-errorlogs >/tmp/mtest-$b.log 2>&1; grep -E "^(Ok|Fail|Timeout|Expected Fail|Unexpected Pass|Skipped):" /tmp/mtest-$b.log | tr "\n" " "; echo; grep -E "(FAIL|TIMEOUT) " /tmp/mtest-$b.log | awk "{print \$2, \$3, \$4, \$5}" | sort -u; done'
diff --git a/working/keyring-crash/setup-guest.sh b/working/keyring-crash/setup-guest.sh
new file mode 100644
index 0000000..0da7c69
--- /dev/null
+++ b/working/keyring-crash/setup-guest.sh
@@ -0,0 +1,40 @@
+#!/bin/bash
+# Exploration fixture: run as root only in the disposable VM.
+set -euo pipefail
+[[ $(uname -n) == archsetup-test && $EUID == 0 ]] || exit 2
+if ! id keyring-test >/dev/null 2>&1; then
+ useradd -m -u 1000 -s /bin/bash keyring-test
+ printf '%s\n' 'keyring-test:synthetic-vm-password' | chpasswd
+fi
+loginctl enable-linger keyring-test
+systemctl start user@1000.service
+runuser -u keyring-test -- env \
+ XDG_RUNTIME_DIR=/run/user/1000 \
+ DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus \
+ systemctl --user stop gnome-keyring-daemon.service gnome-keyring-daemon.socket
+pkill -KILL -u 1000 -x gnome-keyring-d || true
+runuser -u keyring-test -- env \
+ XDG_RUNTIME_DIR=/run/user/1000 \
+ DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus \
+ systemctl --user enable gnome-keyring-daemon.socket
+printf '%s\n' 'synthetic-vm-password' | runuser -u keyring-test -- env \
+ XDG_RUNTIME_DIR=/run/user/1000 \
+ DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus \
+ LD_LIBRARY_PATH="${KEYRING_DAEMON_LIBRARY_PATH:-}" \
+ ASAN_OPTIONS="${ASAN_OPTIONS:-}" \
+ "${KEYRING_DAEMON_BINARY:-gnome-keyring-daemon}" --unlock
+printf '%s\n' 'synthetic-vm-secret' | runuser -u keyring-test -- env \
+ XDG_RUNTIME_DIR=/run/user/1000 \
+ DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus \
+ secret-tool store --label='Disposable VM fixture' purpose keyring-vm
+observed=$(runuser -u keyring-test -- env \
+ XDG_RUNTIME_DIR=/run/user/1000 \
+ DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus \
+ secret-tool lookup purpose keyring-vm)
+[[ $observed == synthetic-vm-secret ]]
+echo 'Synthetic encrypted-secret round trip passed.'
+pacman -Q gnome-keyring glib2 libsecret dbus dbus-broker dbus-broker-units systemd
+runuser -u keyring-test -- env \
+ XDG_RUNTIME_DIR=/run/user/1000 \
+ DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus \
+ systemctl --user show gnome-keyring-daemon.service -p MainPID -p NRestarts
diff --git a/working/keyring-crash/status.org b/working/keyring-crash/status.org
new file mode 100644
index 0000000..aa0a952
--- /dev/null
+++ b/working/keyring-crash/status.org
@@ -0,0 +1,118 @@
+#+TITLE: Keyring crash: cause, fix, validation, deployment
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-10-17
+#+STARTUP: showall
+
+Supersedes the status in the main checkout's status-and-plan.org from 10-16.
+Everything below was done in a disposable VM and this worktree; the host
+keyring, its packages and services were untouched until the deployment step.
+
+* Cause
+
+gnome-keyring aborts when a Secret Service method call is dispatched before
+the daemon has created the caller's per-client record. The record is created
+by a G_PRIORITY_HIGH idle queued from a GDBus message filter on the worker
+thread; that idle is not reliably ordered against the method dispatch, so
+under concurrent bursts a call occasionally arrives first, get_pkcs11_session
+asserts, the negotiate helper returns FALSE without a GError, OpenSession
+completes with a NULL body, and g_variant_new aborts the daemon. systemd
+restarts it, the unlocked state is gone, and the next request shows the
+keyring password dialog.
+
+This is upstream bug 190 (also 194, 195, 196, 199), present in 48 through
+51.1, unmerged fix in merge request !112 since 2026-08-25. On ratio the first
+abort was 2026-10-11 19:03:59, the evening the ChatGPT desktop app was
+installed; openai/codex issue 51694 documents its bundled app-server opening
+a fresh Secret Service connection per keyring read in bursts and crashing
+gnome-keyring with exactly this signature. The same-day glib and keyring
+upgrades were a coincidence: the VM reproduced the crash on 50.0 as well.
+
+A second, pre-existing defect surfaced while testing the fix: the filter's
+alias rewrite reads the alias table on the worker thread while the main
+thread replaces and frees its entries on every client registration.
+AddressSanitizer confirmed it as a heap-use-after-free read in
+gkd_secret_util_build_path from rewrite_default_alias, freed by
+g_hash_table_replace in update_default. Stock and !112 both carry it.
+
+* Fix (packages/gnome-keyring, patch 0002)
+
+- Register the client at the point of use in get_pkcs11_session and
+ publish_dispatch (as !112).
+- aes_negotiate and plain_negotiate return G_DBUS_ERROR_FAILED instead of
+ asserting; OpenSession branches on the return value and never completes
+ with a NULL body (as !112).
+- lookup_collection takes the caller's PKCS#11 session before resolving an
+ alias, so the lazy registration cannot free a borrowed alias string (the
+ earlier reviewer's use-after-free finding).
+- The property-path lookup returns NO_SUCH_OBJECT instead of asserting when
+ there is no session (upstream 195).
+- A mutex guards the alias table; the worker-thread filter and store_default
+ take owned copies under it; the default alias is replaced only when its
+ value changes so main-thread borrowers stay valid.
+
+Packaged as Arch's own recipe for 1:51.1-1 (Cinnamon patch kept, arch-meson,
+selinux disabled) built from GNOME's release tarball (published sha256
+dfe7adc5876ef0670e7eba2907799bdbb8875ce58bb4208e1a52169bd16a47ca) plus the
+patch, at pkgrel 1.1: above stock, below any future 1:51.1-2 or 51.2, so a
+normal upgrade replaces it when a real fix lands. Nothing pins or holds it.
+
+* Validation
+
+Probe (working/keyring-crash/probe.py): real GIO clients, seven modes, every
+reply validated, a daemon round trip after each batch of 100, the synthetic
+secret read back after each mode, bus owner and RSS recorded. Counts: 4000
+clients at 32 workers with bursts of 8 for plain, dh, invalid, disconnect and
+pipeline; 2000 for alias and isolation; alias also at 8000.
+
+| Daemon | Result |
+|-----------------------------+-----------------------------------------------------|
+| stock 51.1 | aborts on the first plain batch, collection locked, |
+| | ratio's exact journal signature (three runs) |
+|-----------------------------+-----------------------------------------------------|
+| v4 (lazy registration only) | passes all modes; alias at 8000 returns 9 bad paths |
+| | and the filter asserts on a malformed object path |
+|-----------------------------+-----------------------------------------------------|
+| v4 under ASan | heap-use-after-free in rewrite_default_alias |
+|-----------------------------+-----------------------------------------------------|
+| v5 and v6, normal and ASan | all modes pass, alias at 8000 passes, no sanitizer |
+| | report, stable owner, RSS flat, secret intact |
+|-----------------------------+-----------------------------------------------------|
+| v6 package via pacman -U | all modes pass through the installed /usr/bin daemon|
+|-----------------------------+-----------------------------------------------------|
+
+Independent review of v5 (patch, pristine and patched trees, prior reviewer's
+requirements, no rationale): approve, no memory-safety defect after checking
+every caller. Two minor notes taken in v6 (store_default under the lock, plain
+NULL return); two declined as pre-existing and bounded (a late dispatch after
+NameOwnerChanged leaks one PKCS#11 session per vanished peer, unique names
+never recur; the remove_filter shutdown race).
+
+Upstream meson suites, identical options and a private bus, run back to back:
+stock and v6 both 56 ok, 1 fail, 5 timeouts, the same names in both
+(gnome2-store import fails intermittently; stock scored 57 ok and 0 fail on
+its first run; the five dbus fixture tests time out identically). No
+regression attributable to the patch. Arch's own recipe ignores this suite's
+failures, which is why it is a comparison here and not a gate.
+
+Installer: install_gnome_keyring_fix runs after pacman_install gnome-keyring,
+builds and installs the recipe with makepkg as the user when stock 51.1 (or
+older) is installed, is a no-op on the rebuild, and skips with a visible
+warning when something newer is installed. Eleven tests in
+tests/installer-steps/test_install_gnome_keyring_fix.py; the fast suite (619
+Python plus 17 ERT tests) passes. The false autologin comment on the
+keyring-dir block is corrected.
+
+* Deployment
+
+working/keyring-crash/deploy-ratio.sh: build (done on ratio 06:53, package at
+~/.cache/archsetup-build/gnome-keyring/), install (permission-protected copy
+of ~/.local/share/keyrings, pacman -U, restart only
+gnome-keyring-daemon.service, verify), rollback (pacman -U of the cached stock
+1:51.1-1, restart), verify. One unlock prompt is expected after the restart;
+answer it in the system dialog. The same script applies to velox once it is
+online, after confirming its versions match.
+
+Observation period: the stock daemon aborted about three times a day on
+ratio. A restart counter that stays flat for several days of normal ChatGPT
+and Claude desktop use is the evidence of lasting stability; a short check
+after install is not.
diff --git a/working/keyring-crash/vm-run.sh b/working/keyring-crash/vm-run.sh
new file mode 100755
index 0000000..482ab3d
--- /dev/null
+++ b/working/keyring-crash/vm-run.sh
@@ -0,0 +1,18 @@
+#!/bin/bash
+# Boot the retained disposable keyring guest headlessly. SSH is forwarded on
+# loopback only. Runtime files (pid, monitor socket, serial log) live under
+# the caller's RUN dir; the overlay and firmware file are the retained ones.
+set -euo pipefail
+OVERLAY="${OVERLAY:?path to guest.qcow2}"
+VARS="${VARS:?path to OVMF_VARS.fd}"
+RUN="${RUN:?runtime dir}"
+PORT="${PORT:-2237}"
+mkdir -p "$RUN"
+exec qemu-system-x86_64 -name keyring-vm -machine q35,accel=kvm -cpu host \
+ -smp 2 -m 4096 \
+ -drive if=pflash,format=raw,readonly=on,file=/usr/share/edk2/x64/OVMF_CODE.4m.fd \
+ -drive "if=pflash,format=raw,file=$VARS" \
+ -drive "file=$OVERLAY,format=qcow2,if=virtio" \
+ -netdev "user,id=net0,hostfwd=tcp:127.0.0.1:${PORT}-:22" -device virtio-net-pci,netdev=net0 \
+ -monitor "unix:$RUN/monitor.sock,server,nowait" -pidfile "$RUN/qemu.pid" \
+ -serial "file:$RUN/serial.log" -display none -boot c
diff --git a/working/meeting-transcription-service/2026-09-19-work-handoff.org b/working/meeting-transcription-service/2026-09-19-work-handoff.org
new file mode 100644
index 0000000..d4da1d9
--- /dev/null
+++ b/working/meeting-transcription-service/2026-09-19-work-handoff.org
@@ -0,0 +1,44 @@
+#+TITLE: Handoff from work: the meeting transcription service needs an install home
+#+AUTHOR: Craig Jennings
+
+* What this is
+A self-hosted meeting transcription service (whisper.cpp plus pyannote speaker diarization) that runs on
+ratio, with velox as the offline fallback. It has been running on both machines since 2026-09-17. The code
+arrives as meeting-transcription-service.tar.gz alongside this note. I picked archsetup as its home because
+it is machine setup: a worker, two systemd user units, a Python venv and a model file. The client script and
+its Emacs backend entry are handled separately.
+
+* How it works
+- A job queue under ~/.local/state/meeting-transcribe/ with incoming/, work/, done/ and failed/. A systemd
+ user path unit (meeting-transcribe.path) starts a oneshot worker (meeting-transcribe.service) when a job
+ lands in incoming/.
+- The worker (src/transcribe-worker) converts audio to 16 kHz mono, runs whisper-cli at word level, runs
+ pyannote (speaker-diarization-community-1) through src/diarize.py with the job's speaker count, and
+ merges the two by timestamp with src/merge_transcript.py. One job at a time, behind a lock.
+- A finished job leaves done/<id>.txt. A failed job leaves failed/<id>.log. Nothing half-written reaches done/.
+- No network listener. Tailscale ssh is the transport and the login, systemd is the daemon, the
+ filesystem is the queue.
+
+* What the install has to provide, per machine
+- ~/.local/share/pyannote-diarize/.venv :: Python 3.12, torch (CPU build), pyannote.audio 4.0.7. About
+ 1.3 GB. Built with uv.
+- ~/.local/share/whisper-models/ggml-large-v3-turbo-q5_0.bin :: the whisper model; ratio's and velox's
+ copies have the same checksum. whisper-cpp itself must be installed (it was already on velox).
+- The src/ scripts placed where the units expect them, and the two user units enabled.
+- Linger on, so the path unit runs without a login session.
+- One-time, online, by hand: a Hugging Face token and acceptance of the pyannote model terms, so the
+ diarization model can be cached. After that neither machine needs Hugging Face. The token is a
+ credential: it must not be written into this repo, which is publicly cloneable.
+
+* State today
+- Installed by hand on ratio and velox; both verified. No NVIDIA GPU and no ROCm on ratio, so it is CPU only.
+- make check in the bundle runs pytest (129 tests; whisper, pyannote, ssh and scp are faked at the process
+ boundary), pyright and shellcheck. All green on 2026-09-19.
+- Known rough edge: when two local runs overlap, the second finds the lock held, the worker returns
+ silently, and the client says "finished without producing a transcript". Rerunning fixes it, but the
+ message should say the lock was held.
+
+* What I'm asking archsetup for
+Take ownership of the service side: the worker, diarize.py, merge_transcript.py, the two units, and an
+install step that builds the venv, fetches the model and enables the units on both daily drivers. Keep
+anything work-specific out of the repo; the code bundle has none (scanned before sending).
diff --git a/working/meeting-transcription-service/Makefile b/working/meeting-transcription-service/Makefile
new file mode 100644
index 0000000..2ed7dad
--- /dev/null
+++ b/working/meeting-transcription-service/Makefile
@@ -0,0 +1,15 @@
+# Checks for the transcription service. transcribe-worker has no .py suffix, so
+# pyright's directory scan skips it; it is named explicitly here.
+.PHONY: check test types lint
+
+check: test types lint
+
+test:
+ python3 -m pytest tests -q
+
+types:
+ pyright
+ pyright src/transcribe-worker
+
+lint:
+ shellcheck src/ratio-transcribe
diff --git a/working/meeting-transcription-service/pyrightconfig.json b/working/meeting-transcription-service/pyrightconfig.json
new file mode 100644
index 0000000..8228120
--- /dev/null
+++ b/working/meeting-transcription-service/pyrightconfig.json
@@ -0,0 +1,4 @@
+{
+ "extraPaths": ["src"],
+ "include": ["src", "tests"]
+}
diff --git a/working/meeting-transcription-service/src/diarize.py b/working/meeting-transcription-service/src/diarize.py
new file mode 100644
index 0000000..a6ae85e
--- /dev/null
+++ b/working/meeting-transcription-service/src/diarize.py
@@ -0,0 +1,114 @@
+#!/usr/bin/env python3
+"""Run pyannote speaker diarization on one audio file and write the turns as JSON.
+
+Usage: diarize.py AUDIO OUT_JSON [--speakers N | --min-speakers N --max-speakers N]
+
+Output is a list of {"start", "end", "speaker"} in seconds, which is what
+merge_transcript.py reads. HF_TOKEN is only needed the first time, to download
+the gated model; after that the cached copy loads offline.
+
+pyannote and torch are imported inside run(), so the pure helpers here can be
+tested without the multi-gigabyte environment.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+from collections.abc import Iterable
+from pathlib import Path
+from typing import Any
+
+MODEL = "pyannote/speaker-diarization-community-1"
+
+
+def turns_from_tracks(tracks: Iterable[tuple[Any, Any, str]]) -> list[dict[str, Any]]:
+ """Convert pyannote (segment, track, label) triples into sorted turn dicts.
+
+ Times are rounded to milliseconds. Segments with no length are dropped.
+ """
+ turns = [
+ {"start": round(float(seg.start), 3), "end": round(float(seg.end), 3), "speaker": str(label)}
+ for seg, _track, label in tracks
+ if float(seg.end) > float(seg.start)
+ ]
+ return sorted(turns, key=lambda t: (t["start"], t["end"]))
+
+
+def _positive_int(value: str) -> int:
+ number = int(value)
+ if number < 1:
+ raise argparse.ArgumentTypeError("must be 1 or more")
+ return number
+
+
+def parse_args(argv: list[str]) -> argparse.Namespace:
+ """Parse the command line. Exits with usage on bad or contradictory counts."""
+ parser = argparse.ArgumentParser(description="Speaker diarization with pyannote.")
+ parser.add_argument("audio")
+ parser.add_argument("out")
+ parser.add_argument("--speakers", type=_positive_int, help="exact number of speakers")
+ parser.add_argument("--min-speakers", type=_positive_int)
+ parser.add_argument("--max-speakers", type=_positive_int)
+ args = parser.parse_args(argv)
+ if args.speakers is not None and (args.min_speakers or args.max_speakers):
+ parser.error("--speakers cannot be combined with --min-speakers/--max-speakers")
+ if args.min_speakers and args.max_speakers and args.min_speakers > args.max_speakers:
+ parser.error("--min-speakers cannot exceed --max-speakers")
+ return args
+
+
+def pipeline_kwargs(args: argparse.Namespace) -> dict[str, int]:
+ """Only the speaker-count options that were actually given."""
+ options = {
+ "num_speakers": args.speakers,
+ "min_speakers": args.min_speakers,
+ "max_speakers": args.max_speakers,
+ }
+ return {name: value for name, value in options.items() if value is not None}
+
+
+def run(args: argparse.Namespace) -> list[dict[str, Any]]:
+ """Load the pipeline, diarize the audio, and return the turns."""
+ # Heavy, and only installed in the service venv, so imported here on purpose.
+ from pyannote.audio import Pipeline # pyright: ignore[reportMissingImports]
+
+ pipeline = Pipeline.from_pretrained(MODEL, token=os.environ.get("HF_TOKEN") or None)
+ if pipeline is None:
+ raise RuntimeError(f"could not load {MODEL}: check HF_TOKEN and that its terms are accepted")
+ output = pipeline(args.audio, **pipeline_kwargs(args))
+ # The exclusive variant never overlaps two speakers, which is what a
+ # word-by-word merge wants. Older pipelines return the annotation itself.
+ annotation = getattr(output, "exclusive_speaker_diarization", None)
+ if annotation is None:
+ annotation = getattr(output, "speaker_diarization", output)
+ return turns_from_tracks(annotation.itertracks(yield_label=True))
+
+
+def main(argv: list[str]) -> int:
+ """CLI entry point. Writes OUT_JSON atomically; non-zero on any failure."""
+ args = parse_args(argv)
+ if not Path(args.audio).is_file():
+ print(f"Error: audio file not found: {args.audio}", file=sys.stderr)
+ return 1
+ try:
+ turns = run(args)
+ except Exception as err: # noqa: BLE001 - report any model failure and exit non-zero
+ print(f"Error: diarization failed: {err}", file=sys.stderr)
+ return 1
+ if not turns:
+ print("Error: diarization found no speech", file=sys.stderr)
+ return 1
+ out = Path(args.out)
+ partial = out.with_name(out.name + ".partial")
+ partial.write_text(json.dumps(turns), encoding="utf-8")
+ partial.replace(out)
+ speakers = len({t["speaker"] for t in turns})
+ print(f"{len(turns)} turns, {speakers} speakers -> {out}", file=sys.stderr)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv[1:]))
diff --git a/working/meeting-transcription-service/src/merge_transcript.py b/working/meeting-transcription-service/src/merge_transcript.py
new file mode 100644
index 0000000..13aec75
--- /dev/null
+++ b/working/meeting-transcription-service/src/merge_transcript.py
@@ -0,0 +1,329 @@
+#!/usr/bin/env python3
+"""Merge whisper word timings with speaker turns into transcript lines.
+
+Input is two JSON files: whisper-cli's ``-oj`` output (run at word level) and
+the diarizer's list of speaker turns. Output is one line per stretch of speech,
+``HH:MM:SS Speaker A: text``, the same shape the hosted services produced.
+
+Standard library only, so it runs under any Python 3.10+ without the venv.
+"""
+
+from __future__ import annotations
+
+import json
+import sys
+from dataclasses import dataclass
+from pathlib import Path
+
+DEFAULT_MAX_GAP_S = 3.0
+DEFAULT_SPEECH_MARGIN_S = 2.0
+DEFAULT_MAX_LOOP_S = 30.0
+
+
+@dataclass(frozen=True)
+class Unit:
+ """A piece of transcribed text with its start and end in seconds."""
+
+ start: float
+ end: float
+ text: str
+
+
+@dataclass(frozen=True)
+class Turn:
+ """A stretch of audio the diarizer attributes to one speaker."""
+
+ start: float
+ end: float
+ speaker: str
+
+
+def _timestamp(seconds: float) -> str:
+ """Render seconds as HH:MM:SS, floored."""
+ whole = int(seconds)
+ return f"{whole // 3600:02d}:{whole % 3600 // 60:02d}:{whole % 60:02d}"
+
+
+def _speaker_name(index: int) -> str:
+ """Name speakers A-Z in order of first speech, then by number."""
+ return chr(ord("A") + index) if index < 26 else str(index)
+
+
+def _speaker_for(unit: Unit, turns: list[Turn]) -> str:
+ """Pick the turn a unit belongs to.
+
+ The turn overlapping most of the unit wins. A unit overlapping nothing (a
+ zero-length word, or one whisper timed into a silence) goes to the turn
+ nearest its midpoint, because whisper's word timings drift by a few hundred
+ milliseconds and dropping the word would be worse than a near guess.
+ """
+ best = max(turns, key=lambda t: min(unit.end, t.end) - max(unit.start, t.start))
+ if min(unit.end, best.end) - max(unit.start, best.start) > 0:
+ return best.speaker
+ mid = (unit.start + unit.end) / 2
+
+ def distance(turn: Turn) -> float:
+ if turn.start <= mid <= turn.end:
+ return 0.0
+ return min(abs(mid - turn.start), abs(mid - turn.end))
+
+ return min(turns, key=distance).speaker
+
+
+def merge(units: list[Unit], turns: list[Turn], max_gap_s: float = DEFAULT_MAX_GAP_S) -> list[str]:
+ """Return transcript lines for ``units`` labelled by ``turns``.
+
+ Consecutive units from one speaker share a line. The line breaks when the
+ speaker changes, or when the speaker pauses longer than ``max_gap_s``, so a
+ long monologue still carries usable timestamps.
+
+ Raises:
+ ValueError: if there are no turns, no spoken words, or ``max_gap_s`` is negative.
+ """
+ if max_gap_s < 0:
+ raise ValueError("max_gap_s must not be negative")
+ spoken = sorted((u for u in units if u.text.strip()), key=lambda u: (u.start, u.end))
+ if not spoken:
+ raise ValueError("no speech: the transcription holds no words")
+ if not turns:
+ raise ValueError("no speaker turns: the diarization is empty")
+ ordered_turns = sorted(turns, key=lambda t: (t.start, t.end))
+
+ names: dict[str, str] = {}
+ lines: list[tuple[float, str, list[str]]] = []
+ previous_end = 0.0
+ for unit in spoken:
+ speaker = _speaker_for(unit, ordered_turns)
+ name = names.setdefault(speaker, _speaker_name(len(names)))
+ if lines and lines[-1][1] == name and unit.start - previous_end <= max_gap_s:
+ lines[-1][2].append(unit.text)
+ else:
+ lines.append((unit.start, name, [unit.text]))
+ previous_end = max(previous_end, unit.end)
+
+ return [
+ f"{_timestamp(start)} Speaker {name}: {' '.join(''.join(parts).split())}"
+ for start, name, parts in lines
+ ]
+
+
+def drop_outside_speech(
+ units: list[Unit], turns: list[Turn], margin_s: float = DEFAULT_SPEECH_MARGIN_S
+) -> tuple[list[Unit], int]:
+ """Return the units that belong to speech, and how many were dropped.
+
+ Whisper invents words ("Thank you.") when it is handed silence. The diarizer
+ marks where people actually spoke, so a unit that touches no turn and sits
+ more than ``margin_s`` from the nearest one is treated as invented. The margin
+ protects real words the diarizer clipped off the edge of a turn.
+
+ Raises:
+ ValueError: if there are no turns, or ``margin_s`` is negative.
+ """
+ if margin_s < 0:
+ raise ValueError("margin_s must not be negative")
+ if not turns:
+ raise ValueError("no speaker turns: the diarization is empty")
+
+ def gap(unit: Unit) -> float:
+ # Seconds between the unit and its nearest turn; zero when they touch.
+ # Rounded to the millisecond, the resolution of whisper's offsets.
+ nearest = min(max(turn.start - unit.end, unit.start - turn.end, 0.0) for turn in turns)
+ return round(nearest, 3)
+
+ kept = [unit for unit in units if gap(unit) <= margin_s]
+ return kept, len(units) - len(kept)
+
+
+def _unit(item: dict) -> Unit:
+ """A Unit from a whisper segment or token dict (offsets in milliseconds).
+
+ whisper-cli clamps a token's start to its segment's start without moving the
+ end, so some tokens arrive ending before they begin. Those become zero-length
+ at their start; left alone they corrupt both the overlap and the pause maths.
+ """
+ start = item["offsets"]["from"] / 1000
+ end = item["offsets"]["to"] / 1000
+ return Unit(start, max(start, end), item["text"])
+
+
+def find_repetition(
+ text: str, min_words: int = 3, max_words: int = 12, min_repeats: int = 4
+) -> tuple[str, int] | None:
+ """Find a phrase repeated back to back, whisper's hallucination signature.
+
+ Returns the phrase and its repeat count, or None. Four consecutive repeats of
+ a phrase of three or more words is the line: people say a thing two or three
+ times, and single-word runs ("yeah yeah yeah") are ordinary speech.
+ """
+ words = text.split()
+ keys = [w.lower().strip(".,!?;:\"'") for w in words]
+ for size in range(min_words, max_words + 1):
+ for i in range(len(keys) - size * min_repeats + 1):
+ phrase = keys[i : i + size]
+ if len(set(phrase)) < 2:
+ continue
+ count = 1
+ while keys[i + count * size : i + (count + 1) * size] == phrase:
+ count += 1
+ if count >= min_repeats:
+ return " ".join(words[i : i + size]), count
+ return None
+
+
+def collapse_repetitions(
+ units: list[Unit],
+ min_words: int = 3,
+ max_words: int = 12,
+ min_repeats: int = 4,
+ max_loop_s: float = DEFAULT_MAX_LOOP_S,
+) -> tuple[list[Unit], list[tuple[str, int]]]:
+ """Collapse whisper's repetition loops, keeping one copy of the phrase.
+
+ Whisper sometimes gets stuck and emits the same phrase over and over. A short
+ loop (up to ``max_loop_s`` of audio) costs a few seconds of speech, so it is
+ collapsed to a single occurrence and reported. A longer one means real speech
+ was lost for a stretch, and that is raised instead, so the job fails rather
+ than hand back a transcript with a hole in it.
+
+ Returns the surviving units and a list of (phrase, repeat count) for every
+ loop collapsed. Units are matched word by word, so a phrase spread over word
+ units and a phrase sitting in one segment unit are both found.
+
+ Raises:
+ ValueError: if a loop lasts longer than ``max_loop_s``, or the limit is negative.
+ """
+ if max_loop_s < 0:
+ raise ValueError("max_loop_s must not be negative")
+ units = list(units)
+ collapsed: list[tuple[str, int]] = []
+
+ def find() -> tuple[int, int, int] | None:
+ # (first word index, phrase size, repeat count) of the earliest loop, or None
+ words = [(w, ui) for ui, u in enumerate(units) for w in u.text.split()]
+ keys = [w.lower().strip(".,!?;:\"'") for w, _ in words]
+ best: tuple[int, int, int] | None = None
+ for size in range(min_words, max_words + 1):
+ for i in range(len(keys) - size * min_repeats + 1):
+ if best is not None and i >= best[0]:
+ break
+ phrase = keys[i : i + size]
+ if len(set(phrase)) < 2:
+ continue
+ count = 1
+ while keys[i + count * size : i + (count + 1) * size] == phrase:
+ count += 1
+ if count >= min_repeats:
+ best = (i, size, count)
+ break
+ return best
+
+ while True:
+ hit = find()
+ if hit is None:
+ return units, collapsed
+ i, size, count = hit
+ words = [(w, ui) for ui, u in enumerate(units) for w in u.text.split()]
+ phrase_text = " ".join(w for w, _ in words[i : i + size])
+ doomed = set(range(i + size, i + count * size)) # word indexes of the repeats
+ touched = {ui for wi, (_, ui) in enumerate(words) if wi in doomed or i <= wi < i + size}
+ span_start = min(units[ui].start for ui in touched)
+ span_end = max(units[ui].end for ui in touched)
+ duration = round(span_end - span_start, 3)
+ if duration > max_loop_s:
+ raise ValueError(
+ f"whisper looped: {phrase_text!r} repeats {count} times over {duration:.0f} s; "
+ "rerun whisper with -mc 0"
+ )
+ # Rebuild every touched unit from the words it keeps. A unit that held only
+ # repeats disappears; one that also held the first copy or later speech keeps
+ # those words. Every pass removes (count - 1) * size words, so this ends.
+ last_kept = max(ui for wi, (_, ui) in enumerate(words) if i <= wi < i + size)
+ rebuilt: list[Unit] = []
+ for ui, unit in enumerate(units):
+ if ui not in touched:
+ rebuilt.append(unit)
+ continue
+ keep = [w for wi, (w, wui) in enumerate(words) if wui == ui and wi not in doomed]
+ if not keep:
+ continue
+ # The kept copy takes over the time the loop occupied, so the merge does
+ # not read the removed stretch as a pause and break the line there.
+ end = max(unit.end, span_end) if ui == last_kept else unit.end
+ rebuilt.append(Unit(unit.start, end, " " + " ".join(keep)))
+ units = rebuilt
+ collapsed.append((phrase_text, count))
+
+
+def load_whisper_json(path: str | Path) -> list[Unit]:
+ """Read whisper-cli JSON output. Offsets there are in milliseconds.
+
+ With ``-ojf`` each segment carries its tokens and their offsets; those become
+ word-level units, which is what lets a speaker change land mid-segment. Plain
+ ``-oj`` output, or a segment with no tokens, falls back to the segment itself.
+
+ Raises:
+ ValueError: if the file is not whisper's JSON shape.
+ """
+ try:
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
+ units: list[Unit] = []
+ for item in data["transcription"]:
+ tokens = item.get("tokens") or []
+ if not tokens:
+ units.append(_unit(item))
+ continue
+ for token in tokens:
+ text = token["text"]
+ if not text or text.startswith("[_"): # [_BEG_], [_TT_123], [_EOT_]
+ continue
+ unit = _unit(token)
+ if units and not text.startswith(" "):
+ # A sub-word piece or punctuation: it belongs to the word before it.
+ previous = units[-1]
+ units[-1] = Unit(previous.start, max(previous.end, unit.end), previous.text + text)
+ else:
+ units.append(unit)
+ return units
+ except OSError as err:
+ raise ValueError(f"{path}: cannot read whisper output ({err.strerror})") from err
+ except (json.JSONDecodeError, KeyError, TypeError) as err:
+ raise ValueError(f"{path}: not whisper-cli JSON output ({err!r})") from err
+
+
+def load_turns_json(path: str | Path) -> list[Turn]:
+ """Read the diarizer's turns: a list of {start, end, speaker}, in seconds.
+
+ Raises:
+ ValueError: if the file is not that shape.
+ """
+ try:
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
+ if not isinstance(data, list):
+ raise TypeError("expected a list of turns")
+ return [Turn(float(t["start"]), float(t["end"]), str(t["speaker"])) for t in data]
+ except OSError as err:
+ raise ValueError(f"{path}: cannot read speaker turns ({err.strerror})") from err
+ except (json.JSONDecodeError, KeyError, TypeError, ValueError) as err:
+ raise ValueError(f"{path}: not a speaker-turns file ({err!r})") from err
+
+
+def main(argv: list[str]) -> int:
+ """CLI: ``merge_transcript.py WHISPER_JSON TURNS_JSON`` prints the transcript."""
+ if len(argv) != 2:
+ print("usage: merge_transcript.py WHISPER_JSON TURNS_JSON", file=sys.stderr)
+ return 2
+ try:
+ turns = load_turns_json(argv[1])
+ units, _dropped = drop_outside_speech(load_whisper_json(argv[0]), turns)
+ units, _collapsed = collapse_repetitions(units)
+ lines = merge(units, turns)
+ except ValueError as err:
+ print(f"Error: {err}", file=sys.stderr)
+ return 1
+ print("\n".join(lines))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv[1:]))
diff --git a/working/meeting-transcription-service/src/ratio-transcribe b/working/meeting-transcription-service/src/ratio-transcribe
new file mode 100755
index 0000000..8db59b6
--- /dev/null
+++ b/working/meeting-transcription-service/src/ratio-transcribe
@@ -0,0 +1,203 @@
+#!/usr/bin/env bash
+# ratio-transcribe - Transcribe audio on my own transcription host, with speaker labels
+# Usage: ratio-transcribe <audio-file> [language]
+#
+# Same contract as assemblyai-transcribe: the transcript goes to stdout, one line
+# per speaker turn ("HH:MM:SS Speaker A: text"); progress and errors go to stderr;
+# any failure exits non-zero with nothing on stdout.
+#
+# The work happens on a host that runs the meeting-transcribe queue (whisper-cpp
+# plus pyannote). This script copies the audio over ssh, drops a job into the
+# queue, waits, and prints the result. The job id is a hash of the audio and its
+# options, so if the connection drops or the laptop sleeps, running the same
+# command again just collects the finished transcript. If the host can't be
+# reached at all, the same queue and worker run on this machine instead.
+#
+# Optional environment:
+# SPEAKERS exact number of speakers, when you know it
+# MIN_SPEAKERS, MAX_SPEAKERS a range instead
+# TRANSCRIBE_HOST ssh name of the host (default: ratio)
+# TRANSCRIBE_TIMEOUT seconds to wait for the job (default: 3600)
+# TRANSCRIBE_POLL seconds between checks (default: 10)
+# TRANSCRIBE_LOCAL=1 skip the host and run here
+# TRANSCRIBE_WORKER path to the local worker
+
+set -euo pipefail
+
+AUDIO="${1:-}"
+LANG_CODE="${2:-en}"
+HOST="${TRANSCRIBE_HOST:-ratio}"
+TIMEOUT="${TRANSCRIBE_TIMEOUT:-3600}"
+POLL="${TRANSCRIBE_POLL:-10}"
+WORKER="${TRANSCRIBE_WORKER:-$HOME/.local/share/pyannote-diarize/src/transcribe-worker}"
+STATE=".local/state/meeting-transcribe" # relative to the home directory, on either machine
+
+if [[ -z "$AUDIO" ]]; then
+ echo "Usage: ratio-transcribe <audio-file> [language]" >&2
+ echo "Example: SPEAKERS=3 ratio-transcribe meeting.m4a en" >&2
+ exit 1
+fi
+
+if [[ ! -f "$AUDIO" ]]; then
+ echo "Error: Audio file not found: $AUDIO" >&2
+ exit 1
+fi
+# scp reads "name:with:colons" as host:path; an absolute path removes the ambiguity.
+AUDIO="$(realpath -- "$AUDIO")"
+
+# Everything below ends up in a job file and on command lines, so check it first.
+if [[ ! "$LANG_CODE" =~ ^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$ ]]; then
+ echo "Error: Invalid language code: $LANG_CODE" >&2
+ exit 1
+fi
+
+for name in SPEAKERS MIN_SPEAKERS MAX_SPEAKERS; do
+ value="${!name:-}"
+ if [[ -n "$value" && ! "$value" =~ ^[1-9][0-9]*$ ]]; then
+ echo "Error: $name must be a positive whole number of speakers, got: $value" >&2
+ exit 1
+ fi
+done
+if [[ -n "${SPEAKERS:-}" && ( -n "${MIN_SPEAKERS:-}" || -n "${MAX_SPEAKERS:-}" ) ]]; then
+ echo "Error: give an exact SPEAKERS count or a MIN/MAX speaker range, not both" >&2
+ exit 1
+fi
+if [[ -n "${MIN_SPEAKERS:-}" && -n "${MAX_SPEAKERS:-}" ]] && (( MIN_SPEAKERS > MAX_SPEAKERS )); then
+ echo "Error: MIN_SPEAKERS cannot exceed MAX_SPEAKERS (speaker range)" >&2
+ exit 1
+fi
+
+for tool in jq sha256sum; do
+ if ! command -v "$tool" &> /dev/null; then
+ echo "Error: $tool command not found" >&2
+ exit 1
+ fi
+done
+
+EXT="${AUDIO##*.}"
+[[ "$EXT" =~ ^[A-Za-z0-9]{1,5}$ ]] || EXT="bin"
+EXT="${EXT,,}"
+
+if [[ -n "${SPEAKERS:-}" ]]; then
+ COUNT_TAG="s${SPEAKERS}"
+elif [[ -n "${MIN_SPEAKERS:-}${MAX_SPEAKERS:-}" ]]; then
+ COUNT_TAG="r${MIN_SPEAKERS:-x}-${MAX_SPEAKERS:-x}"
+else
+ COUNT_TAG="auto"
+fi
+JOB_ID="$(sha256sum "$AUDIO" | cut -c1-16)-${LANG_CODE,,}-${COUNT_TAG}"
+
+JOB_JSON=$(jq -cn \
+ --arg language "$LANG_CODE" \
+ --arg name "$(basename "$AUDIO")" \
+ --arg speakers "${SPEAKERS:-}" --arg min "${MIN_SPEAKERS:-}" --arg max "${MAX_SPEAKERS:-}" \
+ '{language: $language}
+ + (if $speakers != "" then {speakers: ($speakers | tonumber)} else {} end)
+ + (if $min != "" then {min_speakers: ($min | tonumber)} else {} end)
+ + (if $max != "" then {max_speakers: ($max | tonumber)} else {} end)
+ + {original_name: $name}')
+
+# ssh reads stdin unless told not to, which would swallow the input of any loop
+# this script is called from. Only the job-file upload needs stdin.
+remote() { ssh -n -o BatchMode=yes -o ConnectTimeout=8 "$HOST" "$@"; }
+remote_with_stdin() { ssh -o BatchMode=yes -o ConnectTimeout=8 "$HOST" "$@"; }
+
+# One word for where the job stands on the host: done, failed, queued or new.
+remote_status() {
+ remote "cd $STATE 2>/dev/null || { echo new; exit 0; }
+ if [ -e done/$JOB_ID.txt ]; then echo done
+ elif [ -e failed/$JOB_ID.log ]; then echo failed
+ elif [ -d incoming/$JOB_ID ] || [ -d work/$JOB_ID ]; then echo queued
+ else echo new; fi"
+}
+
+print_transcript() { # $1 = the transcript text
+ if [[ -z "${1//[[:space:]]/}" ]]; then
+ echo "Error: the transcript came back empty" >&2
+ exit 1
+ fi
+ echo "Transcription complete! (${SECONDS}s total)" >&2
+ printf '%s\n' "$1"
+}
+
+run_remote() {
+ local status
+ status=$(remote_status)
+
+ if [[ "$status" == "failed" ]]; then
+ echo "An earlier attempt at this job failed; trying again..." >&2
+ remote "rm -f $STATE/failed/$JOB_ID.log"
+ status="new"
+ fi
+
+ if [[ "$status" == "new" ]]; then
+ echo "Uploading audio file to $HOST..." >&2
+ # Copy into uploading/, then rename into incoming/. The queue only ever sees
+ # a complete job.
+ remote "mkdir -p $STATE/incoming $STATE/uploading/$JOB_ID"
+ scp -q -o BatchMode=yes "$AUDIO" "$HOST:$STATE/uploading/$JOB_ID/audio.$EXT" < /dev/null
+ printf '%s' "$JOB_JSON" | remote_with_stdin "cat > $STATE/uploading/$JOB_ID/job.json"
+ remote "mv $STATE/uploading/$JOB_ID $STATE/incoming/$JOB_ID"
+ echo "Job $JOB_ID queued. Waiting for completion..." >&2
+ elif [[ "$status" == "queued" ]]; then
+ echo "Job $JOB_ID is already queued on $HOST. Waiting for completion..." >&2
+ fi
+
+ while true; do
+ # A dropped connection is not a failed job; keep asking until the timeout.
+ status=$(remote_status 2> /dev/null) || status="unreachable"
+ case "$status" in
+ done)
+ print_transcript "$(remote "cat $STATE/done/$JOB_ID.txt")"
+ return 0
+ ;;
+ failed)
+ echo "Error: transcription failed on $HOST" >&2
+ remote "cat $STATE/failed/$JOB_ID.log" >&2 || true
+ exit 1
+ ;;
+ esac
+ if (( SECONDS >= TIMEOUT )); then
+ echo "Error: no result after ${TIMEOUT}s. The job is still with $HOST;" >&2
+ echo "run the same command again to collect the transcript." >&2
+ exit 1
+ fi
+ sleep "$POLL"
+ [[ "$status" == "unreachable" ]] || echo "Processing... (${SECONDS}s elapsed)" >&2
+ done
+}
+
+run_local() {
+ if [[ ! -x "$WORKER" ]]; then
+ echo "Error: $HOST is unreachable and there is no local worker at $WORKER" >&2
+ exit 1
+ fi
+ local state="$HOME/$STATE"
+ if [[ ! -s "$state/done/$JOB_ID.txt" ]]; then
+ echo "Running the transcription locally (this machine is slower; expect a wait)..." >&2
+ rm -f "$state/failed/$JOB_ID.log"
+ rm -rf "$state/uploading/$JOB_ID"
+ mkdir -p "$state/incoming" "$state/uploading/$JOB_ID"
+ cp "$AUDIO" "$state/uploading/$JOB_ID/audio.$EXT"
+ printf '%s' "$JOB_JSON" > "$state/uploading/$JOB_ID/job.json"
+ [[ -d "$state/incoming/$JOB_ID" ]] || mv "$state/uploading/$JOB_ID" "$state/incoming/$JOB_ID"
+ HF_HUB_OFFLINE=1 "$WORKER" >&2 < /dev/null
+ fi
+ if [[ -e "$state/failed/$JOB_ID.log" ]]; then
+ echo "Error: local transcription failed" >&2
+ cat "$state/failed/$JOB_ID.log" >&2
+ exit 1
+ fi
+ if [[ ! -e "$state/done/$JOB_ID.txt" ]]; then
+ echo "Error: the local worker finished without producing a transcript" >&2
+ exit 1
+ fi
+ print_transcript "$(< "$state/done/$JOB_ID.txt")"
+}
+
+if [[ -z "${TRANSCRIBE_LOCAL:-}" ]] && remote true 2> /dev/null; then
+ run_remote
+else
+ [[ -n "${TRANSCRIBE_LOCAL:-}" ]] || echo "$HOST is unreachable." >&2
+ run_local
+fi
diff --git a/working/meeting-transcription-service/src/transcribe-worker b/working/meeting-transcription-service/src/transcribe-worker
new file mode 100755
index 0000000..12e64b0
--- /dev/null
+++ b/working/meeting-transcription-service/src/transcribe-worker
@@ -0,0 +1,255 @@
+#!/usr/bin/env python3
+"""Drain the meeting-transcription queue, one job at a time.
+
+Layout under the state directory (default ~/.local/state/meeting-transcribe):
+
+ incoming/<id>/ audio.<ext> and an optional job.json, dropped by the client
+ work/<id>/ the job being processed
+ done/<id>.txt the transcript, plus done/<id>.json with run metadata
+ failed/<id>.log what went wrong, by stage
+
+The client uploads into uploading/<id>/ and renames the folder into incoming/ when
+the copy is complete, so the queue only ever lists whole jobs. A folder whose name
+starts with a dot is skipped as well, as a second line of defence. Nothing is
+written into done/ except by rename, so a reader never sees half a transcript.
+
+Standard library only. whisper-cli and ffmpeg come from PATH; the diarizer runs in
+its own virtualenv and is called as a subprocess.
+"""
+
+from __future__ import annotations
+
+import argparse
+import fcntl
+import json
+import os
+import re
+import shutil
+import subprocess
+import sys
+import time
+from dataclasses import dataclass, field
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+import merge_transcript # noqa: E402
+
+MAX_ATTEMPTS = 2
+LANGUAGE_RE = re.compile(r"^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$")
+SHARE = Path.home() / ".local/share"
+
+
+@dataclass
+class Config:
+ """Where things live and how the tools are called."""
+
+ home: Path = Path.home() / ".local/state/meeting-transcribe"
+ whisper_model: Path = SHARE / "whisper-models/ggml-large-v3-turbo-q5_0.bin"
+ diarize_cmd: list[str] = field(
+ default_factory=lambda: [
+ str(SHARE / "pyannote-diarize/.venv/bin/python"),
+ str(Path(__file__).resolve().parent / "diarize.py"),
+ ]
+ )
+ threads: int = max(1, (os.cpu_count() or 4) // 2)
+
+
+class JobError(Exception):
+ """A job failed at a named stage."""
+
+ def __init__(self, stage: str, detail: str) -> None:
+ super().__init__(f"{stage}: {detail}")
+ self.stage = stage
+ self.detail = detail
+
+
+def _run(stage: str, argv: list[str]) -> None:
+ """Run one tool; raise JobError carrying the tail of its stderr on failure."""
+ try:
+ result = subprocess.run(argv, capture_output=True, text=True, check=False, stdin=subprocess.DEVNULL)
+ except OSError as err:
+ raise JobError(stage, f"cannot run {argv[0]}: {err.strerror}") from err
+ if result.returncode != 0:
+ tail = "\n".join(result.stderr.strip().splitlines()[-15:])
+ raise JobError(stage, f"exit {result.returncode}\n{tail}")
+
+
+def _read_job(folder: Path) -> dict:
+ """Load and validate job.json. A missing file means all defaults."""
+ path = folder / "job.json"
+ if not path.exists():
+ return {}
+ try:
+ job = json.loads(path.read_text(encoding="utf-8"))
+ if not isinstance(job, dict):
+ raise ValueError("expected an object")
+ except (OSError, ValueError) as err:
+ raise JobError("job", f"job.json is unreadable: {err}") from err
+ language = job.get("language", "en")
+ if not isinstance(language, str) or not LANGUAGE_RE.match(language):
+ raise JobError("job", f"job.json: bad language {language!r}")
+ for key in ("speakers", "min_speakers", "max_speakers"):
+ value = job.get(key)
+ if value is not None and (isinstance(value, bool) or not isinstance(value, int) or value < 1):
+ raise JobError("job", f"job.json: {key} must be a positive whole number, got {value!r}")
+ return job
+
+
+def _diarize_options(job: dict) -> list[str]:
+ options: list[str] = []
+ if job.get("speakers") is not None:
+ return ["--speakers", str(job["speakers"])]
+ if job.get("min_speakers") is not None:
+ options += ["--min-speakers", str(job["min_speakers"])]
+ if job.get("max_speakers") is not None:
+ options += ["--max-speakers", str(job["max_speakers"])]
+ return options
+
+
+def process(folder: Path, config: Config) -> tuple[list[str], dict]:
+ """Run one job folder through the pipeline. Returns transcript lines and metadata."""
+ job = _read_job(folder)
+ audio = next((p for p in sorted(folder.iterdir()) if p.name.startswith("audio.")), None)
+ if audio is None:
+ raise JobError("job", "no audio file in the job folder")
+
+ wav = folder / "speech.wav"
+ _run("ffmpeg", ["ffmpeg", "-v", "error", "-y", "-i", str(audio), "-ar", "16000", "-ac", "1", str(wav)])
+
+ # -ojf keeps normal segments (better text) and adds token offsets for the merge.
+ # -mc 0 stops whisper feeding its own output back in, which is what sends it
+ # into repetition loops on long meetings.
+ prefix = folder / "words"
+ _run("whisper", [
+ "whisper-cli", "-m", str(config.whisper_model), "-f", str(wav),
+ "-l", job.get("language", "en"), "-ojf", "-mc", "0",
+ "-t", str(config.threads), "-of", str(prefix),
+ ])
+
+ turns = folder / "turns.json"
+ _run("diarize", [*config.diarize_cmd, str(wav), str(turns), *_diarize_options(job)])
+
+ try:
+ loaded_turns = merge_transcript.load_turns_json(turns)
+ # Whisper invents words in silence. Drop them before the loop guard, so a
+ # quiet meeting isn't mistaken for a repetition loop.
+ units, dropped = merge_transcript.drop_outside_speech(
+ merge_transcript.load_whisper_json(prefix.with_suffix(".json")), loaded_turns
+ )
+ # A short stutter is collapsed and recorded; a long loop still fails the job.
+ units, collapsed = merge_transcript.collapse_repetitions(units)
+ lines = merge_transcript.merge(units, loaded_turns)
+ except ValueError as err:
+ raise JobError("merge", str(err)) from err
+
+ meta = {
+ "original_name": job.get("original_name"),
+ "language": job.get("language", "en"),
+ "speakers_requested": job.get("speakers"),
+ "speakers_found": len({t.speaker for t in loaded_turns}),
+ "lines": len(lines),
+ "words": sum(len(line.split()) - 3 for line in lines),
+ "dropped_outside_speech": dropped,
+ "loops_collapsed": [[phrase, count] for phrase, count in collapsed],
+ }
+ return lines, meta
+
+
+def _write_atomic(path: Path, text: str) -> None:
+ partial = path.with_name(path.name + ".partial")
+ partial.write_text(text, encoding="utf-8")
+ partial.replace(path)
+
+
+def _fail(failed_dir: Path, job_id: str, stage: str, detail: str) -> None:
+ """Record a failed job. Falls back to stderr if even the log cannot be written."""
+ try:
+ _write_atomic(failed_dir / f"{job_id}.log", f"stage: {stage}\n{detail}\n")
+ except OSError as err:
+ print(f"{job_id}: {stage}: {detail} (and the failure log could not be written: {err})", file=sys.stderr)
+
+
+def _bump_attempts(folder: Path) -> int:
+ """Count this run against the job, tolerating a missing or broken job.json."""
+ path = folder / "job.json"
+ try:
+ job = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
+ if not isinstance(job, dict):
+ return 1
+ except (OSError, ValueError):
+ return 1 # _read_job reports the real problem
+ try:
+ attempts = int(job.get("attempts") or 0) + 1
+ except (TypeError, ValueError):
+ attempts = 1 # a malformed counter counts as a first try
+ job["attempts"] = attempts
+ path.write_text(json.dumps(job), encoding="utf-8")
+ return attempts
+
+
+def drain(config: Config) -> dict[str, int]:
+ """Process every waiting job. Returns counts of done and failed jobs."""
+ dirs = {name: config.home / name for name in ("incoming", "work", "done", "failed")}
+ for folder in dirs.values():
+ folder.mkdir(parents=True, exist_ok=True)
+
+ counts = {"done": 0, "failed": 0}
+ with open(config.home / "lock", "w", encoding="utf-8") as lock:
+ try:
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ except OSError:
+ return counts # another worker is draining; it will reach these jobs
+
+ while True:
+ # Jobs stranded in work/ by a crash go first, then arrivals, oldest first.
+ waiting = sorted(
+ (p for d in (dirs["work"], dirs["incoming"]) for p in d.iterdir()
+ if p.is_dir() and not p.name.startswith(".")),
+ key=lambda p: (p.parent.name != "work", p.stat().st_mtime),
+ )
+ if not waiting:
+ return counts
+ source = waiting[0]
+ job_id = source.name
+ done_txt = dirs["done"] / f"{job_id}.txt"
+ if done_txt.exists():
+ shutil.rmtree(source)
+ continue
+
+ folder = dirs["work"] / job_id
+ if source != folder:
+ source.rename(folder)
+ started = time.monotonic()
+ try:
+ if _bump_attempts(folder) > MAX_ATTEMPTS:
+ raise JobError("worker", f"gave up after {MAX_ATTEMPTS} attempts; the job kept crashing")
+ lines, meta = process(folder, config)
+ meta["seconds"] = round(time.monotonic() - started, 1)
+ _write_atomic(dirs["done"] / f"{job_id}.json", json.dumps(meta))
+ _write_atomic(done_txt, "\n".join(lines) + "\n")
+ (dirs["failed"] / f"{job_id}.log").unlink(missing_ok=True)
+ counts["done"] += 1
+ except JobError as err:
+ _fail(dirs["failed"], job_id, err.stage, err.detail)
+ counts["failed"] += 1
+ except Exception as err: # noqa: BLE001 - one bad job must never stop the queue
+ _fail(dirs["failed"], job_id, "worker", f"{type(err).__name__}: {err}")
+ counts["failed"] += 1
+ finally:
+ shutil.rmtree(folder, ignore_errors=True)
+
+
+def main(argv: list[str]) -> int:
+ """CLI: drain the queue once and exit. systemd's path unit calls this."""
+ parser = argparse.ArgumentParser(description="Drain the meeting-transcription queue.")
+ parser.add_argument("--home", type=Path, help="state directory (default ~/.local/state/meeting-transcribe)")
+ args = parser.parse_args(argv)
+ config = Config(home=args.home) if args.home else Config()
+ counts = drain(config)
+ print(f"done={counts['done']} failed={counts['failed']}", file=sys.stderr)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv[1:]))
diff --git a/working/meeting-transcription-service/systemd/meeting-transcribe.path b/working/meeting-transcription-service/systemd/meeting-transcribe.path
new file mode 100644
index 0000000..821b292
--- /dev/null
+++ b/working/meeting-transcription-service/systemd/meeting-transcribe.path
@@ -0,0 +1,12 @@
+[Unit]
+Description=Watch the meeting-transcription queue for new jobs
+
+[Path]
+# incoming/ only ever holds complete jobs: the client uploads into uploading/ and
+# renames the finished folder across. So "not empty" always means real work, and the
+# worker emptying the folder is what lets this unit go quiet again.
+DirectoryNotEmpty=%h/.local/state/meeting-transcribe/incoming
+MakeDirectory=yes
+
+[Install]
+WantedBy=default.target
diff --git a/working/meeting-transcription-service/systemd/meeting-transcribe.service b/working/meeting-transcription-service/systemd/meeting-transcribe.service
new file mode 100644
index 0000000..92f5ef4
--- /dev/null
+++ b/working/meeting-transcription-service/systemd/meeting-transcribe.service
@@ -0,0 +1,9 @@
+[Unit]
+Description=Transcribe queued meeting recordings (whisper + pyannote)
+
+[Service]
+Type=oneshot
+ExecStart=%h/.local/share/pyannote-diarize/src/transcribe-worker
+# The pyannote model is cached after its first download; never reach for the network.
+Environment=HF_HUB_OFFLINE=1
+Nice=5
diff --git a/working/meeting-transcription-service/tests/test_diarize.py b/working/meeting-transcription-service/tests/test_diarize.py
new file mode 100644
index 0000000..ef009b8
--- /dev/null
+++ b/working/meeting-transcription-service/tests/test_diarize.py
@@ -0,0 +1,74 @@
+"""Tests for diarize's pure parts. The pyannote pipeline itself is not loaded here."""
+
+import sys
+from collections import namedtuple
+from pathlib import Path
+
+import pytest
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
+
+import diarize # noqa: E402
+
+Segment = namedtuple("Segment", "start end")
+
+
+class TestTurnsFromTracks:
+ def test_diarize_turns_from_tracks_converts_and_sorts(self):
+ """Normal: (segment, track, label) triples become sorted turn dicts."""
+ tracks = [(Segment(5.0, 9.25), "_", "SPEAKER_01"), (Segment(0.5, 4.0), "_", "SPEAKER_00")]
+ assert diarize.turns_from_tracks(tracks) == [
+ {"start": 0.5, "end": 4.0, "speaker": "SPEAKER_00"},
+ {"start": 5.0, "end": 9.25, "speaker": "SPEAKER_01"},
+ ]
+
+ def test_diarize_turns_from_tracks_rounds_to_milliseconds(self):
+ """Boundary: float noise from the model is rounded away."""
+ tracks = [(Segment(0.03096875, 1.9980000000000002), "_", "SPEAKER_00")]
+ assert diarize.turns_from_tracks(tracks) == [
+ {"start": 0.031, "end": 1.998, "speaker": "SPEAKER_00"}
+ ]
+
+ def test_diarize_turns_from_tracks_drops_empty_segments(self):
+ """Boundary: zero or negative length segments carry no speech."""
+ tracks = [(Segment(2.0, 2.0), "_", "SPEAKER_00"), (Segment(3.0, 2.5), "_", "SPEAKER_00")]
+ assert diarize.turns_from_tracks(tracks) == []
+
+ def test_diarize_turns_from_tracks_empty_input_is_empty_list(self):
+ """Boundary: no tracks."""
+ assert diarize.turns_from_tracks([]) == []
+
+
+class TestParseArgs:
+ def test_diarize_parse_args_speakers_sets_exact_count(self):
+ """Normal: --speakers pins the count."""
+ args = diarize.parse_args(["a.wav", "out.json", "--speakers", "3"])
+ assert (args.audio, args.out, args.speakers) == ("a.wav", "out.json", 3)
+
+ def test_diarize_parse_args_defaults_let_the_model_estimate(self):
+ """Normal: no count given."""
+ args = diarize.parse_args(["a.wav", "out.json"])
+ assert args.speakers is None and args.min_speakers is None and args.max_speakers is None
+
+ @pytest.mark.parametrize("argv", [
+ ["a.wav", "out.json", "--speakers", "0"],
+ ["a.wav", "out.json", "--speakers", "-2"],
+ ["a.wav", "out.json", "--speakers", "three"],
+ ["a.wav", "out.json", "--speakers", "3", "--max-speakers", "5"],
+ ["a.wav", "out.json", "--min-speakers", "4", "--max-speakers", "2"],
+ ["a.wav"],
+ ])
+ def test_diarize_parse_args_rejects_bad_counts(self, argv):
+ """Error: non-positive, non-numeric, contradictory or missing arguments."""
+ with pytest.raises(SystemExit):
+ diarize.parse_args(argv)
+
+
+class TestPipelineKwargs:
+ def test_diarize_pipeline_kwargs_only_passes_what_was_given(self):
+ """Normal: unset options are not forwarded to the model."""
+ args = diarize.parse_args(["a.wav", "o.json", "--min-speakers", "2", "--max-speakers", "4"])
+ assert diarize.pipeline_kwargs(args) == {"min_speakers": 2, "max_speakers": 4}
+ args = diarize.parse_args(["a.wav", "o.json", "--speakers", "3"])
+ assert diarize.pipeline_kwargs(args) == {"num_speakers": 3}
+ assert diarize.pipeline_kwargs(diarize.parse_args(["a.wav", "o.json"])) == {}
diff --git a/working/meeting-transcription-service/tests/test_merge_transcript.py b/working/meeting-transcription-service/tests/test_merge_transcript.py
new file mode 100644
index 0000000..a584043
--- /dev/null
+++ b/working/meeting-transcription-service/tests/test_merge_transcript.py
@@ -0,0 +1,510 @@
+"""Tests for merge_transcript: whisper words + speaker turns -> transcript lines."""
+
+import json
+import sys
+from pathlib import Path
+
+import pytest
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
+
+import merge_transcript as mt # noqa: E402
+from merge_transcript import Turn, Unit # noqa: E402
+
+
+def words(start, *tokens, step=0.5):
+ """Evenly spaced word units beginning at ``start`` seconds."""
+ return [
+ Unit(start + i * step, start + (i + 1) * step, f" {token}")
+ for i, token in enumerate(tokens)
+ ]
+
+
+class TestMergeNormal:
+ def test_merge_transcript_merge_two_speakers_yields_one_line_per_turn(self):
+ """Normal: words fall into the turn that contains them."""
+ units = words(0.0, "Good", "morning.") + words(6.0, "Sounds", "good.")
+ turns = [Turn(0.0, 5.0, "SPEAKER_00"), Turn(5.5, 9.0, "SPEAKER_01")]
+ assert mt.merge(units, turns) == [
+ "00:00:00 Speaker A: Good morning.",
+ "00:00:06 Speaker B: Sounds good.",
+ ]
+
+ def test_merge_transcript_merge_letters_follow_order_of_first_speech(self):
+ """Normal: Speaker A is whoever talks first, whatever pyannote called them."""
+ units = words(0.0, "First.") + words(4.0, "Second.") + words(8.0, "Again.")
+ turns = [
+ Turn(0.0, 3.0, "SPEAKER_02"),
+ Turn(3.5, 7.0, "SPEAKER_00"),
+ Turn(7.5, 10.0, "SPEAKER_02"),
+ ]
+ assert mt.merge(units, turns) == [
+ "00:00:00 Speaker A: First.",
+ "00:00:04 Speaker B: Second.",
+ "00:00:08 Speaker A: Again.",
+ ]
+
+ def test_merge_transcript_merge_same_speaker_across_turns_stays_one_line(self):
+ """Normal: pyannote splits a speaker's run into turns; the line does not."""
+ units = words(0.0, "One", "two") + words(1.2, "three.")
+ turns = [Turn(0.0, 1.0, "SPEAKER_00"), Turn(1.1, 2.0, "SPEAKER_00")]
+ assert mt.merge(units, turns) == ["00:00:00 Speaker A: One two three."]
+
+ def test_merge_transcript_merge_long_pause_starts_a_new_line(self):
+ """Normal: a pause past max_gap_s breaks the line so timestamps stay useful."""
+ units = words(0.0, "Before.") + words(10.0, "After.")
+ turns = [Turn(0.0, 12.0, "SPEAKER_00")]
+ assert mt.merge(units, turns, max_gap_s=3.0) == [
+ "00:00:00 Speaker A: Before.",
+ "00:00:10 Speaker A: After.",
+ ]
+
+ def test_merge_transcript_merge_word_in_a_gap_goes_to_nearest_turn(self):
+ """Normal: whisper's timing drifts; a word between turns joins the closer one."""
+ units = [Unit(4.6, 4.8, " late"), Unit(5.2, 5.4, " early")]
+ turns = [Turn(0.0, 4.5, "SPEAKER_00"), Turn(5.5, 9.0, "SPEAKER_01")]
+ assert mt.merge(units, turns) == [
+ "00:00:04 Speaker A: late",
+ "00:00:05 Speaker B: early",
+ ]
+
+ def test_merge_transcript_merge_overlapping_turns_pick_the_larger_overlap(self):
+ """Normal: with overlapped speech, the word goes where most of it sits."""
+ # The anchor word fixes SPEAKER_00 as A, so the contested word's label
+ # actually shows which turn won: 0.2 s of overlap with A, 0.9 s with B.
+ units = [Unit(1.0, 1.5, " anchor"), Unit(4.0, 5.0, " contested")]
+ turns = [Turn(0.0, 4.2, "SPEAKER_00"), Turn(4.1, 9.0, "SPEAKER_01")]
+ assert mt.merge(units, turns) == [
+ "00:00:01 Speaker A: anchor",
+ "00:00:04 Speaker B: contested",
+ ]
+
+
+class TestMergeBoundary:
+ def test_merge_transcript_merge_timestamp_past_an_hour_is_floored(self):
+ """Boundary: 3725.9 s renders as 01:02:05."""
+ units = [Unit(3725.9, 3726.4, " Late.")]
+ turns = [Turn(3700.0, 3800.0, "SPEAKER_00")]
+ assert mt.merge(units, turns) == ["01:02:05 Speaker A: Late."]
+
+ def test_merge_transcript_merge_zero_length_and_blank_units_are_handled(self):
+ """Boundary: whisper emits empty units and zero-length words."""
+ units = [Unit(0.0, 0.0, ""), Unit(2.18, 2.18, " we"), Unit(2.18, 2.54, " have")]
+ turns = [Turn(0.0, 5.0, "SPEAKER_00")]
+ assert mt.merge(units, turns) == ["00:00:02 Speaker A: we have"]
+
+ def test_merge_transcript_merge_twenty_seventh_speaker_gets_a_number(self):
+ """Boundary: letters run out after Z."""
+ units, turns = [], []
+ for i in range(27):
+ units += [Unit(i * 10.0, i * 10.0 + 1, f" s{i}")]
+ turns += [Turn(i * 10.0, i * 10.0 + 5, f"SPEAKER_{i:02d}")]
+ lines = mt.merge(units, turns)
+ assert lines[25] == "00:04:10 Speaker Z: s25"
+ assert lines[26] == "00:04:20 Speaker 26: s26"
+
+ def test_merge_transcript_merge_unicode_and_inner_spacing_survive(self):
+ """Boundary: non-ASCII text; whitespace collapses to single spaces."""
+ units = [Unit(0.0, 0.5, " Բարև,"), Unit(0.5, 1.0, " Երևան"), Unit(1.0, 1.5, " — café.")]
+ turns = [Turn(0.0, 2.0, "SPEAKER_00")]
+ assert mt.merge(units, turns) == ["00:00:00 Speaker A: Բարև, Երևան — café."]
+
+ def test_merge_transcript_merge_unsorted_input_is_sorted_first(self):
+ """Boundary: neither list has to arrive in time order."""
+ units = words(6.0, "Second.") + words(0.0, "First.")
+ turns = [Turn(5.0, 9.0, "SPEAKER_01"), Turn(0.0, 4.0, "SPEAKER_00")]
+ assert mt.merge(units, turns) == [
+ "00:00:00 Speaker A: First.",
+ "00:00:06 Speaker B: Second.",
+ ]
+
+
+class TestMergeError:
+ def test_merge_transcript_merge_no_turns_raises(self):
+ """Error: words with no diarization cannot be labelled."""
+ with pytest.raises(ValueError, match="no speaker turns"):
+ mt.merge(words(0.0, "Hello."), [])
+
+ def test_merge_transcript_merge_no_words_raises(self):
+ """Error: an empty transcription is a failure, not an empty transcript."""
+ with pytest.raises(ValueError, match="no speech"):
+ mt.merge([Unit(0.0, 0.0, " ")], [Turn(0.0, 5.0, "SPEAKER_00")])
+
+ def test_merge_transcript_merge_negative_gap_setting_raises(self):
+ """Error: max_gap_s must not be negative."""
+ with pytest.raises(ValueError, match="max_gap_s"):
+ mt.merge(words(0.0, "Hi."), [Turn(0.0, 1.0, "SPEAKER_00")], max_gap_s=-1)
+
+
+class TestLoaders:
+ def test_merge_transcript_load_whisper_json_reads_millisecond_offsets(self, tmp_path):
+ """Normal: whisper-cli -oj stores offsets in milliseconds."""
+ path = tmp_path / "w.json"
+ path.write_text(json.dumps({"transcription": [
+ {"offsets": {"from": 0, "to": 190}, "text": " if"},
+ {"offsets": {"from": 190, "to": 590}, "text": " there's"},
+ ]}))
+ assert mt.load_whisper_json(path) == [Unit(0.0, 0.19, " if"), Unit(0.19, 0.59, " there's")]
+
+ def test_merge_transcript_load_turns_json_reads_seconds(self, tmp_path):
+ """Normal: the diarizer writes start/end in seconds."""
+ path = tmp_path / "t.json"
+ path.write_text(json.dumps([{"start": 0.5, "end": 4.25, "speaker": "SPEAKER_00"}]))
+ assert mt.load_turns_json(path) == [Turn(0.5, 4.25, "SPEAKER_00")]
+
+ @pytest.mark.parametrize("content", ["not json", "{}", '{"transcription": [{"text": "x"}]}'])
+ def test_merge_transcript_load_whisper_json_malformed_raises(self, tmp_path, content):
+ """Error: a truncated or foreign file is rejected with its path named."""
+ path = tmp_path / "w.json"
+ path.write_text(content)
+ with pytest.raises(ValueError, match="w.json"):
+ mt.load_whisper_json(path)
+
+ @pytest.mark.parametrize("content", ["not json", "{}", '[{"start": 1}]'])
+ def test_merge_transcript_load_turns_json_malformed_raises(self, tmp_path, content):
+ """Error: same for the turns file."""
+ path = tmp_path / "t.json"
+ path.write_text(content)
+ with pytest.raises(ValueError, match="t.json"):
+ mt.load_turns_json(path)
+
+
+class TestLoadersMissingFile:
+ def test_merge_transcript_load_whisper_json_missing_file_raises_value_error(self, tmp_path):
+ """Error: a missing whisper file is a clean ValueError naming the path."""
+ with pytest.raises(ValueError, match="gone.json"):
+ mt.load_whisper_json(tmp_path / "gone.json")
+
+ def test_merge_transcript_load_turns_json_missing_file_raises_value_error(self, tmp_path):
+ """Error: a missing turns file (the diarizer failed upstream) is a clean ValueError."""
+ with pytest.raises(ValueError, match="gone.json"):
+ mt.load_turns_json(tmp_path / "gone.json")
+
+ def test_merge_transcript_main_missing_turns_file_exits_one_without_traceback(self, tmp_path, capsys):
+ """Error: the CLI reports it in one line and prints nothing on stdout."""
+ w = tmp_path / "w.json"
+ w.write_text('{"transcription": [{"offsets": {"from": 0, "to": 500}, "text": " Hi."}]}')
+ assert mt.main([str(w), str(tmp_path / "gone.json")]) == 1
+ captured = capsys.readouterr()
+ assert captured.out == ""
+ assert "gone.json" in captured.err and "Traceback" not in captured.err
+
+
+class TestCli:
+ def _files(self, tmp_path, transcription, turns):
+ w = tmp_path / "w.json"
+ t = tmp_path / "t.json"
+ w.write_text(json.dumps({"transcription": transcription}))
+ t.write_text(json.dumps(turns))
+ return str(w), str(t)
+
+ def test_merge_transcript_main_prints_transcript_and_returns_zero(self, tmp_path, capsys):
+ """Normal: the CLI writes the lines to stdout."""
+ w, t = self._files(
+ tmp_path,
+ [{"offsets": {"from": 0, "to": 500}, "text": " Hello."}],
+ [{"start": 0.0, "end": 2.0, "speaker": "SPEAKER_00"}],
+ )
+ assert mt.main([w, t]) == 0
+ assert capsys.readouterr().out == "00:00:00 Speaker A: Hello.\n"
+
+ def test_merge_transcript_main_failure_prints_nothing_on_stdout(self, tmp_path, capsys):
+ """Error: a failed merge exits 1 with the reason on stderr only."""
+ w, t = self._files(tmp_path, [], [{"start": 0.0, "end": 2.0, "speaker": "SPEAKER_00"}])
+ assert mt.main([w, t]) == 1
+ captured = capsys.readouterr()
+ assert captured.out == ""
+ assert "no speech" in captured.err
+
+ def test_merge_transcript_main_wrong_argument_count_returns_two(self, capsys):
+ """Error: usage."""
+ assert mt.main([]) == 2
+ assert "usage" in capsys.readouterr().err.lower()
+
+
+def tok(start_ms, end_ms, text):
+ return {"text": text, "offsets": {"from": start_ms, "to": end_ms}}
+
+
+class TestLoadWhisperTokens:
+ """whisper-cli -ojf keeps normal segments and adds per-token offsets inside each."""
+
+ def _write(self, tmp_path, segments):
+ path = tmp_path / "full.json"
+ path.write_text(json.dumps({"transcription": segments}))
+ return path
+
+ def test_merge_transcript_load_whisper_json_prefers_tokens_over_segments(self, tmp_path):
+ """Normal: with tokens present, units are words, not whole segments."""
+ path = self._write(tmp_path, [{
+ "offsets": {"from": 0, "to": 2000}, "text": " we basically",
+ "tokens": [tok(0, 0, "[_BEG_]"), tok(10, 400, " we"), tok(500, 1900, " basically"), tok(2000, 2000, "[_TT_100]")],
+ }])
+ assert mt.load_whisper_json(path) == [Unit(0.01, 0.4, " we"), Unit(0.5, 1.9, " basically")]
+
+ def test_merge_transcript_load_whisper_json_joins_subword_and_punctuation_tokens(self, tmp_path):
+ """Normal: a token with no leading space continues the previous word."""
+ path = self._write(tmp_path, [{
+ "offsets": {"from": 0, "to": 3000}, "text": " Saturday, yes.",
+ "tokens": [tok(0, 300, " Sat"), tok(300, 700, "urday"), tok(700, 750, ","), tok(900, 1300, " yes"), tok(1300, 1350, ".")],
+ }])
+ assert mt.load_whisper_json(path) == [Unit(0.0, 0.75, " Saturday,"), Unit(0.9, 1.35, " yes.")]
+
+ def test_merge_transcript_load_whisper_json_segment_without_tokens_falls_back(self, tmp_path):
+ """Boundary: plain -oj output, or a segment whose token list is empty."""
+ path = self._write(tmp_path, [
+ {"offsets": {"from": 0, "to": 1000}, "text": " First.", "tokens": []},
+ {"offsets": {"from": 1000, "to": 2000}, "text": " Second."},
+ ])
+ assert mt.load_whisper_json(path) == [Unit(0.0, 1.0, " First."), Unit(1.0, 2.0, " Second.")]
+
+ def test_merge_transcript_load_whisper_json_leading_continuation_token_stands_alone(self, tmp_path):
+ """Boundary: the very first token has nothing to attach to."""
+ path = self._write(tmp_path, [{
+ "offsets": {"from": 0, "to": 500}, "text": "ing on",
+ "tokens": [tok(0, 200, "ing"), tok(200, 500, " on")],
+ }])
+ assert mt.load_whisper_json(path) == [Unit(0.0, 0.2, "ing"), Unit(0.2, 0.5, " on")]
+
+ def test_merge_transcript_load_whisper_json_token_without_offsets_raises(self, tmp_path):
+ """Error: a malformed token is rejected, naming the file."""
+ path = self._write(tmp_path, [{"offsets": {"from": 0, "to": 1}, "text": " x", "tokens": [{"text": " x"}]}])
+ with pytest.raises(ValueError, match="full.json"):
+ mt.load_whisper_json(path)
+
+
+class TestFindRepetition:
+ def test_merge_transcript_find_repetition_clean_text_returns_none(self):
+ """Normal: ordinary speech, even with stock phrases scattered about."""
+ text = " ".join(f"point {i} and i don't know if that works for us" for i in range(8))
+ assert mt.find_repetition(text) is None
+
+ def test_merge_transcript_find_repetition_back_to_back_phrase_is_reported(self):
+ """Normal: whisper's hallucination loop, the same phrase again and again."""
+ text = "okay so " + "We don't know where we're going to be. " * 6 + "anyway moving on"
+ hit = mt.find_repetition(text)
+ assert hit is not None
+ phrase, count = hit
+ assert count >= 6
+ assert "where we're going to be" in phrase.lower()
+
+ def test_merge_transcript_find_repetition_three_repeats_is_tolerated(self):
+ """Boundary: people do say a thing three times; four in a row is the line."""
+ assert mt.find_repetition("go back to this area " * 3) is None
+ assert mt.find_repetition("go back to this area " * 4) is not None
+
+ def test_merge_transcript_find_repetition_short_fillers_are_not_loops(self):
+ """Boundary: 'yeah yeah yeah yeah yeah' is speech, not a loop."""
+ assert mt.find_repetition("yeah " * 9 + "no no no no no") is None
+
+ def test_merge_transcript_find_repetition_empty_text_returns_none(self):
+ """Boundary: nothing to scan."""
+ assert mt.find_repetition("") is None
+
+ def test_merge_transcript_main_loop_in_transcript_fails(self, tmp_path, capsys):
+ """Error: a looping transcription exits 1 with nothing on stdout."""
+ words_ = ("we don't know where we're going to be " * 5).split()
+ # 40 words at 3 s each: two minutes of loop, far past the 30 s a short stutter gets
+ segs = [{"offsets": {"from": i * 3_000, "to": i * 3_000 + 2_500}, "text": f" {w}"} for i, w in enumerate(words_)]
+ w = tmp_path / "w.json"
+ w.write_text(json.dumps({"transcription": segs}))
+ t = tmp_path / "t.json"
+ t.write_text(json.dumps([{"start": 0.0, "end": 130.0, "speaker": "SPEAKER_00"}]))
+ assert mt.main([str(w), str(t)]) == 1
+ captured = capsys.readouterr()
+ assert captured.out == ""
+ assert "repeat" in captured.err.lower()
+
+
+class TestInvertedTokenTimes:
+ """whisper-cli sometimes clamps a token's start to its segment, leaving end < start."""
+
+ def test_merge_transcript_load_whisper_json_inverted_token_becomes_zero_length(self, tmp_path):
+ """Boundary: an end before the start is treated as a zero-length word at the start."""
+ path = tmp_path / "inv.json"
+ path.write_text(json.dumps({"transcription": [{
+ "offsets": {"from": 13120, "to": 16160}, "text": " which you",
+ "tokens": [tok(13120, 9580, " which"), tok(13120, 10140, " you")],
+ }]}))
+ assert mt.load_whisper_json(path) == [Unit(13.12, 13.12, " which"), Unit(13.12, 13.12, " you")]
+
+ def test_merge_transcript_merge_inverted_tokens_do_not_split_a_sentence(self, tmp_path):
+ """Normal: the real case, after a pause longer than max_gap_s."""
+ path = tmp_path / "inv.json"
+ path.write_text(json.dumps({"transcription": [
+ {"offsets": {"from": 5360, "to": 8640}, "text": " blue pixels",
+ "tokens": [tok(5360, 7000, " blue"), tok(7000, 8640, " pixels")]},
+ {"offsets": {"from": 13120, "to": 16160}, "text": " which you know",
+ "tokens": [tok(13120, 9580, " which"), tok(13120, 10140, " you"), tok(13120, 10890, " know")]},
+ ]}))
+ turns = [Turn(0.0, 8.8, "SPEAKER_00"), Turn(13.3, 16.0, "SPEAKER_00")]
+ assert mt.merge(mt.load_whisper_json(path), turns) == [
+ "00:00:05 Speaker A: blue pixels",
+ "00:00:13 Speaker A: which you know",
+ ]
+
+
+class TestDropOutsideSpeech:
+ """Whisper invents words in silence; the diarizer knows where the speech is."""
+
+ def test_merge_transcript_drop_outside_speech_removes_words_far_from_any_turn(self):
+ """Normal: a hallucinated run in a silent stretch goes; real words stay."""
+ real = words(0.0, "Good", "morning.")
+ invented = words(60.0, "Thank", "you.", "Thank", "you.", step=5.0)
+ kept, dropped = mt.drop_outside_speech(real + invented, [Turn(0.0, 3.0, "SPEAKER_00")])
+ assert kept == real
+ assert dropped == 4
+
+ def test_merge_transcript_drop_outside_speech_keeps_words_between_close_turns(self):
+ """Normal: a word in a short gap between two turns is speech the diarizer clipped."""
+ units = words(0.0, "One.") + words(3.2, "and") + words(4.0, "two.")
+ turns = [Turn(0.0, 3.0, "SPEAKER_00"), Turn(4.0, 6.0, "SPEAKER_01")]
+ assert mt.drop_outside_speech(units, turns) == (units, 0)
+
+ def test_merge_transcript_drop_outside_speech_word_exactly_at_the_margin_is_kept(self):
+ """Boundary: the margin is inclusive."""
+ unit = Unit(5.0, 5.5, " edge")
+ assert mt.drop_outside_speech([unit], [Turn(0.0, 3.0, "S")], margin_s=2.0) == ([unit], 0)
+
+ def test_merge_transcript_drop_outside_speech_word_just_past_the_margin_is_dropped(self):
+ """Boundary: one millisecond further and it goes."""
+ unit = Unit(5.001, 5.5, " edge")
+ assert mt.drop_outside_speech([unit], [Turn(0.0, 3.0, "S")], margin_s=2.0) == ([], 1)
+
+ def test_merge_transcript_drop_outside_speech_zero_margin_needs_contact_with_a_turn(self):
+ """Boundary: margin 0 keeps a word touching a turn and drops one that is not."""
+ touching, apart = Unit(3.0, 3.4, " touch"), Unit(3.5, 3.9, " apart")
+ kept, dropped = mt.drop_outside_speech([touching, apart], [Turn(0.0, 3.0, "S")], margin_s=0.0)
+ assert (kept, dropped) == ([touching], 1)
+
+ def test_merge_transcript_drop_outside_speech_before_the_first_turn_counts_too(self):
+ """Boundary: silence at the start of a recording."""
+ early = Unit(1.0, 1.5, " Thanks.")
+ assert mt.drop_outside_speech([early], [Turn(30.0, 40.0, "S")]) == ([], 1)
+
+ def test_merge_transcript_drop_outside_speech_no_units_is_a_no_op(self):
+ """Boundary: nothing in, nothing out."""
+ assert mt.drop_outside_speech([], [Turn(0.0, 3.0, "S")]) == ([], 0)
+
+ def test_merge_transcript_drop_outside_speech_no_turns_raises(self):
+ """Error: without turns there is no way to tell speech from silence."""
+ with pytest.raises(ValueError, match="no speaker turns"):
+ mt.drop_outside_speech(words(0.0, "Hello."), [])
+
+ def test_merge_transcript_drop_outside_speech_negative_margin_raises(self):
+ """Error: a negative margin is a caller bug."""
+ with pytest.raises(ValueError, match="margin"):
+ mt.drop_outside_speech(words(0.0, "Hello."), [Turn(0.0, 3.0, "S")], margin_s=-1.0)
+
+ def test_merge_transcript_main_silence_hallucinations_do_not_trip_the_loop_guard(self, tmp_path, capsys):
+ """Normal: a run of invented thank-yous in silence is dropped, not reported as a loop."""
+ transcription = [{"offsets": {"from": 0, "to": 900}, "text": " Good morning."}] + [
+ {"offsets": {"from": 60_000 + i * 5_000, "to": 64_000 + i * 5_000}, "text": " Thank you."}
+ for i in range(12)
+ ]
+ whisper = tmp_path / "w.json"
+ whisper.write_text(json.dumps({"transcription": transcription}))
+ turns = tmp_path / "t.json"
+ turns.write_text(json.dumps([{"start": 0.0, "end": 2.0, "speaker": "SPEAKER_00"}]))
+ assert mt.main([str(whisper), str(turns)]) == 0
+ assert capsys.readouterr().out == "00:00:00 Speaker A: Good morning.\n"
+
+ def test_merge_transcript_main_a_loop_inside_speech_still_fails(self, tmp_path, capsys):
+ """Error: a real repetition loop happens while someone is talking, and is still caught."""
+ transcription = [
+ {"offsets": {"from": i * 1_000, "to": i * 1_000 + 900}, "text": " where we're going to be"}
+ for i in range(60) # a full minute of the same phrase
+ ]
+ whisper = tmp_path / "w.json"
+ whisper.write_text(json.dumps({"transcription": transcription}))
+ turns = tmp_path / "t.json"
+ turns.write_text(json.dumps([{"start": 0.0, "end": 70.0, "speaker": "SPEAKER_00"}]))
+ assert mt.main([str(whisper), str(turns)]) == 1
+ assert "looped" in capsys.readouterr().err
+
+
+class TestCollapseRepetitions:
+ """A short stutter is collapsed to one occurrence; a long loop still fails the job."""
+
+ def test_merge_transcript_collapse_repetitions_short_loop_keeps_one_copy(self):
+ """Normal: a six-word phrase said four times in ten seconds becomes one phrase."""
+ units = words(0.0, "So", "anyway,") + words(1.0, *("fair, it's not going to be".split() * 4), step=0.4) + words(12.0, "done.")
+ kept, collapsed = mt.collapse_repetitions(units)
+ assert " ".join(u.text.strip() for u in kept) == "So anyway, fair, it's not going to be done."
+ assert collapsed == [("fair, it's not going to be", 4)]
+
+ def test_merge_transcript_collapse_repetitions_clean_units_are_untouched(self):
+ """Normal: ordinary speech passes through with nothing collapsed."""
+ units = words(0.0, "We", "have", "detection", "today,", "and", "we", "have", "a", "plan.")
+ assert mt.collapse_repetitions(units) == (units, [])
+
+ def test_merge_transcript_collapse_repetitions_two_loops_both_collapse(self):
+ """Normal: separate stutters are each collapsed and each reported."""
+ units = (words(0.0, *("go back to this area".split() * 4), step=0.3)
+ + words(10.0, "then")
+ + words(11.0, *("where we're going to be".split() * 5), step=0.3))
+ kept, collapsed = mt.collapse_repetitions(units)
+ assert " ".join(u.text.strip() for u in kept) == "go back to this area then where we're going to be"
+ assert collapsed == [("go back to this area", 4), ("where we're going to be", 5)]
+
+ def test_merge_transcript_collapse_repetitions_three_repeats_are_speech(self):
+ """Boundary: three repeats is emphasis, not a loop, and stays."""
+ units = words(0.0, *("this is the thing".split() * 3))
+ assert mt.collapse_repetitions(units) == (units, [])
+
+ def test_merge_transcript_collapse_repetitions_single_word_runs_stay(self):
+ """Boundary: "yeah yeah yeah yeah" is ordinary speech."""
+ units = words(0.0, *(["yeah"] * 8))
+ assert mt.collapse_repetitions(units) == (units, [])
+
+ def test_merge_transcript_collapse_repetitions_loop_at_the_limit_is_collapsed(self):
+ """Boundary: a loop lasting exactly max_loop_s is still a short one."""
+ units = words(0.0, *("we do not know where".split() * 4), step=1.5) # 20 words, 30.0 s
+ kept, collapsed = mt.collapse_repetitions(units, max_loop_s=30.0)
+ assert collapsed == [("we do not know where", 4)] and len(kept) == 5
+
+ def test_merge_transcript_collapse_repetitions_loop_past_the_limit_raises(self):
+ """Error: a loop longer than max_loop_s means real speech was lost, so the job fails."""
+ units = words(0.0, *("we do not know where".split() * 4), step=1.6) # 32.0 s
+ with pytest.raises(ValueError, match="looped"):
+ mt.collapse_repetitions(units, max_loop_s=30.0)
+
+ def test_merge_transcript_collapse_repetitions_segment_units_collapse_too(self):
+ """Boundary: whisper's segment fallback puts a whole phrase in one unit."""
+ units = [Unit(i * 1.0, i * 1.0 + 0.9, " where we're going to be") for i in range(6)]
+ kept, collapsed = mt.collapse_repetitions(units)
+ assert len(kept) == 1 and collapsed == [("where we're going to be", 6)]
+
+ def test_merge_transcript_collapse_repetitions_negative_limit_raises(self):
+ """Error: a negative limit is a caller bug."""
+ with pytest.raises(ValueError, match="max_loop_s"):
+ mt.collapse_repetitions(words(0.0, "hi"), max_loop_s=-1.0)
+
+ def test_merge_transcript_main_short_loop_is_collapsed_not_fatal(self, tmp_path, capsys):
+ """Normal: the CLI prints the collapsed transcript and exits 0."""
+ words_ = "we have detection today " .split() + ("fair, it's not going to be " * 4).split() + "easy.".split()
+ segs = [{"offsets": {"from": i * 300, "to": i * 300 + 250}, "text": f" {w}"} for i, w in enumerate(words_)]
+ w = tmp_path / "w.json"; w.write_text(json.dumps({"transcription": segs}))
+ t = tmp_path / "t.json"; t.write_text(json.dumps([{"start": 0.0, "end": 60.0, "speaker": "SPEAKER_00"}]))
+ assert mt.main([str(w), str(t)]) == 0
+ assert capsys.readouterr().out == "00:00:00 Speaker A: we have detection today fair, it's not going to be easy.\n"
+
+
+class TestCollapseRepetitionsInsideUnits:
+ """The repeat can live inside one unit's text, which is what whisper's segment fallback emits."""
+
+ def test_merge_transcript_collapse_repetitions_loop_inside_one_unit_is_collapsed(self):
+ """Error case turned regression: a single unit holding the phrase four times must not hang."""
+ unit = Unit(0.0, 8.0, " " + " ".join(["we do not know where"] * 4))
+ kept, collapsed = mt.collapse_repetitions(unit and [unit])
+ assert [u.text for u in kept] == [" we do not know where"]
+ assert collapsed == [("we do not know where", 4)]
+
+ def test_merge_transcript_collapse_repetitions_partial_unit_keeps_its_other_words(self):
+ """Boundary: a unit holding the last repeat and real words after it keeps the real words."""
+ units = words(0.0, *("go back to this area".split() * 3), step=0.5) + [
+ Unit(7.5, 9.0, " go back to this area and then we stopped.")
+ ]
+ kept, collapsed = mt.collapse_repetitions(units)
+ assert " ".join(u.text.strip() for u in kept) == "go back to this area and then we stopped."
+ assert collapsed == [("go back to this area", 4)]
diff --git a/working/meeting-transcription-service/tests/test_ratio_transcribe.py b/working/meeting-transcription-service/tests/test_ratio_transcribe.py
new file mode 100644
index 0000000..1a10b3e
--- /dev/null
+++ b/working/meeting-transcription-service/tests/test_ratio_transcribe.py
@@ -0,0 +1,263 @@
+"""Tests for ratio-transcribe, the client.
+
+ssh and scp are replaced by fakes that act on a temp directory standing in for the
+remote home, so the client's real logic (job ids, upload-then-rename, polling,
+collecting, the local fallback) runs against a filesystem it can't tell from the host.
+"""
+
+import json
+import os
+import subprocess
+from pathlib import Path
+
+import pytest
+
+SCRIPT = Path(__file__).resolve().parent.parent / "src" / "ratio-transcribe"
+STATE = ".local/state/meeting-transcribe"
+
+FAKE_SSH = r"""#!/usr/bin/env bash
+# ssh [-o k=v]... host command... -> run the command with HOME at the fake remote
+[[ -n "${FAKE_SSH_DOWN:-}" ]] && exit 255
+no_stdin=""
+while [[ "$1" == -* ]]; do [[ "$1" == "-n" ]] && no_stdin=1; [[ "$1" == "-o" ]] && shift; shift; done
+shift # host
+printf '%s\n' "$*" >> "$FAKE_SSH_LOG"
+if [[ -n "$no_stdin" ]]; then
+ ( cd "$FAKE_REMOTE_HOME" && HOME="$FAKE_REMOTE_HOME" bash -c "$*" < /dev/null )
+ rc=$?
+else
+ ( cd "$FAKE_REMOTE_HOME" && HOME="$FAKE_REMOTE_HOME" bash -c "$*" )
+ rc=$?
+ cat > /dev/null # like the real ssh, drain whatever stdin the command left behind
+fi
+if [[ "$*" == *"incoming/"* && "$*" == mv* && -n "${FAKE_ON_SUBMIT:-}" ]]; then
+ ( cd "$FAKE_REMOTE_HOME" && bash -c "$FAKE_ON_SUBMIT" )
+fi
+exit $rc
+"""
+
+FAKE_SCP = r"""#!/usr/bin/env bash
+[[ -n "${FAKE_SSH_DOWN:-}" ]] && exit 255
+while [[ "$1" == -* ]]; do [[ "$1" == "-o" ]] && shift; shift; done
+printf '%s -> %s\n' "$1" "$2" >> "$FAKE_SCP_LOG"
+cp "$1" "$FAKE_REMOTE_HOME/${2#*:}"
+"""
+
+# What the worker would do, compressed: finish or fail whatever sits in incoming/.
+WORKER_OK = f"""cd {STATE}; mkdir -p done; for d in incoming/*/; do id=$(basename "$d");
+ printf '00:00:00 Speaker A: Hello from the host.\\n' > done/$id.txt; rm -rf "$d"; done"""
+WORKER_FAIL = f"""cd {STATE}; mkdir -p failed; for d in incoming/*/; do id=$(basename "$d");
+ printf 'stage: whisper\\nexit 3\\n' > failed/$id.log; rm -rf "$d"; done"""
+WORKER_EMPTY = f"""cd {STATE}; mkdir -p done; for d in incoming/*/; do id=$(basename "$d");
+ : > done/$id.txt; rm -rf "$d"; done"""
+
+
+class Rig:
+ def __init__(self, tmp_path):
+ self.tmp = tmp_path
+ self.remote = tmp_path / "remote-home"
+ self.remote.mkdir()
+ self.local_home = tmp_path / "local-home"
+ self.local_home.mkdir()
+ self.bin = tmp_path / "bin"
+ self.bin.mkdir()
+ for name, body in (("ssh", FAKE_SSH), ("scp", FAKE_SCP)):
+ path = self.bin / name
+ path.write_text(body)
+ path.chmod(0o755)
+ self.ssh_log = tmp_path / "ssh.log"
+ self.scp_log = tmp_path / "scp.log"
+ self.audio = tmp_path / "meeting.m4a"
+ self.audio.write_bytes(b"pretend audio")
+
+ def run(self, args=None, on_submit=WORKER_OK, **env_extra):
+ env = {
+ "PATH": f"{self.bin}:{os.environ['PATH']}",
+ "HOME": str(self.local_home),
+ "FAKE_REMOTE_HOME": str(self.remote),
+ "FAKE_SSH_LOG": str(self.ssh_log),
+ "FAKE_SCP_LOG": str(self.scp_log),
+ "TRANSCRIBE_HOST": "testhost",
+ "TRANSCRIBE_POLL": "0",
+ "TRANSCRIBE_TIMEOUT": "5",
+ }
+ if on_submit:
+ env["FAKE_ON_SUBMIT"] = on_submit
+ env.update({k: str(v) for k, v in env_extra.items()})
+ if args is None:
+ args = [str(self.audio)]
+ return subprocess.run([str(SCRIPT), *args], env=env, capture_output=True, text=True, timeout=60)
+
+ def state(self, *parts):
+ return self.remote.joinpath(STATE, *parts)
+
+ def uploads(self):
+ return self.scp_log.read_text().splitlines() if self.scp_log.exists() else []
+
+
+@pytest.fixture
+def rig(tmp_path):
+ return Rig(tmp_path)
+
+
+class TestClientNormal:
+ def test_ratio_transcribe_new_recording_is_uploaded_and_transcript_printed(self, rig):
+ """Normal: upload, wait, print. stdout is the transcript and nothing else."""
+ result = rig.run()
+ assert result.returncode == 0, result.stderr
+ assert result.stdout == "00:00:00 Speaker A: Hello from the host.\n"
+ assert len(rig.uploads()) == 1
+
+ def test_ratio_transcribe_job_file_carries_language_speakers_and_name(self, rig):
+ """Normal: the options reach the host inside job.json."""
+ rig.run(args=[str(rig.audio), "es"], on_submit=None, SPEAKERS=3, TRANSCRIBE_TIMEOUT=0)
+ jobs = list(rig.state("incoming").glob("*/job.json"))
+ assert len(jobs) == 1
+ assert json.loads(jobs[0].read_text()) == {
+ "language": "es", "speakers": 3, "original_name": "meeting.m4a",
+ }
+ assert (jobs[0].parent / "audio.m4a").read_bytes() == b"pretend audio"
+
+ def test_ratio_transcribe_finished_job_is_collected_without_uploading_again(self, rig):
+ """Normal: rerunning after a dropped connection costs one ssh round trip."""
+ first = rig.run()
+ rig.scp_log.unlink()
+ second = rig.run(on_submit=None)
+ assert second.returncode == 0
+ assert second.stdout == first.stdout
+ assert rig.uploads() == []
+
+ def test_ratio_transcribe_job_id_depends_on_audio_and_options(self, rig):
+ """Normal: same file with a different speaker count is a different job."""
+ rig.run(on_submit=None, TRANSCRIBE_TIMEOUT=0)
+ rig.run(on_submit=None, TRANSCRIBE_TIMEOUT=0)
+ rig.run(on_submit=None, TRANSCRIBE_TIMEOUT=0, SPEAKERS=3)
+ assert len(list(rig.state("incoming").iterdir())) == 2
+
+ def test_ratio_transcribe_earlier_failure_is_retried(self, rig):
+ """Normal: a stale failure log does not block a fresh attempt."""
+ assert rig.run(on_submit=WORKER_FAIL).returncode == 1
+ result = rig.run(on_submit=WORKER_OK)
+ assert result.returncode == 0
+ assert "Hello from the host" in result.stdout
+ assert list(rig.state("failed").glob("*.log")) == []
+
+
+class TestClientBoundary:
+ def test_ratio_transcribe_job_still_queued_is_not_uploaded_twice(self, rig):
+ """Boundary: a second run while the first job waits just joins the wait."""
+ rig.run(on_submit=None, TRANSCRIBE_TIMEOUT=0)
+ rig.run(on_submit=None, TRANSCRIBE_TIMEOUT=0)
+ assert len(rig.uploads()) == 1
+
+ def test_ratio_transcribe_upload_lands_in_incoming_only_by_rename(self, rig):
+ """Boundary: the audio is copied into uploading/, never straight into incoming/."""
+ rig.run(on_submit=None, TRANSCRIBE_TIMEOUT=0)
+ assert "/uploading/" in rig.uploads()[0]
+ assert list(rig.state("uploading").iterdir()) == []
+
+ def test_ratio_transcribe_awkward_filename_survives(self, rig):
+ """Boundary: spaces and quotes in the recording's name."""
+ odd = rig.tmp / "someone's \"weekly\" sync.m4a"
+ odd.write_bytes(b"x")
+ result = rig.run(args=[str(odd)], on_submit=None, TRANSCRIBE_TIMEOUT=0)
+ job = json.loads(next(rig.state("incoming").glob("*/job.json")).read_text())
+ assert job["original_name"] == "someone's \"weekly\" sync.m4a", result.stderr
+
+ def test_ratio_transcribe_colon_in_a_relative_filename_still_uploads(self, rig):
+ """Boundary: scp reads "standup 14:30.m4a" as host:path; the client hands it an absolute path."""
+ (rig.tmp / "standup 14:30.m4a").write_bytes(b"x")
+ env = {
+ "PATH": f"{rig.bin}:{os.environ['PATH']}", "HOME": str(rig.local_home),
+ "FAKE_REMOTE_HOME": str(rig.remote), "FAKE_SSH_LOG": str(rig.ssh_log),
+ "FAKE_SCP_LOG": str(rig.scp_log), "FAKE_ON_SUBMIT": WORKER_OK,
+ "TRANSCRIBE_HOST": "testhost", "TRANSCRIBE_POLL": "0", "TRANSCRIBE_TIMEOUT": "5",
+ }
+ result = subprocess.run(
+ [str(SCRIPT), "standup 14:30.m4a"], cwd=rig.tmp, env=env, capture_output=True, text=True, timeout=60,
+ )
+ assert result.returncode == 0, result.stderr
+ assert rig.uploads()[0].startswith("/")
+
+ def test_ratio_transcribe_host_unreachable_falls_back_to_local_worker(self, rig):
+ """Boundary: offline, the same queue and worker run on this machine."""
+ worker = rig.tmp / "fake-worker"
+ worker.write_text(
+ "#!/usr/bin/env bash\n"
+ f"cd \"$HOME/{STATE}\" && mkdir -p done && for d in incoming/*/; do id=$(basename \"$d\");\n"
+ "printf '00:00:00 Speaker A: Local fallback.\\n' > done/$id.txt; rm -rf \"$d\"; done\n"
+ )
+ worker.chmod(0o755)
+ result = rig.run(FAKE_SSH_DOWN=1, TRANSCRIBE_WORKER=str(worker))
+ assert result.returncode == 0, result.stderr
+ assert result.stdout == "00:00:00 Speaker A: Local fallback.\n"
+ assert "local" in result.stderr.lower()
+
+
+class TestClientStdin:
+ def test_ratio_transcribe_leaves_the_callers_stdin_alone(self, rig):
+ """Boundary: in a `while read` loop the client must not eat the loop's input."""
+ env = {
+ "PATH": f"{rig.bin}:{os.environ['PATH']}", "HOME": str(rig.local_home),
+ "FAKE_REMOTE_HOME": str(rig.remote), "FAKE_SSH_LOG": str(rig.ssh_log),
+ "FAKE_SCP_LOG": str(rig.scp_log), "FAKE_ON_SUBMIT": WORKER_OK,
+ "TRANSCRIBE_HOST": "testhost", "TRANSCRIBE_POLL": "0", "TRANSCRIBE_TIMEOUT": "5",
+ }
+ result = subprocess.run(
+ ["bash", "-c", '"$0" "$1" > /dev/null 2>&1; cat', str(SCRIPT), str(rig.audio)],
+ env=env, input="next line of the caller's loop\n", capture_output=True, text=True, timeout=60,
+ )
+ assert result.stdout == "next line of the caller's loop\n"
+
+
+class TestClientError:
+ def test_ratio_transcribe_no_arguments_prints_usage(self, rig):
+ """Error: usage."""
+ result = rig.run(args=[])
+ assert result.returncode == 1 and "Usage: ratio-transcribe" in result.stderr and result.stdout == ""
+
+ def test_ratio_transcribe_missing_file_fails_before_any_ssh(self, rig):
+ """Error: no such recording."""
+ result = rig.run(args=[str(rig.tmp / "nope.m4a")])
+ assert result.returncode == 1 and "not found" in result.stderr
+ assert not rig.ssh_log.exists()
+
+ @pytest.mark.parametrize("env", [{"SPEAKERS": "0"}, {"SPEAKERS": "three"}, {"MIN_SPEAKERS": "-1"},
+ {"MIN_SPEAKERS": "4", "MAX_SPEAKERS": "2"}, {"SPEAKERS": "3", "MAX_SPEAKERS": "5"}])
+ def test_ratio_transcribe_bad_speaker_settings_are_rejected(self, rig, env):
+ """Error: non-positive, non-numeric or contradictory counts."""
+ result = rig.run(**env)
+ assert result.returncode == 1 and "speaker" in result.stderr.lower()
+ assert not rig.ssh_log.exists()
+
+ def test_ratio_transcribe_bad_language_is_rejected(self, rig):
+ """Error: the language ends up in a job file and a command line."""
+ result = rig.run(args=[str(rig.audio), "en; rm -rf /"])
+ assert result.returncode == 1 and "language" in result.stderr.lower()
+ assert not rig.ssh_log.exists()
+
+ def test_ratio_transcribe_failed_job_reports_the_log_and_prints_nothing(self, rig):
+ """Error: the host's failure log comes back on stderr."""
+ result = rig.run(on_submit=WORKER_FAIL)
+ assert result.returncode == 1
+ assert result.stdout == ""
+ assert "stage: whisper" in result.stderr
+
+ def test_ratio_transcribe_timeout_says_the_job_is_still_running(self, rig):
+ """Error: giving up waiting is not the job failing."""
+ result = rig.run(on_submit=None, TRANSCRIBE_TIMEOUT=0)
+ assert result.returncode == 1
+ assert result.stdout == ""
+ assert "again" in result.stderr.lower()
+
+ def test_ratio_transcribe_empty_transcript_is_a_failure(self, rig):
+ """Error: a zero-byte result is never passed off as a transcript."""
+ result = rig.run(on_submit=WORKER_EMPTY, TRANSCRIBE_TIMEOUT=1)
+ assert result.returncode == 1
+ assert result.stdout == ""
+
+ def test_ratio_transcribe_offline_without_local_worker_names_what_is_missing(self, rig):
+ """Error: no host and nothing installed locally."""
+ result = rig.run(FAKE_SSH_DOWN=1, TRANSCRIBE_WORKER=str(rig.tmp / "absent"))
+ assert result.returncode == 1
+ assert "absent" in result.stderr
diff --git a/working/meeting-transcription-service/tests/test_transcribe_worker.py b/working/meeting-transcription-service/tests/test_transcribe_worker.py
new file mode 100644
index 0000000..bfffd31
--- /dev/null
+++ b/working/meeting-transcription-service/tests/test_transcribe_worker.py
@@ -0,0 +1,359 @@
+"""Tests for transcribe-worker: the queue drain on the transcription host.
+
+ffmpeg, whisper-cli and the diarizer are replaced by small fake executables (the
+process boundary). The queue handling, the merge and the loop guard run for real.
+"""
+
+import importlib.machinery
+import importlib.util
+import json
+import os
+import stat
+import sys
+from pathlib import Path
+
+import pytest
+
+SRC = Path(__file__).resolve().parent.parent / "src"
+sys.path.insert(0, str(SRC))
+
+
+def _load_worker():
+ loader = importlib.machinery.SourceFileLoader("transcribe_worker", str(SRC / "transcribe-worker"))
+ spec = importlib.util.spec_from_loader("transcribe_worker", loader)
+ assert spec is not None
+ module = importlib.util.module_from_spec(spec)
+ sys.modules["transcribe_worker"] = module # dataclasses looks the module up by name
+ loader.exec_module(module)
+ return module
+
+
+worker = _load_worker()
+
+FAKE_FFMPEG = """#!/usr/bin/env bash
+# last argument is the output; the one after -i is the input
+while [[ $# -gt 1 ]]; do [[ "$1" == "-i" ]] && in="$2"; shift; done
+cp "$in" "$1"
+exit "${FAKE_FFMPEG_EXIT:-0}"
+"""
+
+FAKE_WHISPER = """#!/usr/bin/env bash
+printf '%s\\n' "$*" >> "$FAKE_LOG"
+while [[ $# -gt 0 ]]; do [[ "$1" == "-of" ]] && prefix="$2"; shift; done
+[[ "${FAKE_WHISPER_EXIT:-0}" == "0" ]] && cp "$FAKE_WHISPER_JSON" "$prefix.json"
+exit "${FAKE_WHISPER_EXIT:-0}"
+"""
+
+FAKE_DIARIZE = """#!/usr/bin/env bash
+printf 'diarize %s\\n' "$*" >> "$FAKE_LOG"
+[[ "${FAKE_DIARIZE_EXIT:-0}" == "0" ]] && cp "$FAKE_TURNS_JSON" "$2"
+echo "fake diarizer says hello" >&2
+exit "${FAKE_DIARIZE_EXIT:-0}"
+"""
+
+
+def whisper_json(*words, step_ms=400):
+ return {"transcription": [
+ {"offsets": {"from": i * step_ms, "to": i * step_ms + 300}, "text": f" {w}"}
+ for i, w in enumerate(words)
+ ]}
+
+
+class Rig:
+ def __init__(self, tmp_path, monkeypatch):
+ self.home = tmp_path / "state"
+ self.bin = tmp_path / "bin"
+ self.bin.mkdir()
+ for name, body in (("ffmpeg", FAKE_FFMPEG), ("whisper-cli", FAKE_WHISPER), ("fake-diarize", FAKE_DIARIZE)):
+ path = self.bin / name
+ path.write_text(body)
+ path.chmod(path.stat().st_mode | stat.S_IXUSR)
+ self.log = tmp_path / "calls.log"
+ self.whisper_json = tmp_path / "whisper.json"
+ self.turns_json = tmp_path / "turns.json"
+ self.set_whisper(whisper_json("Good", "morning."))
+ self.turns_json.write_text(json.dumps([{"start": 0.0, "end": 60.0, "speaker": "SPEAKER_00"}]))
+ monkeypatch.setenv("PATH", f"{self.bin}:{os.environ['PATH']}")
+ monkeypatch.setenv("FAKE_LOG", str(self.log))
+ monkeypatch.setenv("FAKE_WHISPER_JSON", str(self.whisper_json))
+ monkeypatch.setenv("FAKE_TURNS_JSON", str(self.turns_json))
+ self.monkeypatch = monkeypatch
+ self.config = worker.Config(
+ home=self.home,
+ whisper_model=tmp_path / "model.bin",
+ diarize_cmd=[str(self.bin / "fake-diarize")],
+ threads=2,
+ )
+
+ def set_whisper(self, data):
+ self.whisper_json.write_text(json.dumps(data))
+
+ def submit(self, job_id, job=None, audio=b"audio", where="incoming"):
+ folder = self.home / where / job_id
+ folder.mkdir(parents=True)
+ (folder / "audio.m4a").write_bytes(audio)
+ if job is not False:
+ (folder / "job.json").write_text(json.dumps(job if job is not None else {"language": "en"}))
+ return folder
+
+ def calls(self):
+ return self.log.read_text().splitlines() if self.log.exists() else []
+
+
+@pytest.fixture
+def rig(tmp_path, monkeypatch):
+ return Rig(tmp_path, monkeypatch)
+
+
+class TestDrainNormal:
+ def test_transcribe_worker_drain_one_job_writes_transcript_to_done(self, rig):
+ """Normal: a job in incoming/ ends as done/<id>.txt and leaves nothing behind."""
+ rig.submit("job1")
+ assert worker.drain(rig.config) == {"done": 1, "failed": 0}
+ assert (rig.home / "done" / "job1.txt").read_text() == "00:00:00 Speaker A: Good morning.\n"
+ assert not (rig.home / "incoming" / "job1").exists()
+ assert not (rig.home / "work" / "job1").exists()
+ assert not (rig.home / "failed" / "job1.log").exists()
+
+ def test_transcribe_worker_drain_writes_metadata_beside_the_transcript(self, rig):
+ """Normal: done/<id>.json records what ran, for the client and for debugging."""
+ rig.submit("job1", {"language": "en", "speakers": 3, "original_name": "standup.mkv"})
+ worker.drain(rig.config)
+ meta = json.loads((rig.home / "done" / "job1.json").read_text())
+ assert meta["original_name"] == "standup.mkv"
+ assert meta["speakers_found"] == 1
+ assert meta["lines"] == 1
+ assert meta["seconds"] >= 0
+
+ def test_transcribe_worker_drain_passes_language_and_speaker_count_through(self, rig):
+ """Normal: job options reach whisper and the diarizer."""
+ rig.submit("job1", {"language": "es", "speakers": 3})
+ worker.drain(rig.config)
+ whisper_call = next(c for c in rig.calls() if not c.startswith("diarize"))
+ diarize_call = next(c for c in rig.calls() if c.startswith("diarize"))
+ assert "-l es" in whisper_call
+ assert "-mc 0" in whisper_call and "-ojf" in whisper_call
+ assert diarize_call.endswith("--speakers 3")
+
+ def test_transcribe_worker_drain_min_and_max_speakers_are_forwarded(self, rig):
+ """Normal: a range instead of an exact count."""
+ rig.submit("job1", {"min_speakers": 2, "max_speakers": 5})
+ worker.drain(rig.config)
+ diarize_call = next(c for c in rig.calls() if c.startswith("diarize"))
+ assert "--min-speakers 2" in diarize_call and "--max-speakers 5" in diarize_call
+
+ def test_transcribe_worker_drain_processes_every_job_oldest_first(self, rig):
+ """Normal: the queue drains completely, in arrival order."""
+ first = rig.submit("b-first")
+ rig.submit("a-second")
+ os.utime(first, (1, 1))
+ assert worker.drain(rig.config) == {"done": 2, "failed": 0}
+ diarize_calls = [c for c in rig.calls() if c.startswith("diarize")]
+ assert "b-first" in diarize_calls[0] and "a-second" in diarize_calls[1]
+
+
+class TestDrainBoundary:
+ def test_transcribe_worker_drain_empty_queue_is_a_no_op(self, rig):
+ """Boundary: nothing to do, and the folders get created."""
+ assert worker.drain(rig.config) == {"done": 0, "failed": 0}
+ assert (rig.home / "incoming").is_dir() and (rig.home / "done").is_dir()
+
+ def test_transcribe_worker_drain_ignores_uploads_still_in_flight(self, rig):
+ """Boundary: a dot-prefixed folder is left alone (second line of defence behind uploading/)."""
+ rig.submit(".tmp-job9")
+ assert worker.drain(rig.config) == {"done": 0, "failed": 0}
+ assert (rig.home / "incoming" / ".tmp-job9").exists()
+
+ def test_transcribe_worker_drain_job_already_done_is_not_rerun(self, rig):
+ """Boundary: resubmitting finished work costs nothing."""
+ (rig.home / "done").mkdir(parents=True)
+ (rig.home / "done" / "job1.txt").write_text("already here\n")
+ rig.submit("job1")
+ assert worker.drain(rig.config) == {"done": 0, "failed": 0}
+ assert (rig.home / "done" / "job1.txt").read_text() == "already here\n"
+ assert not (rig.home / "incoming" / "job1").exists()
+ assert rig.calls() == []
+
+ def test_transcribe_worker_drain_missing_job_file_uses_defaults(self, rig):
+ """Boundary: audio with no job.json still transcribes, in English, count estimated."""
+ rig.submit("job1", job=False)
+ assert worker.drain(rig.config) == {"done": 1, "failed": 0}
+
+ def test_transcribe_worker_drain_retries_a_job_left_in_work_by_a_crash(self, rig):
+ """Boundary: a job stranded in work/ is picked up again."""
+ rig.submit("job1", where="work")
+ assert worker.drain(rig.config) == {"done": 1, "failed": 0}
+
+ def test_transcribe_worker_drain_gives_up_on_a_job_that_keeps_crashing(self, rig):
+ """Boundary: the second stranding is a failure, not a loop."""
+ rig.submit("job1", {"language": "en", "attempts": 2}, where="work")
+ assert worker.drain(rig.config) == {"done": 0, "failed": 1}
+ assert "attempt" in (rig.home / "failed" / "job1.log").read_text()
+
+
+class TestDrainError:
+ def test_transcribe_worker_drain_whisper_failure_is_logged_with_its_stage(self, rig):
+ """Error: whisper exits non-zero."""
+ rig.monkeypatch.setenv("FAKE_WHISPER_EXIT", "3")
+ rig.submit("job1")
+ assert worker.drain(rig.config) == {"done": 0, "failed": 1}
+ log = (rig.home / "failed" / "job1.log").read_text()
+ assert "whisper" in log
+ assert not (rig.home / "done" / "job1.txt").exists()
+ assert not (rig.home / "work" / "job1").exists()
+
+ def test_transcribe_worker_drain_diarizer_failure_keeps_its_stderr(self, rig):
+ """Error: the diarizer fails; its own words land in the log."""
+ rig.monkeypatch.setenv("FAKE_DIARIZE_EXIT", "1")
+ rig.submit("job1")
+ assert worker.drain(rig.config) == {"done": 0, "failed": 1}
+ log = (rig.home / "failed" / "job1.log").read_text()
+ assert "diarize" in log and "fake diarizer says hello" in log
+
+ def test_transcribe_worker_drain_looping_transcription_fails(self, rig):
+ """Error: whisper's repetition loop is a failed job, never a transcript."""
+ rig.set_whisper(whisper_json(*("we do not know where we are going".split() * 5), step_ms=1000)) # 40 s
+ rig.submit("job1")
+ assert worker.drain(rig.config) == {"done": 0, "failed": 1}
+ assert "looped" in (rig.home / "failed" / "job1.log").read_text()
+ assert not (rig.home / "done" / "job1.txt").exists()
+
+ def test_transcribe_worker_drain_short_loop_is_collapsed_and_recorded(self, rig):
+ """Normal: a stutter of a few seconds is collapsed to one copy and noted in the metadata."""
+ rig.set_whisper(whisper_json("Okay,", *("fair, it's not going to be".split() * 4), "easy."))
+ rig.submit("job1")
+ assert worker.drain(rig.config) == {"done": 1, "failed": 0}
+ assert (rig.home / "done" / "job1.txt").read_text() == "00:00:00 Speaker A: Okay, fair, it's not going to be easy.\n"
+ meta = json.loads((rig.home / "done" / "job1.json").read_text())
+ assert meta["loops_collapsed"] == [["fair, it's not going to be", 4]]
+
+ def test_transcribe_worker_drain_malformed_job_file_fails_that_job_only(self, rig):
+ """Error: one bad job does not stop the queue."""
+ bad = rig.submit("bad")
+ (bad / "job.json").write_text("{not json")
+ os.utime(bad, (1, 1))
+ rig.submit("good")
+ assert worker.drain(rig.config) == {"done": 1, "failed": 1}
+ assert (rig.home / "done" / "good.txt").exists()
+ assert "job.json" in (rig.home / "failed" / "bad.log").read_text()
+
+ def test_transcribe_worker_drain_job_without_audio_fails(self, rig):
+ """Error: a job folder holding no audio file."""
+ folder = rig.submit("job1")
+ (folder / "audio.m4a").unlink()
+ assert worker.drain(rig.config) == {"done": 0, "failed": 1}
+ assert "audio" in (rig.home / "failed" / "job1.log").read_text()
+
+ @pytest.mark.parametrize("bad", [{"speakers": 0}, {"speakers": "three"}, {"language": "en; rm -rf"}])
+ def test_transcribe_worker_drain_rejects_bad_option_values(self, rig, bad):
+ """Error: options are validated before they reach a command line."""
+ rig.submit("job1", bad)
+ assert worker.drain(rig.config) == {"done": 0, "failed": 1}
+ assert rig.calls() == []
+
+
+class TestDrainSilenceHallucinations:
+ def _silent_stretch(self, rig):
+ spoken = [{"offsets": {"from": 0, "to": 900}, "text": " Good morning."}]
+ invented = [
+ {"offsets": {"from": 60_000 + i * 5_000, "to": 64_000 + i * 5_000}, "text": " Thank you."}
+ for i in range(12)
+ ]
+ rig.set_whisper({"transcription": spoken + invented})
+ rig.turns_json.write_text(json.dumps([{"start": 0.0, "end": 2.0, "speaker": "SPEAKER_00"}]))
+
+ def test_transcribe_worker_drain_words_invented_in_silence_are_dropped_not_failed(self, rig):
+ """Normal: a quiet meeting transcribes; the invented run never reaches the transcript."""
+ self._silent_stretch(rig)
+ rig.submit("job1")
+ assert worker.drain(rig.config) == {"done": 1, "failed": 0}
+ assert (rig.home / "done" / "job1.txt").read_text() == "00:00:00 Speaker A: Good morning.\n"
+
+ def test_transcribe_worker_drain_metadata_counts_the_dropped_words(self, rig):
+ """Normal: the count is on record, so a transcript that lost a lot is visible."""
+ self._silent_stretch(rig)
+ rig.submit("job1")
+ worker.drain(rig.config)
+ assert json.loads((rig.home / "done" / "job1.json").read_text())["dropped_outside_speech"] == 12
+
+ def test_transcribe_worker_drain_recording_with_no_speech_at_all_fails_cleanly(self, rig):
+ """Error: everything whisper produced sits in silence."""
+ rig.set_whisper({"transcription": [{"offsets": {"from": 60_000, "to": 61_000}, "text": " Thank you."}]})
+ rig.turns_json.write_text(json.dumps([{"start": 0.0, "end": 2.0, "speaker": "SPEAKER_00"}]))
+ rig.submit("job1")
+ assert worker.drain(rig.config) == {"done": 0, "failed": 1}
+ assert "no speech" in (rig.home / "failed" / "job1.log").read_text()
+
+
+class TestRunStdin:
+ def test_transcribe_worker_run_gives_tools_no_stdin(self, tmp_path):
+ """Boundary: ffmpeg reads stdin when it can, which would eat a calling loop's input."""
+ out = tmp_path / "stdin-target"
+ read_end, write_end = os.pipe()
+ saved = os.dup(0)
+ os.dup2(read_end, 0)
+ try:
+ worker._run("probe", ["bash", "-c", f"readlink /proc/self/fd/0 > {out}"])
+ finally:
+ os.dup2(saved, 0)
+ for fd in (saved, read_end, write_end):
+ os.close(fd)
+ assert out.read_text().strip() == "/dev/null"
+
+
+class TestDrainResilience:
+ def test_transcribe_worker_drain_null_attempts_counts_as_a_first_try(self, rig):
+ """Error: a job.json with "attempts": null must not take the worker down; it is a first try."""
+ odd = rig.submit("odd", {"language": "en", "attempts": None})
+ os.utime(odd, (1, 1))
+ rig.submit("good")
+ assert worker.drain(rig.config) == {"done": 2, "failed": 0}
+ assert (rig.home / "done" / "odd.txt").exists()
+ assert (rig.home / "done" / "good.txt").exists()
+
+ def test_transcribe_worker_drain_unexpected_error_in_one_job_is_logged_and_the_queue_goes_on(self, rig):
+ """Error: an exception the pipeline never anticipated lands in failed/ with its type, not on the run."""
+ first = rig.submit("first")
+ os.utime(first, (1, 1))
+ rig.submit("second")
+
+ def explode(job: dict) -> list[str]:
+ raise RuntimeError("boom")
+
+ rig.monkeypatch.setattr(worker, "_diarize_options", explode)
+ assert worker.drain(rig.config) == {"done": 0, "failed": 2} # both reached, neither crashed the run
+ for job_id in ("first", "second"):
+ log = (rig.home / "failed" / f"{job_id}.log").read_text()
+ assert "RuntimeError" in log and "boom" in log
+ assert not (rig.home / "work" / job_id).exists()
+
+ def test_transcribe_worker_drain_unwritable_done_dir_is_a_failed_job_not_a_crash(self, rig):
+ """Error: an OSError while writing the transcript lands in failed/, and the run survives."""
+ if os.geteuid() == 0:
+ pytest.skip("root ignores directory permissions")
+ (rig.home / "done").mkdir(parents=True)
+ (rig.home / "done").chmod(0o500)
+ try:
+ rig.submit("job1")
+ assert worker.drain(rig.config) == {"done": 0, "failed": 1}
+ assert "job1" in "".join(p.name for p in (rig.home / "failed").iterdir())
+ finally:
+ (rig.home / "done").chmod(0o700)
+
+ def test_transcribe_worker_drain_ffmpeg_failure_is_logged_with_its_stage(self, rig):
+ """Error: ffmpeg exits non-zero."""
+ rig.monkeypatch.setenv("FAKE_FFMPEG_EXIT", "2")
+ rig.submit("job1")
+ assert worker.drain(rig.config) == {"done": 0, "failed": 1}
+ assert "ffmpeg" in (rig.home / "failed" / "job1.log").read_text()
+
+ def test_transcribe_worker_drain_returns_at_once_when_another_worker_holds_the_lock(self, rig):
+ """Boundary: a second drain does not touch the queue while the first holds the lock."""
+ import fcntl
+ rig.home.mkdir(parents=True, exist_ok=True)
+ rig.submit("job1")
+ with open(rig.home / "lock", "w", encoding="utf-8") as held:
+ fcntl.flock(held, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ assert worker.drain(rig.config) == {"done": 0, "failed": 0}
+ assert (rig.home / "incoming" / "job1").exists()
+ assert rig.calls() == []
diff --git a/working/velox-touchpad-interrupt/touchpad-module-underside-2026-08-15.jpg b/working/velox-touchpad-interrupt/touchpad-module-underside-2026-08-15.jpg
new file mode 100644
index 0000000..40c71af
--- /dev/null
+++ b/working/velox-touchpad-interrupt/touchpad-module-underside-2026-08-15.jpg
Binary files differ