aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xarchsetup89
-rw-r--r--docs/design/2026-07-10-net-bt-failure-taxonomy.org4
-rw-r--r--docs/post-install-checklist.org26
-rwxr-xr-xscripts/post-rebuild-check667
-rw-r--r--tests/installer-steps/test_clone_user_repos.py155
-rw-r--r--tests/installer-steps/test_configure_tlp_power.py87
-rw-r--r--tests/net-scenarios/test_run_net_scenarios.py6
-rw-r--r--tests/post-rebuild-check/test_post_rebuild_check.py1126
-rw-r--r--todo.org1275
9 files changed, 3177 insertions, 258 deletions
diff --git a/archsetup b/archsetup
index 9b42f32..32a3ad4 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
@@ -1428,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" "$?"
@@ -1442,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.
@@ -1773,8 +1809,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
@@ -1791,7 +1831,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
@@ -2472,6 +2522,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
@@ -3550,6 +3603,32 @@ 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" "$?"
+ # 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
}
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/post-install-checklist.org b/docs/post-install-checklist.org
index 97fc0d5..f0545a7 100644
--- a/docs/post-install-checklist.org
+++ b/docs/post-install-checklist.org
@@ -18,6 +18,32 @@ 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
diff --git a/scripts/post-rebuild-check b/scripts/post-rebuild-check
new file mode 100755
index 0000000..ee77a19
--- /dev/null
+++ b/scripts/post-rebuild-check
@@ -0,0 +1,667 @@
+#!/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.
+#
+# 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
+.claude \.claude
+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/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_tlp_power.py b/tests/installer-steps/test_configure_tlp_power.py
index c88e0c2..1ddff72 100644
--- a/tests/installer-steps/test_configure_tlp_power.py
+++ b/tests/installer-steps/test_configure_tlp_power.py
@@ -1,4 +1,4 @@
-"""Test configure_tlp_power's radio-enable line and laptop gating.
+"""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
@@ -6,6 +6,20 @@ 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.
+
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.
@@ -25,7 +39,7 @@ 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):
+def run(battery=True, bat_name="BAT0", unwritable_tlpd=False, systemctl_fails=False):
with tempfile.TemporaryDirectory() as d:
psdir = os.path.join(d, "power_supply")
os.makedirs(psdir)
@@ -37,13 +51,14 @@ def run(battery=True, bat_name="BAT0", unwritable_tlpd=False):
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
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"; }}
+ systemctl() {{ echo "SYSTEMCTL: $*" >> "{d}/systemctl.log"; return {sysrc}; }}
error_warn() {{ echo "WARN: $1"; return 1; }}
source <(sed -n '/^configure_tlp_power() {{/,/^}}/p' "{ARCHSETUP}")
configure_tlp_power "{tlpd}" "{psdir}"
@@ -74,6 +89,44 @@ class ConfigureTlpPower(unittest.TestCase):
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]
@@ -97,7 +150,35 @@ class ConfigureTlpPower(unittest.TestCase):
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")
+
@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)
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..a034f87
--- /dev/null
+++ b/tests/post-rebuild-check/test_post_rebuild_check.py
@@ -0,0 +1,1126 @@
+"""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/", ".claude/", "todo.org"])
+ r = run_check(project_roots=root)
+ self.assertEqual(r.returncode, 1)
+ for missing in (".ai", ".claude", "todo.org"):
+ self.assertIn(missing, 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: +15045173983 ...")
+ 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 6a17f30..28872f8 100644
--- a/todo.org
+++ b/todo.org
@@ -75,6 +75,260 @@ 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.
+** 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 [#B] Function keys issue media actions instead of F-keys :bug:velox:
+: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].
+
+** 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 [#A] Reseat velox input-cover ribbon — phantom power button :bug:velox:hardware:
+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.
+
** DOING [#A] Velox reinstall — DR test of archangel + archsetup :velox:chore:
DEADLINE: <2026-08-15 Sat>
:PROPERTIES:
@@ -116,6 +370,274 @@ 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 [#A] powerprofilesctl crashes on a loop since ppd was masked :bug:velox:dotfiles:
+: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.
+** 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 [#A] The installer clones my two working repos shallow and read-only :bug:velox:
+: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.
+** 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.
** TODO [#C] screen-lock test suite red on ratio :bug:test:dotfiles:
:PROPERTIES:
:CREATED: [2026-08-13 Thu]
@@ -263,7 +785,7 @@ users sometimes = P3 = [#C].
** TODO [#B] Land the rescued emacs-wttrin commit :chore:velox:
:PROPERTIES:
:CREATED: [2026-08-14 Fri]
-:LAST_REVIEWED: 2026-08-14
+:LAST_REVIEWED: 2026-08-17
:END:
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
@@ -273,6 +795,17 @@ git bundle before the disk was wiped:
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/velox-reinstall/wttrin-bf0457f.bundle= 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, and it also
+pins the working directory open — the reinstall task cannot file its artifacts
+away while this bundle is still load-bearing.
** TODO [#B] archsetup doesn't clone rulesets :bug:velox:
DEADLINE: <2026-08-15 Sat>
:PROPERTIES:
@@ -334,6 +867,60 @@ archangel+archsetup ISO that's already ~80% built. Two ISO modes: generic
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
+: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.
+
+Confirmed, and it generalizes past caffeine. The settings module splits cleanly into two halves, and only one of them persists.
+
+Persisted, in =~/.config/desktop-settings/state.json= (=store.py= =DEFAULTS=): program slots, idle-tripper stages, wallpaper. These come back correctly.
+
+Not persisted — every one is derived live from a process or a compositor runtime option, so a session restart resets it to whatever =hyprland.conf= establishes:
+- Caffeine — =caffeine_state()= is =pgrep -x hypridle= inverted, and =hyprland.conf:73= runs =exec-once = pkill -x hypridle; hypridle=. So every launch unconditionally starts hypridle, which means caffeine is *always* OFF after login. There is no code path that could restore it ON.
+- Auto-dim — =dim_state()= reads =hyprctl getoption decoration:dim_inactive=, a compositor runtime value that resets to the config default on restart.
+- Night light — =state()= is =pgrep -x gammastep=; the process dies with the session.
+- DND — =dunstctl=; dunst restarts fresh from =exec-once=.
+- Power profile / brightness — owned by powerprofilesctl and systemd-backlight, outside this module's scope.
+
+Verified live 10 minutes after the reboot: hypridle running (caffeine OFF), dim =false=, gammastep not running, dnd =false=, power =balanced=. Every toggle sat at its factory position.
+
+The failure is silent, which is what makes it bite: nothing tells you the value you set was discarded. That is the mechanism behind the 2026-07-27 lockout, where Craig believed caffeine was on and the screen locked anyway.
+
+Grading: Major severity (the panel's core promise is holding these values, and the reset is silent and total across all four toggles) x most users frequently (every session start resets them, though it only harms when a deliberate non-default was set) = P2 = [#B].
+
+Not :solo: — the fix needs Craig's call on *which* toggles should persist and whether persistence is per-toggle opt-in. Restoring night light at 3pm or caffeine on a laptop are both plausibly wrong, so this is a preference question, not a derivable one. The mechanism itself (extend =store.py= with a =toggles= block, restore on session start) is mechanical once that's settled.
+
+Related: =[#B] Caffeine state is unreadable on both surfaces= covers display accuracy — whether the surfaces report the truth. This covers whether the value survives at all. Distinct bugs, same subsystem.
+
+*** Side finding — gammastep loses a startup race and nothing relaunches it
+=hyprland.conf:75= runs =exec-once = gammastep=, but no gammastep process is alive. Today's three launch logs tell the story: =gammastep-2026-07-28-090034.log= carries "Wayland connection experienced a fatal error: -1 / Temperature adjustment failed", and the other two are empty.
+
+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.
** VERIFY [#A] Pre-vacation fix list — morning review
SCHEDULED: <2026-08-08 Sat>
:PROPERTIES:
@@ -373,72 +960,6 @@ items needing your call say so.
if you disagree). Found tonight, low priority: the orchestrator sequence
pin can't see an added-but-unstubbed call (it caught drops only) — worth a
harness hardening pass someday.
-** TODO [#B] Encrypted mail-password files are world-writable :bug:security:dotfiles:quick:
-:PROPERTIES:
-:CREATED: [2026-08-15 Sat]
-:LAST_REVIEWED: 2026-08-15
-:END:
-=~/.config/.gmailpass.gpg= and =~/.config/.dmailpass.gpg= resolve to mode 777
-in the dotfiles repo, on both daily drivers. Noticed by a .emacs.d session
-2026-08-14 while comparing the machines after the velox rebuild (inbox handoff,
-PROCESSED).
-
-The contents are gpg-encrypted, so this is not an exposure of the passwords
-themselves — it is that any local process can *overwrite* a credential file
-without complaint. Fix the mode in the dotfiles repo so a fresh stow lands it
-correctly, not just =chmod= on the two live machines, or the next rebuild
-reintroduces it.
-
-Grading: Minor severity (the contents stay encrypted, so the harm once the
-state is entered is tampering rather than disclosure, and it needs local access
-already) x every user, every time (the wrong mode ships from the repo, so every
-machine has it after every install) = P2 = [#B]. Grading the frequency row on
-"how often does something actually overwrite it" would double-count the rarity
-the severity band already carries.
-
-While in there, archsetup owns dotfiles work end to end (notes.org, Craig
-2026-07-04): make the edit, test, commit and push dotfiles from here, then drop
-a note in =~/.dotfiles/inbox/=.
-
-** TODO [#B] Desktop settings don't survive a session restart :bug:dotfiles:hyprland:
-:PROPERTIES:
-:CREATED: [2026-07-28 Tue]
-:LAST_REVIEWED: 2026-07-28
-: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.
-
-Confirmed, and it generalizes past caffeine. The settings module splits cleanly into two halves, and only one of them persists.
-
-Persisted, in =~/.config/desktop-settings/state.json= (=store.py= =DEFAULTS=): program slots, idle-tripper stages, wallpaper. These come back correctly.
-
-Not persisted — every one is derived live from a process or a compositor runtime option, so a session restart resets it to whatever =hyprland.conf= establishes:
-- Caffeine — =caffeine_state()= is =pgrep -x hypridle= inverted, and =hyprland.conf:73= runs =exec-once = pkill -x hypridle; hypridle=. So every launch unconditionally starts hypridle, which means caffeine is *always* OFF after login. There is no code path that could restore it ON.
-- Auto-dim — =dim_state()= reads =hyprctl getoption decoration:dim_inactive=, a compositor runtime value that resets to the config default on restart.
-- Night light — =state()= is =pgrep -x gammastep=; the process dies with the session.
-- DND — =dunstctl=; dunst restarts fresh from =exec-once=.
-- Power profile / brightness — owned by powerprofilesctl and systemd-backlight, outside this module's scope.
-
-Verified live 10 minutes after the reboot: hypridle running (caffeine OFF), dim =false=, gammastep not running, dnd =false=, power =balanced=. Every toggle sat at its factory position.
-
-The failure is silent, which is what makes it bite: nothing tells you the value you set was discarded. That is the mechanism behind the 2026-07-27 lockout, where Craig believed caffeine was on and the screen locked anyway.
-
-Grading: Major severity (the panel's core promise is holding these values, and the reset is silent and total across all four toggles) x most users frequently (every session start resets them, though it only harms when a deliberate non-default was set) = P2 = [#B].
-
-Not :solo: — the fix needs Craig's call on *which* toggles should persist and whether persistence is per-toggle opt-in. Restoring night light at 3pm or caffeine on a laptop are both plausibly wrong, so this is a preference question, not a derivable one. The mechanism itself (extend =store.py= with a =toggles= block, restore on session start) is mechanical once that's settled.
-
-Related: =[#B] Caffeine state is unreadable on both surfaces= covers display accuracy — whether the surfaces report the truth. This covers whether the value survives at all. Distinct bugs, same subsystem.
-
-Recovered 2026-08-14: the heading was overwritten in =ce28d35= when a new task
-was inserted at the top of Open Work, and the headless body then rode the
-podman task into Resolved when that one was archived. Restored here.
-
-*** Side finding — gammastep loses a startup race and nothing relaunches it
-=hyprland.conf:75= runs =exec-once = gammastep=, but no gammastep process is alive. Today's three launch logs tell the story: =gammastep-2026-07-28-090034.log= carries "Wayland connection experienced a fatal error: -1 / Temperature adjustment failed", and the other two are empty.
-
-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]
@@ -576,23 +1097,6 @@ 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-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.
-
** VERIFY Should coredump entries group as one journal-digest row per binary? :maint:
:PROPERTIES:
:LAST_REVIEWED: 2026-07-24
@@ -632,6 +1136,21 @@ Found by sentry (2026-07-25), verified by exercising. =hyprland/.local/bin/wayba
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] obsbot-wb-guard polls forever on machines with no OBSBOT :bug:dotfiles:quick:solo:
+: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.
** TODO [#C] Auto-dim status forgotten on layout change :bug:dotfiles:
:PROPERTIES:
:LAST_REVIEWED: 2026-07-25
@@ -702,18 +1221,38 @@ 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.
-*** TODO [#A] velox: reconcile its clones after the history rewrite
-velox was offline for repair during the 2026-08-09 purge, so its clones still
-hold the pre-rewrite history and are diverged from the rewritten remotes. On
-its return: force-fetch + rebase local work onto the rewritten main in both
-repos (or re-clone), force-update the local tag, local-gc, before its next
-push. Also on the velox riders on the sleep/suspend task.
+*** 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 =6faa31c=, 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.
** 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 =6faa31c= — 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.
** TODO [#B] Velox boot-failure retrospective — upgrade guard gaps :bug:zfs:maint:
:PROPERTIES:
:LAST_REVIEWED: 2026-07-21
@@ -1165,15 +1704,28 @@ 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
+*** 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.
-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.
+stow step. Handed off 2026-07-04 with the archsetup deps already confirmed
+installed.
*** 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.
@@ -1435,6 +1987,29 @@ 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.
+
** TODO [#B] Manual testing and validation :test:
:PROPERTIES:
:LAST_REVIEWED: 2026-07-09
@@ -1443,6 +2018,33 @@ Craig's standing checklist of everything that isn't agent-verifiable. Each child
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.
+*** 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.
@@ -2036,7 +2638,7 @@ NOTE (2026-07-04 audit): the "four-tab panel" framing predates the instrument-co
** 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
@@ -2093,6 +2695,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.
@@ -2224,9 +2839,11 @@ From the roam inbox (routed 2026-07-13): the networking panel should track speed
** 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=.
@@ -2266,10 +2883,12 @@ Craig's roam capture 2026-07-20, routed via .emacs.d sentry inbox-zero as archse
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.
** 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.
@@ -2371,6 +2990,119 @@ Re-graded =[#C]= → =[#D]= per the bug matrix. There is no defect to fix here;
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 [#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:working/velox-reinstall/velox-uefi-boot-entry-reference.org][working/velox-reinstall/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 [#A] Tracked WireGuard private keys in repo — public leak, resolved :bug:security:network:
CLOSED: [2026-07-20 Mon]
@@ -3657,159 +4389,204 @@ CLOSED: [2026-08-08 Sat]
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 [#A] Reseat velox input-cover ribbon — phantom power button :bug:velox:hardware:
-CLOSED: [2026-08-15 Sat] DEADLINE: <2026-08-14 Fri>
-: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-15 Sat @ 22:30:00 -0500 Reseated the ribbon; the power button is fixed and the touchpad is not
-Done the night before the flight. The power-button half worked: after the
-reseat I flexed the chassis and the 22:09 boot logged *zero* "Power key
-pressed" lines, against nine on the previous boot. That is real rather than the
-shield masking it — =HandlePowerKey=ignore= was already live during those nine,
-so logind logs what it suppresses. Short sample (minutes); worth re-checking
-after a day of uptime.
-
-The touchpad did not change, which separated the two symptoms and disproved the
-one-fault model they were filed under. Split out as its own task below.
-** DONE [#B] Velox touchpad interrupt line is dead — needs a part or a BIOS fix :bug:velox:hardware:
-CLOSED: [2026-08-15 Sat]
+** 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:
-:CREATED: [2026-08-15 Sat]
-:LAST_REVIEWED: 2026-08-15
+:LAST_REVIEWED: 2026-07-24
: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]].
+=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.
-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.
+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).
-*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:working/velox-reinstall/velox-uefi-boot-entry-reference.org][working/velox-reinstall/velox-uefi-boot-entry-reference.org]].
+Not graded on the bug matrix: it is test infrastructure, not the shipped codebase.
-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.
+*** 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.
-*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.
+*** 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.
-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.
+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.
-Workaround in the meantime: Bluetooth mouse, already in use.
+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.