aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org112
-rw-r--r--todo.org132
2 files changed, 178 insertions, 66 deletions
diff --git a/docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org b/docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org
index c2461be..d9ec8d4 100644
--- a/docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org
+++ b/docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org
@@ -8,6 +8,8 @@
:PROPERTIES:
:ID: 81cdfd72-db96-43d3-aa03-779878c99f3e
:END:
+- [2026-08-25 Tue @ 18:45 -0600] decisions closed 7/7. The kernel decision reversed on the velox DKMS failure chain: held on every everyday run, landed only in the dedicated session behind a DKMS/initramfs/snapshot gate.
+- [2026-08-25 Tue @ 18:30 -0600] redirected: the everyday path is a live split upgrade (apply everything the guard would not block, defer the rest); the boot-time oneshot becomes the completion step for the deferred set. Decided while running exactly that by hand on ratio.
- [2026-08-25 Tue @ 06:39:42 -0600] drafted. Grounded in a live read of the maint engine, the pacman hooks, and the boot path on velox, not memory. The topgrade-freshness diagnosis that motivates it is in this session's log.
* Metadata
@@ -45,21 +47,23 @@ Two framings of the fix are in tension, and choosing between them is the spec's
** Non-Goals
- Weakening or bypassing the =hypr-live-update-guard= hook. It stays exactly as strict; this builds *around* it, not through it.
- Making the full topgrade ecosystem sweep (git repos, vim, npm, ...) run at boot. Those never need a stopped compositor and are out of the boot path.
-- Solving the live-kernel-upgrade hazard (running-kernel modules vanish until reboot). Related, not guarded by this hook, and deferred.
+- Changing how the kernel hazard is *guarded*. The hook stays silent on kernels; the split script holds them back on a live run as a second, separately-reasoned list (see Design), which is a deferral policy rather than a guard.
- A general offline-update system for all of pacman. Scope is the guarded-library case.
** Scope tiers
-- v1: stamp the safe-completion path; an armed, bounded, non-blocking boot-time =topgrade --only system= unit installed by archsetup; an arming affordance in maint that fires when guarded libs are pending.
-- Out of scope: full-sweep-at-boot; kernel-reboot coordination; touching the guard's policy.
-- vNext: fold the same arm-and-reboot pattern into a live-kernel-upgrade prompt (log to =todo.org=).
+- v1: the split-upgrade script (live: apply the non-blocked remainder, defer the rest, run the ecosystem sweep with the system step off, report the deferred set); maint's UPDATE/TOPGRADE levers route through it; an "apply on reboot" affordance that installs the held kernel live and arms the boot-time oneshot for the GPU/compositor set.
+- Out of scope: full-sweep-at-boot; touching the guard's policy.
+- vNext: none open — the kernel deferral that was vNext is now part of v1's held set.
* Design
The shape follows one principle: the only part of topgrade that needs a stopped compositor is its =system= step when a guarded library is pending. Everything else runs fine live and rarely fails. So the safe path is small and targeted — apply the guarded system upgrade with Hyprland down, once, and stamp it — while the ordinary full sweep stays a normal live =topgrade= run.
-Three pieces, at two altitudes.
+Three pieces, at two altitudes — but the everyday gesture is not a reboot. It is a normal live update that simply leaves the dangerous few behind.
-*For the user.* When the maintenance panel sees a guarded library in the pending set (the exact condition that fails a live topgrade), it offers a new action: "apply on reboot." Choosing it arms a persistent flag and offers to reboot. On the next boot, before the autologin shell starts Hyprland, the guarded upgrade runs in the console — the guard passes freely because nothing is live — the stamp is written, the flag is cleared, and boot continues into the session. No second reboot: the libraries are already current before anything maps them. If anything goes wrong, the machine still boots into Hyprland and the panel still shows the pending upgrade, so you are never worse off than before arming.
+*The split script.* A pacman =PreTransaction= hook can only abort or allow the transaction it is handed; it cannot drop targets from it. So "upgrade everything except the guarded set" cannot live in the hook — it lives one layer up, in a script the panel calls. On a live run the script: refreshes the sync db and reads the pending set (=checkupdates=); computes the *blocked set* = the guard's own trigger list (read from the installed hook's =Target= lines, so there is one source of truth, and version-aware the way the guard is — a same-version reinstall is not a swap) plus the *kernel set* (every installed kernel with its =-headers=, always as a set; held on every everyday run because a failed DKMS rebuild on velox's ZFS root leaves the machine unbootable — see the kernel decision); clears the news hook (=informant read=) where installed; runs =pacman -Syu --noconfirm --ignore=<blocked set>=; runs the AUR-only remainder (=yay -Sua --noconfirm=, AUR packages pinning a guarded version hold themselves back); then runs =topgrade --disable system,git_repos -y= so the other ecosystems still get their sweep and topgrade can actually exit 0. It writes the deferred set to a state file the panel reads, and exits 0 when the live part succeeded, whatever was deferred. The guard hook stays installed as the backstop for a bare =pacman -Syu= typed at a shell; on the driven path it never fires. Proof of concept: this exact sequence, run by hand on ratio on 2026-08-25 while Hyprland was live, resolved 724 of 730 pending packages (Emacs 31.1 among them) with the six guard hits deferred — after one unrelated fix, an orphaned =qemu-block-gluster= that had been dropped from the repo.
+
+*For the user.* UPDATE and TOPGRADE on the panel run the split script; they succeed, and the panel shows "N deferred" when the script held anything back. Landing the deferred set is a dedicated session, chosen on purpose, run in the foreground from the panel's action or =guarded-upgrade --complete= in a terminal: first the kernel set, live, with the desktop still up; then the gate — every DKMS module built for the new kernel, a fresh initramfs, and on a ZFS root a pre-pacman snapshot to fall back on. If the gate fails the script stops there, names what failed, and does not reboot; the machine keeps running on the old kernel and the desktop is available for the fix. If it passes, the script arms a persistent flag for the GPU/compositor set and offers to reboot (or, from a TTY with no compositor, applies that set directly). On the next boot, before the autologin shell starts Hyprland, the deferred guarded upgrade runs in the console — the guard passes freely because nothing is live — the stamp is written, the flag is cleared, and boot continues into the session. No second reboot: the libraries are already current before anything maps them. If anything goes wrong, the machine still boots into Hyprland and the panel still shows the pending upgrade, so you are never worse off than before arming.
*For the implementer.* A persistent arm flag (a file on a non-tmpfs path, e.g. =/var/lib/archsetup/apply-upgrade-on-boot=, so it survives the reboot the =/run= guard sentinel cannot). A system oneshot, =archsetup-boot-upgrade.service=, =ConditionPathExists= on the flag, ordered =Before=getty@tty1.service= so it completes before autologin execs Hyprland — this ordering is mandatory, because a parallel run would let Hyprland start mid-swap and reintroduce the exact crash the guard prevents. The unit is bounded (=TimeoutStartSec=) and best-effort: its failure or timeout must not fail any target the session needs, so boot proceeds past it regardless. Its =ExecStart= runs, as the user: =informant read= (clear the news hook that would otherwise abort the transaction), then =topgrade --only system= (or the equivalent =yay -Syu=), then =maint stamp topgrade= on success, then removes the flag unconditionally (a one-shot arm — a failed attempt disarms rather than retrying every boot). =sudo= works unattended (=%cjennings NOPASSWD: ALL=), so no password prompt wedges it.
@@ -87,47 +91,81 @@ The stamp also needs to happen when the upgrade is completed by other safe means
- Bad, because it puts a unit on the boot critical path, which must be bounded and non-fatal with care, and it is the most to build.
- Neutral, because it composes with C: the same =maint apply-upgrade= path serves both an interactive TTY run and the boot unit.
-* Decisions [/]
+** E. Split the live run: apply the non-blocked remainder now, defer the rest (this spec's everyday path)
+- Good, because it is what a careful operator does by hand anyway — and did, on ratio, the day this was decided. The live run succeeds on the common day, topgrade exits 0, the AUR and every other ecosystem stay current, and the guard's abort becomes the rare path rather than the default.
+- Bad, because Arch calls any =--ignore= run a partial upgrade. In practice pacman still enforces declared dependencies, so anything needing the newer mesa fails resolution instead of installing broken; the residual exposure is a package with an *unversioned* dependency built against a new ABI, which for mesa/wayland/libdrm is rare. Named, accepted.
+- Bad, because a deferred set nobody surfaces is a set that silently never lands — the same trap as the freshness stamp, one layer down. So the script must record the deferred set durably and the panel must show it; this is why D stays in the design as the completion step rather than being replaced.
+- Neutral, because it does not change the guard at all; it changes who decides the transaction's contents.
+
+* Decisions [7/7]
-** TODO Metric means state, not recency
-- Owner / by-when: Craig / at spec-review
+** DONE Metric means state, not recency
+CLOSED: [2026-08-25 Tue 18:45]
+- Owner / by-when: Craig / 2026-08-25
- Context: the stamp gate can mean "ran the sweep" or "system is current." The whole fix differs by which.
- Decision: We will keep the state meaning. Freshness stays stale while a guarded upgrade is genuinely un-applied, and the fix is to make *safe completion* stamp — not to loosen the gate.
- Consequences: easier — the metric stays trustworthy as an is-current signal, and Alternative A is off the table. Harder — completion now needs a real safe path (the rest of this spec) rather than a one-line wrapper change.
-** TODO Primary mechanism is the armed boot-time oneshot (Alternative D)
-- Owner / by-when: Craig / at spec-review
-- Context: D vs the manual-only C vs the risky live-and-reboot B.
-- Decision: We will build D, and structure it so the same completion path also serves an interactive TTY run (C as the manual fallback). B is rejected on the live-swap risk.
-- Consequences: easier — one gesture (arm + reboot) applies guarded upgrades safely and keeps the metric honest. Harder — a boot-critical-path unit needs the bounded, non-fatal, ordered-before-autologin wiring exactly right, or it risks boot delay.
-
-** TODO Boot run is arch-only (=topgrade --only system=), not the full sweep
-- Owner / by-when: Craig / at spec-review
-- Context: the only step needing a stopped compositor is the guarded system upgrade; the rest run fine live and are what usually fail.
-- Decision: We will run only the system step at boot. The full topgrade sweep stays a normal live run.
-- Consequences: easier — the boot path is fast (one transaction), has a tiny interactive-prompt surface, and rarely fails. Harder — freshness then reflects the system step specifically; if we want the sweep's other steps to also gate freshness, that is a separate signal.
-
-** TODO Arm flag lives on a persistent path and is one-shot
-- Owner / by-when: Craig / at spec-review
+** DONE Everyday mechanism is the split live run (Alternative E); the boot oneshot (D) completes the deferred set
+CLOSED: [2026-08-25 Tue 18:30]
+- Owner / by-when: Craig / 2026-08-25
+- Context: the first draft made D the primary gesture, which means every guarded-library day is a reboot day. Craig's read while watching the ratio run: when the guard would trip, the rational move is to upgrade everything *except* the guarded and kernel items, then run the rest of topgrade without the yay piece — and that logic should be a script we can keep editing, not something baked into the panel.
+- Decision: I will build E as the path UPDATE and TOPGRADE always take on a live session, and keep D as the way the deferred set lands (arm + reboot). C remains the manual fallback through the same script from a TTY (no compositor → nothing blocked → a full run). B stays rejected on the live-swap risk.
+- Consequences: easier — the common day is one live run that succeeds; reboots are reserved for the days the deferred set is non-empty, and even then the machine keeps working until the reboot is convenient. Harder — two lists to maintain (the guard's, read from the hook; the kernel list, owned by the script), a state file the panel must render, and the partial-upgrade caveat above to keep an eye on.
+
+** DONE The script lives in archsetup beside the guard, and maint calls it
+CLOSED: [2026-08-25 Tue 18:45]
+- Owner / by-when: Craig / 2026-08-25
+- Context: the panel (dotfiles =maint=) and the guard (archsetup =scripts/hypr-live-update-guard=, installed to =/usr/local/bin=) live in different repos, and maint already carries its own copy of the trigger list as =[updates] guard_patterns= in the thresholds TOML.
+- Decision: ship the split script in archsetup next to the guard, installed by the same installer step, reading the blocked list from the installed hook so the guard and the script can never disagree. maint's UPDATE/TOPGRADE levers change their =argv= to the script; the TOML patterns stay as the panel's *display-side* mirror (the badge that says a run will defer) and gain a test asserting they match the hook.
+- Consequences: easier — one owner for "which libraries are dangerous," and a rebuilt machine gets the script with the guard. Harder — a cross-repo change (archsetup ships it, dotfiles wires it), so the rollout is two commits, archsetup first.
+
+** DONE What a split run stamps
+CLOSED: [2026-08-25 Tue 18:45]
+- Owner / by-when: Craig / 2026-08-25
+- Context: under the state framing, a run that deferred six packages left the system *not* current, yet the sweep ran and every other ecosystem is fresh.
+- Decision: the script stamps =topgrade_run= only when the deferred set is empty. When it is non-empty it writes the deferred set to its own cache key, and the panel renders that as its own state ("6 deferred — apply on reboot") rather than as stale freshness. The boot oneshot stamps when it completes the deferred set. Freshness keeps meaning "current"; the deferred badge carries the other half.
+- Consequences: easier — no signal is thrown away, and the reboot nag has a precise count behind it. Harder — one more cache key and one more probe in maint.
+
+** DONE Kernel set is held on every everyday run and lands only in the dedicated session, gated on the DKMS result
+CLOSED: [2026-08-25 Tue 18:45]
+- Owner / by-when: Craig / 2026-08-25
+- Context: I first wrote this as "install the kernel live at apply-on-reboot," on the reasoning that a kernel swap crashes nothing and the modules-vanish window ends with the reboot. Craig asked what happens on velox when the DKMS rebuild fails, and the answer changed the decision. Velox is an encrypted ZFS root with =/boot= inside the root dataset, one kernel (=linux-lts=), and =zfs-dkms=. On a kernel upgrade the DKMS build runs PostTransaction, after the kernel is swapped and the old modules are deleted, so nothing can abort; a failed build leaves a new kernel beside an initramfs built for the old one, whose =zfs.ko= won't load, and the next boot can't import the pool. It is survivable — ZFSBootMenu can boot the pre-pacman snapshot, which holds the old kernel, initramfs, and modules — but it is a recovery session, not an update. Ratio (btrfs root, two kernels, zfs only for a data pool) is exposed only at the pool. The realistic triggers are a kernel major outrunning OpenZFS's supported range, a kernel upgraded without its headers, a toolchain regression, or a full disk.
+- Decision: the script holds the kernel set — every installed kernel with its =-headers=, moved as a set, never one without the other — on every everyday run, on both machines, so there is one rule rather than a per-host exception. The kernel set lands only in the dedicated session, live, while a working desktop exists for diagnosing, and the script gates what follows on the result: =dkms status= reports every DKMS module installed for the new kernel version, the initramfs is newer than the kernel image, and on a ZFS root a pre-pacman snapshot exists. A failed gate stops with the failure named and never reboots. The GPU/compositor set follows only after the gate passes — armed for the boot oneshot, or applied from a TTY. Kernels stay off the guard's list (the hook would block a TTY kernel upgrade for no reason). "Install the kernel live at apply-on-reboot" is withdrawn.
+- Consequences: easier — an everyday UPDATE can never put velox into the unbootable state, and the day the kernel moves is one Craig chose, sitting at the machine, expecting to handle issues. Harder — the kernel deferral is now standing, so the dedicated session has to happen on a cadence (security fixes ride the kernel), and the panel's deferred count carries a kernel most days; the gate is one more script to test, with fakes for =dkms status= and the image timestamps.
+
+** DONE Boot run applies exactly the deferred GPU/compositor set, nothing else
+CLOSED: [2026-08-25 Tue 18:45]
+- Owner / by-when: Craig / 2026-08-25
+- Context: the only packages that need a stopped compositor are the guard's trigger set; the rest run fine live and are what usually fail. The first draft phrased this as =topgrade --only system=; with the split script that wording is stale, and with the kernel decision above the kernel is not part of what boot applies either.
+- Decision: the boot oneshot runs the script's =--complete= form scoped to the deferred GPU/compositor set: one pacman transaction, no ecosystem sweep, no kernel. The full topgrade sweep stays a normal live run through the everyday path.
+- Consequences: easier — the boot path is fast, has a tiny interactive-prompt surface, and rarely fails. Harder — freshness after a boot run reflects the guarded set specifically, which is what the stamp decision above already accounts for.
+
+** DONE Arm flag lives on a persistent path and is one-shot
+CLOSED: [2026-08-25 Tue 18:45]
+- Owner / by-when: Craig / 2026-08-25
- Context: the guard's =/run= sentinel is tmpfs and cleared on reboot, so it cannot carry an intent across the reboot. A boot that retries forever on failure is its own outage.
- Decision: We will use a persistent flag (=/var/lib/archsetup/=) that the boot unit removes unconditionally at the end of its attempt — success or failure disarms.
- Consequences: easier — the intent survives exactly one reboot and a failed attempt never wedges subsequent boots. Harder — a failed attempt needs re-arming, which is correct (a human decides to try again) but is a manual step.
* Implementation phases
-** Phase 1 — Stamp the safe-completion path
-A single =maint= entry point (=maint apply-upgrade=, or a documented flag on the existing lever) that runs =informant read= then the guarded system upgrade with Hyprland assumed down, and calls =maint stamp topgrade= on a zero exit. Usable immediately from a TTY. Leaves the tree working; no boot machinery yet. Tests: stamps on success, does not stamp on failure, refuses/aborts cleanly if Hyprland is live.
+** Phase 1 — The split-upgrade script (archsetup)
+=scripts/guarded-upgrade= (name open), installed to =/usr/local/bin= by the step that installs the guard. Behaviour as in Design: pending set → blocked set (hook =Target= lines, version-aware) ∪ held-kernel set when a compositor is live → =informant read= if present → =pacman -Syu --noconfirm --ignore=…= → =yay -Sua --noconfirm= → =topgrade --disable system,git_repos -y= → deferred set written to a state file → stamp only when nothing was deferred → exit 0 on a successful live part. The kernel set is derived from what is installed (every =linux*= kernel package and its =-headers=), never a hardcoded pair, and is always held or applied whole. Flags: =--dry-run= (print the plan and the deferred set, change nothing), =--no-topgrade=, =--no-aur=, =--complete= (the dedicated-session form: apply the kernel set live, run the gate, then arm the GPU/compositor set or, with no compositor live, apply it directly; stamp when the deferred set is empty). The gate is its own small script, =kernel-modules-check=: for each kernel under =/usr/lib/modules=, =dkms status= reports every registered module =installed= for it, and its initramfs is newer than its =vmlinuz=; on a ZFS root, a =pre-pacman_= snapshot of the root dataset exists. It exits non-zero with the failing item named, and =--complete= refuses to arm or reboot on that exit. Usable from a TTY at once. Tests (pytest beside the guard's): blocked-set computation against a fixture hook and version map; the kernel set is derived from the installed kernels and held whole on every everyday run; the =--ignore= list is exactly blocked ∪ kernel set; the state file round-trips; stamps only on an empty deferred set; =--dry-run= is IO-free; the gate passes and fails on fake =dkms status= output, image timestamps, and snapshot listings, and =--complete= never reaches the arm step on a failed gate.
-** Phase 2 — The armed boot-time unit (archsetup)
-=archsetup-boot-upgrade.service= installed by the installer: =ConditionPathExists= the arm flag, =Before=getty@tty1.service=, =TimeoutStartSec= bounded, non-fatal to every session target, =ExecStart= calls the Phase-1 path as the user and removes the flag unconditionally. Installer step + unit file + the =/var/lib/archsetup/= flag directory. Tests in =tests/installer-steps/= for the install step; a documented manual boot test (arm, reboot, observe) in =todo.org= under Manual testing and validation.
+** Phase 2 — Wire maint to it (dotfiles)
+UPDATE and TOPGRADE levers change their =argv= to the script; the press-again-to-force sentinel wrap goes away (the driven path never trips the guard). A new probe reads the deferred-set state file and the panel renders "N deferred — apply on reboot" as its own row. A test asserts the TOML =guard_patterns= equal the installed hook's =Target= list. Tests under the maint fake harness.
-** Phase 3 — The arming affordance (dotfiles maint)
-The panel/CLI detects a guarded library in the pending set (the same trigger list the hook uses) and offers "apply on reboot": writes the arm flag and offers to reboot. Tests: the offer appears only when a guarded lib is pending, writes the flag, and is idempotent.
+** Phase 3 — "Apply on reboot" and the boot-time unit (archsetup + maint)
+The panel action installs the held-kernel set live, writes the persistent arm flag, and offers to reboot. =archsetup-boot-upgrade.service=, installed by the installer: =ConditionPathExists= the flag, =Before=getty@tty1.service=, =TimeoutStartSec= bounded, non-fatal to every session target, =ExecStart= runs the script's =--complete= form as the user and removes the flag unconditionally. Installer step + unit file + the =/var/lib/archsetup/= flag directory. Tests in =tests/installer-steps/= for the install step; the arm action's tests in maint; a documented manual boot test (defer, arm, reboot, observe) in =todo.org= under Manual testing and validation.
** Phase 4 — Docs, rollout, and both daily drivers
Document the flow (arm → reboot → console upgrade → session). Roll the unit to velox and ratio (installer already covers a rebuild; existing machines need the one-time install). Confirm the ratio path matches.
* Acceptance criteria
+- [ ] With a guarded library pending and Hyprland live, UPDATE applies everything else, exits 0, and the panel shows the exact deferred set; the guard hook does not fire.
+- [ ] The same run with no compositor live (a TTY) applies everything but the kernel set and stamps only if nothing was deferred.
+- [ ] A =--complete= run whose DKMS build fails stops before arming or rebooting, names the failure, and leaves the machine running on the old kernel; on velox the pre-pacman snapshot it required is bootable from ZFSBootMenu.
- [ ] With a guarded library pending, arming and rebooting applies it in the console before Hyprland starts, and =maint status= then reads a fresh =topgrade_age=.
- [ ] A boot-upgrade failure (a failed step, a timeout, an aborted transaction) never blocks the session: the machine boots into Hyprland, the flag is cleared, and the panel still shows the pending work.
- [ ] Unread Arch news does not wedge the boot run (=informant read= precedes the transaction).
@@ -141,7 +179,7 @@ Answer each, or write "N/A because…".
- Security & privacy: relies on the existing =%cjennings NOPASSWD: ALL=; the unit runs the upgrade as the user via sudo, adds no new privilege. Note the NOPASSWD breadth as a pre-existing fact, not introduced here.
- Observability: the boot run's output is on the console; its systemd unit status and journal record success/failure; the panel reflects the cleared or still-pending state after boot.
- Performance & scale: one pacman/yay transaction at boot; bounded by =TimeoutStartSec=. Negligible boot-time cost when the flag is absent (=ConditionPathExists= skips the unit).
-- Reuse & lost opportunities: reuses the guard's trigger list (single source of truth for "which libs are dangerous"), =informant=, =maint stamp=, and topgrade's own =--only system=. No reinvention.
+- Reuse & lost opportunities: reuses the guard's trigger list by reading the installed hook (single source of truth for "which libs are dangerous"), =informant=, =maint stamp=, =checkupdates=, and topgrade's own step switches. The one duplicate that exists today — maint's TOML =guard_patterns= — is kept as a display mirror and pinned to the hook by a test rather than removed.
- Architecture fit & weak points: integration points are the pacman hook set, getty autologin ordering, and the maint cache. Weak point: the =Before=getty@tty1= ordering is load-bearing for safety; a parallel run reintroduces the live-swap crash. Mitigated by making the ordering explicit and tested-by-inspection.
- Config surface: the arm flag path and the timeout. Defaults safe (absent flag = no-op).
- Documentation plan: a short "reboot to apply guarded upgrades" note in the maint docs; the installer step self-documents in-comment.
@@ -152,13 +190,23 @@ Answer each, or write "N/A because…".
* Risks, Rabbit Holes, and Drawbacks
- Boot critical path: the unit sits ahead of autologin, so a hang would delay boot. Mitigated by =TimeoutStartSec= and non-fatal wiring; worst case is a bounded delay, then a normal session.
- Interactive prompts under no stdin: =yay=/pacman can still prompt (provider choice, replace, AUR review) even with =assume_yes=. The =--only system= scope and =--noconfirm=-style flags shrink this to near zero, but a prompt with no stdin fails the run (benign) — needs a genuinely non-interactive invocation, verified in Phase 2.
-- Partial ecosystem state: N/A for the hazard — the system step is a single atomic pacman transaction, so there is no half-swapped GPU library.
-- Adjacent but out of scope: a live kernel upgrade is not guarded by this hook yet removes running-kernel modules until reboot. Flagged as vNext, not solved here.
+- Partial ecosystem state: N/A for the GPU hazard — each pacman run is one atomic transaction, so there is no half-swapped library. The =--ignore= run is a partial upgrade in Arch's sense; pacman's dependency resolution is the safety net, and the residual unversioned-ABI exposure is accepted in Alternative E.
+- Orphans that block resolution: a package dropped from the repo but still pinning an old version (ratio's =qemu-block-gluster= on 2026-08-25) fails the whole transaction. The script should detect the "could not satisfy dependencies" case, name the foreign package, and stop with the remedy — never =-Rdd= on its own.
+- The kernel on a DKMS ZFS root: a failed =zfs-dkms= build after the kernel swap cannot be aborted (the DKMS hooks are PostTransaction) and leaves velox unbootable on the new kernel. Mitigated by holding the kernel set on every everyday run, landing it only in the dedicated session behind the gate, and by the standing fallback: =/boot= lives in the root dataset, the =05-zfs-snapshot= hook snapshots it before every transaction, and ZFSBootMenu can boot that snapshot. The pacman cache also keeps the previous kernel and =zfs-dkms= for a downgrade. Ratio's exposure is its data pool only (btrfs root, two kernels).
+- Standing kernel deferral: because the everyday run never moves the kernel, the dedicated session has to happen on a cadence or kernel security fixes sit unapplied. The panel's deferred row is the reminder; a stale-kernel age in maint is a possible follow-up.
* Testing / Verification / Rollout
Phase-1 and Phase-3 logic under the maint fake harness; Phase-2 install under =tests/installer-steps/=. The one thing no unit test can cover — that an armed reboot actually applies the upgrade pre-session and stamps — is a scripted manual test in =todo.org= (arm with a guarded lib pending, reboot, confirm the console run, the fresh metric, and a normal session). Roll to velox first, then ratio.
* Review and iteration history
+** 2026-08-25 Tue @ 18:45 -0600 — Craig Jennings — author
+- What: closed all seven decisions. Reversed the kernel decision (hold on every everyday run; land only in the dedicated session, gated on DKMS built, initramfs fresh, snapshot present; withdrew "install live at apply-on-reboot"), reworded the boot-scope decision for the split design, added the =kernel-modules-check= gate to Phase 1 and the acceptance criteria, and wrote the velox failure chain and the ZFSBootMenu fallback into Risks.
+- Why: on velox a failed =zfs-dkms= rebuild after a kernel swap is unabortable and unbootable; that belongs in a session I chose, not in an update I expected to touch applications.
+- Artifacts: this session's log (velox boot layout verified live: ZBM on the ESP, =/boot= in =zroot/ROOT/default=, one kernel, =zfs-dkms 2.4.4=).
+** 2026-08-25 Tue @ 18:30 -0600 — Craig Jennings — author
+- What: made the split live run (E) the everyday path and the boot oneshot (D) the completion step; added the script-ownership, stamp-semantics, and kernel-hold decisions; rewrote the phases around the script; added the orphan-blocks-resolution risk.
+- Why: watching a 724-of-730 guarded run succeed by hand on ratio made it obvious the guard's abort should be the rare path, and that the logic belongs in an editable script the panel calls rather than in the panel.
+- Artifacts: this session's log; the ratio run (=ratio-upgrade.service=, =/var/log/ratio-upgrade.log=).
** 2026-08-25 Tue @ 06:39:42 -0600 — Craig Jennings — author
- What: initial draft.
- Why: the topgrade-freshness metric reads permanently stale because the guard blocks the arch step; designing a safe completion path rather than loosening the gate.
diff --git a/todo.org b/todo.org
index ed37e3c..d6a3e45 100644
--- a/todo.org
+++ b/todo.org
@@ -45,6 +45,45 @@ below):
input-side-spec.org (DRAFT, four decisions open).
* Archsetup Open Work
+** TODO [#B] gcalcli in the installer, token carried from the other daily driver :feature:velox:tooling:solo:
+:PROPERTIES:
+:CREATED: [2026-08-25 Tue]
+:LAST_REVIEWED: 2026-08-25
+:END:
+From home's 2026-08-25 handoff: velox's 08-13 reinstall left it without
+gcalcli, and on 08-21 both calendar write paths on velox were down at once
+(the google-calendar MCP with expired tokens, and no gcalcli), so a booked lab
+appointment sat uncalendared for three days. The daily-drivers one-time-setup
+drift, exactly.
+
+Part 1 is done (2026-08-25, this session): =pipx install gcalcli==4.5.1= on
+velox to match ratio, then ratio's =~/.local/share/gcalcli/{oauth,cache}=
+copied over tailscale (=oauth= is a 1 KB pickled google-auth credential,
+=chmod 600=). =gcalcli list= on velox returned all six calendars with no
+re-consent, so the token is portable between the daily drivers and the OAuth
+click-through is not needed when the other machine is reachable.
+
+Part 2, this task: make the installer do it.
+- =pip_install gcalcli= in the tool set beside =pip_install yt-dlp= (archsetup
+ ~line 3109; =pip_install= wraps =pipx install= as =$username=). Pin or not:
+ ratio and velox are both 4.5.1; unpinned matches how yt-dlp is installed.
+- The credential can't be installed: add a named post-install manual step
+ "copy =~/.local/share/gcalcli/oauth= from the other daily driver
+ (=scp <other>:.local/share/gcalcli/oauth ~/.local/share/gcalcli/=,
+ =chmod 600=), or run =gcalcli init= per
+ =assets/2026-02-01-gcalcli-setup.org= when neither machine has it."
+- A =post-rebuild-check= item: gcalcli on PATH and the oauth file present, so
+ the drift is caught by the checker rather than by a missed appointment.
+- Tests: an installer-steps pytest asserting the tool set carries
+ =pip_install gcalcli=; a post-rebuild-check test for the new item, both
+ states.
+- When it lands, confirm back to home (=inbox-send home=) so it can retire
+ its "gcalcli is not installed on velox" notes.
+
+Grading: feature, no hard date, real improvement to the install = [#B].
+:solo: — build path (installer + checker + tests) and verify path (pytest;
+the live proof already exists on velox) with no open decision.
+
** TODO [#A] Topgrade guarded-upgrade spec — decisions, review, decomposition :feature:maint:dotfiles:
SCHEDULED: <2026-08-25 Tue>
:PROPERTIES:
@@ -73,26 +112,6 @@ Then: flip the decisions DONE, run spec-review (DRAFT → READY), run
spec-response to decompose the four phases into build tasks here, file the
vNext =[#D]= kernel-reboot item, and commit the spec.
-** TODO [#A] Ratio: pull .emacs.d before upgrading Emacs to 31.1 :chore:ratio:emacs:
-SCHEDULED: <2026-08-25 Tue>
-:PROPERTIES:
-:CREATED: [2026-08-25 Tue]
-:LAST_REVIEWED: 2026-08-25
-:END:
-Emacs 31.1's warnings.el defers daemon-startup warnings into a closure holding
-the =*Warnings*= buffer; the config's dashboard-only sweep killed that buffer,
-so the first client frame of every fresh 31.1 daemon failed on Wayland and
-emacsclient silently fell back to =$DISPLAY= (XWayland, pgtk warning dialog).
-Fixed in =.emacs.d= commit =63831060= (2026-08-25, velox verified live:
-=GdkWaylandDisplay=). Ratio is still on 30.2, which lacks the deferring code,
-so it is fine until it upgrades — then it hits the same trap once per daemon
-start unless the fix is pulled first.
-
-Order on ratio: =git -C ~/.emacs.d pull= (the push from velox is the telega
-session's; confirm =63831060= is on origin first), then the =pacman -Syu= that
-brings =emacs-wayland 31.1=, then restart the daemon. Check afterwards:
-=emacsclient -e '(pgtk-backend-display-class)'= → =GdkWaylandDisplay=.
-
** TODO [#A] Lock-screen clock stale after a real sleep :bug:hyprland:dotfiles:velox:
SCHEDULED: <2026-08-25 Tue>
:PROPERTIES:
@@ -1645,10 +1664,10 @@ Restyle the audio panel's GTK CSS onto =tokens-waybar.css= + the banked composit
After ~5 hand ports, weigh widget-level codegen with evidence (mechanical duplication vs judgment per port). Recorded as a dated decision in the spec; go spawns its own spec.
*** TODO Flip the spec to IMPLEMENTED
When the phases above close: status heading keyword → =IMPLEMENTED=, dated history line with the reason, Metadata =Status= mirror. Three lines, one file.
-** TODO [#B] Net doctor expansion v1 — VM live verification :feature:dotfiles:network:
+** TODO [#B] Net doctor expansion v1 — VM live verification :feature:dotfiles:network:solo:
:PROPERTIES:
:SPEC_ID: ce29b103-ed9d-4f56-bf8c-9ed8fe680ff3
-:LAST_REVIEWED: 2026-07-13
+:LAST_REVIEWED: 2026-08-25
:END:
Build the [[file:docs/specs/2026-07-11-net-doctor-expansion-spec.org][net doctor expansion]] (IMPLEMENTED). Adds the control-plane cluster (rival-manager / nm-masked / keyfile-perms) and a sharper auth verdict to the shipped net doctor (=~/.dotfiles/net/=). Archsetup owns the dotfiles work end to end — edit, test, commit, and push in =~/.dotfiles=, then drop an inbox note. All build phases shipped and fake-verified; the one open piece is the VM live verification below.
*** 2026-07-11 Sat @ 02:47:47 -0500 Built the read-only control-plane probe
@@ -1670,13 +1689,13 @@ Spec status heading now IMPLEMENTED (dated history line + Status mirror); all fo
** DOING [#B] Run-time privilege model, standard across every panel doctor :feature:dotfiles:
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-13
+:LAST_REVIEWED: 2026-08-25
:END:
The audio input/output doctor is gaining a run-time privilege model (see [[file:docs/specs/2026-07-10-audio-doctor-input-side-spec.org][docs/specs/2026-07-10-audio-doctor-input-side-spec.org]], decision "The doctor may use sudo, resolved by context at run time"). Craig's call, 2026-07-10: make it a standard, "revise the other panels to be consistent with these changes."
The model: a doctor resolves its privilege at run time from three signals — passwordless sudo available (=sudo -n true=, which never hangs), a tty to prompt at, and whether it is the GUI panel. Four remedy classes: Auto (user-scope, reversible), Privileged (needs sudo — runs where passwordless, prompts on a CLI tty, degrades to Guide in a GUI with neither), Reboot-tail (run the applicable part, then instruct the reboot), and Guide (physical/BIOS/wait-for-upstream, nothing to run). Safety floor: every Privileged and Reboot-tail remedy defaults to Confirm or Arm tier, never silent Auto, because passwordless sudo is not consequence-free.
-The shared helper is built (see the dated entry below). What remains is per-panel adoption: wire each doctor's remedies through =panelkit.privmodel.resolve()= and audit them against the Confirm/Arm floor, and reconcile maint's =priv.py= build/fire table with the model rather than leaving its implicit always-passwordless assumption. That wiring lives in the per-panel fix phases (net Phase 1, bt Phase 2, audio input/output), each needing a real privileged host to verify =--fix= end to end, so none is agent-solo.
+The shared helper is built (see the dated entries below), maint is reconciled onto it, and net is wired: =classify.py= carries the =remedy_class= on its privileged verdicts and =doctor.py= resolves each through =panelkit.privmodel.resolve()= (net Phase 1, shipped 07-11). Adoption is the gate only — every panel's repair actions already exist; what adoption changes is whether and how an existing privileged action is allowed to run (RUN where passwordless, PROMPT on a CLI tty, GUIDE in a GUI), under the Confirm/Arm floor. What remains, checked against the tree 2026-08-25: bluetooth is part-wired (=bt/doctor.py= makes one =resolve(PRIVILEGED, ...)= call, no per-remedy classes yet — audit its individual fixes against the floor), and audio has nothing on the doctor side (pending the input-side spec). Each needs a real privileged host to verify =--fix= end to end, so not agent-solo.
Craig's decision, 2026-07-12: maint's harmless-reclaim privileged remedies (the silent CLEAN UP set — paccache keep3, journal vacuum, coredump clean) STAY silent-auto. The reconciliation gives that class a sanctioned, documented exception to the confirm floor rather than forcing Confirm/Arm; the value of the floor holds for everything else. Where sudo is not passwordless, maint should degrade per the model (prompt on a tty, guide in a GUI) instead of hard-failing.
@@ -1701,7 +1720,7 @@ The support machinery was deliberately kept for this task: =layout-navigate= and
** TODO [#B] Audit dotfiles/common directory :chore:dotfiles:
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-14
+:LAST_REVIEWED: 2026-08-25
:END:
Refiled from the archsetup task audit (2026-06-28), landed via ~/.dotfiles/inbox; the dotfiles content split into its own repo 2026-06-16 but the task tracking stays here per Craig (2026-07-02). Three parts:
- Review all 50+ scripts in =~/.local/bin= and remove unused ones.
@@ -1780,7 +1799,7 @@ Acceptance: fresh VM install of the ratio profile reaches an endpoint on =:8081=
** TODO [#B] Test + CI infrastructure :test:
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-13
+:LAST_REVIEWED: 2026-08-25
:END:
Umbrella for the test-harness and CI-automation buildout. Consolidated from the 2026-06-28 task audit: these were scattered top-level tasks circling one effort, re-homed as children so the work reads as a unit. Each child ships independently and keeps the priority it carried before. No CI runner exists yet, so the CI/CD-pipeline child gates several of the others.
@@ -1852,7 +1871,7 @@ Keep test runs performant as installs and post-install tests grow (target < 2 ho
:LAST_REVIEWED: 2026-05-21
:END:
Proactive monitoring integrated with testing
-*** TODO [#B] Fix VM cloning machine-ID conflicts for parallel testing
+*** TODO [#C] Fix VM cloning machine-ID conflicts for parallel testing :no-sync:
:PROPERTIES:
:LAST_REVIEWED: 2026-05-21
:END:
@@ -1960,7 +1979,7 @@ machine state.
** TODO [#B] Security hardening + audit :security:
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-14
+:LAST_REVIEWED: 2026-08-25
:END:
Umbrella for the security-hardening and audit effort. Consolidated from the 2026-06-28 task audit, re-homing the scattered security tasks as children so the work reads as a unit. Each child ships independently and keeps its prior priority.
@@ -1976,12 +1995,12 @@ Umbrella for the security-hardening and audit effort. Consolidated from the 2026
**** TODO [#B] Implement port scanning check
**** TODO [#B] Create security posture verification script
**** TODO [#B] Set up intrusion detection monitoring
-*** TODO [#B] Document threat model and mitigations within 6 months
+*** TODO [#B] Document threat model and mitigations
:PROPERTIES:
:LAST_REVIEWED: 2026-05-21
:END:
Identify attack vectors, what's mitigated, what remains
-*** TODO [#B] Complete security education within 3 months
+*** TODO [#B] Security education
:PROPERTIES:
:LAST_REVIEWED: 2026-06-24
:END:
@@ -3022,9 +3041,9 @@ Rewrote the bare =if $var= boolean conditionals (=show_status_only=, =fresh_inst
*** 2026-05-26 Tue @ 15:27:09 -0500 eval task moot — the line-434 eval is gone, the survivor is deliberate
Verified: the only =eval= left in =archsetup= is line 578 in =retry_install=, and it's intentional and documented — it captures =$?= directly from =eval "$cmd"= to dodge the if-compound-swallows-exit-code trap. Replacing it with an array would reintroduce that bug. The line-434 eval this task pointed at no longer exists. Nothing to change.
-** TODO [#B] The audio doctor never checks the microphone :bug:audio:
+** TODO [#C] The audio doctor never checks the microphone :bug:audio:
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-13
+:LAST_REVIEWED: 2026-08-25
:END:
The classifier is output-only. =diag.probe_semantic= already collects =default_source= and =default_source_present=, and =classify.py= reads neither: the word "source" appears once in the whole module, in the graph row that counts them. So a muted mic, a default source naming an unplugged device, or a mic at zero volume all classify as =healthy=, and the verdict prints "the default output is present and audible" while the input side goes unexamined. Found 2026-07-10 while asking whether the doctor would have caught Chrome losing the mic. It would not have.
@@ -3034,10 +3053,12 @@ Work: mirror the sink rules onto the source. =probe_semantic= gains =default_sou
Two things not to get wrong. An absent microphone is legitimate on a desktop, so "no input devices" must never be a fault the way =no-output-devices= is. And a monitor source is a legitimate default source (recording desktop audio), which is why =probe_semantic= passes =include_monitors=True= — inheriting the panel's display filter here would call a working setup broken.
-Specced 2026-07-10 after discussion with Craig, and the design grew past the original gap: [[file:docs/specs/2026-07-10-audio-doctor-input-side-spec.org][docs/specs/2026-07-10-audio-doctor-input-side-spec.org]] (DRAFT, four decisions open). A doctor key per direction, a kernel-level capture probe below PipeWire, PTT-aware muting, and a direction-aware guard. The precedence question the build would have faced is gone: a doctor per direction means the user's press says which side they came to fix.
+Specced 2026-07-10 after discussion with Craig, and the design grew past the original gap: [[file:docs/specs/2026-07-10-audio-doctor-input-side-spec.org][docs/specs/2026-07-10-audio-doctor-input-side-spec.org]] (DRAFT, three decisions open as of 2026-08-25). A doctor key per direction, a kernel-level capture probe below PipeWire, PTT-aware muting, and a direction-aware guard. The precedence question the build would have faced is gone: a doctor per direction means the user's press says which side they came to fix.
Parent spec: [[file:docs/specs/2026-07-09-audio-doctor-spec.org][docs/specs/2026-07-09-audio-doctor-spec.org]] (IMPLEMENTED). This is a v1 gap found after the fact, not a phase of it.
+Grading (2026-08-25 review): Major severity — the doctor's verdict is silently wrong for a whole direction, workaround is checking the mic by hand — × "some users, sometimes" (mic faults are occasional) = P3 = [#C]. Was held at [#B] ungraded; regraded by the matrix.
+
** TODO [#C] Weather chip color signals unclear + unenforced :bug:dotfiles:waybar:weather:
:PROPERTIES:
:LAST_REVIEWED: 2026-07-21
@@ -3089,7 +3110,7 @@ manual-testing entry; everything else verifies with fakes.
** TODO [#C] Net panel speedtest history :feature:dotfiles:network:
:PROPERTIES:
-:LAST_REVIEWED: 2026-07-14
+:LAST_REVIEWED: 2026-08-25
:END:
From the roam inbox (routed 2026-07-13): the networking panel should track speedtests over time with appropriate info. Shape: persist each SPEED TEST result (timestamp, down/up, latency, server) to a small local store and surface history in the net panel. Design questions for work time: retention window, which fields matter, and presentation within the panel's ~400px width (recent-results list vs trend readout). Point-in-time results exist today; the gap is comparison across days and venues.
@@ -4314,3 +4335,46 @@ installed.
*** 2026-08-21 Fri @ 14:18:03 -0700 Promoted the Phase 5 residual out to its own task
Rescoped 2026-07-04 (audit): the tunnels track already shipped most of the original Phase 5. Panel tunnel bring-up/down and detection landed (dotfiles 2d9d060 probes tailscale/NM-wireguard/Proton; 21db05a brings overlays up/down from the panel's Tunnels sub-view; 31ba056 diagnose/doctor understand tunnel routes; archsetup 2e40781 wireguard config import; the net-panel-other-interfaces spec is IMPLEMENTED). What remains for Phase 5 is only the =net vpn ...= CLI subcommand — cli.py still has no vpn/tunnel parser. Fold the panel's existing tunnel operations into a CLI surface; spec separately when picked up.
+** DONE [#A] Ratio: pull .emacs.d before upgrading Emacs to 31.1 :chore:ratio:emacs:
+CLOSED: [2026-08-25 Tue]
+:PROPERTIES:
+:CREATED: [2026-08-25 Tue]
+:LAST_REVIEWED: 2026-08-25
+:END:
+Emacs 31.1's warnings.el defers daemon-startup warnings into a closure holding
+the =*Warnings*= buffer; the config's dashboard-only sweep killed that buffer,
+so the first client frame of every fresh 31.1 daemon failed on Wayland and
+emacsclient silently fell back to =$DISPLAY= (XWayland, pgtk warning dialog).
+Fixed in =.emacs.d= commit =63831060= (2026-08-25, velox verified live:
+=GdkWaylandDisplay=). Ratio is still on 30.2, which lacks the deferring code,
+so it is fine until it upgrades — then it hits the same trap once per daemon
+start unless the fix is pulled first.
+
+Order on ratio: =git -C ~/.emacs.d pull= (the push from velox is the telega
+session's; confirm =63831060= is on origin first), then the =pacman -Syu= that
+brings =emacs-wayland 31.1=, then restart the daemon. Check afterwards:
+=emacsclient -e '(pgtk-backend-display-class)'= → =GdkWaylandDisplay=.
+
+*** 2026-08-25 18:10 — pull already landed; the upgrade half remains
+Checked ratio over tailscale: =~/.emacs.d= is clean at =91fbac72= (= =origin/main=),
+and =63831060= is an ancestor of HEAD — =modules/undead-buffers.el= carries the
+=*Warnings*= entry. Ratio is on =emacs-wayland 30.2-3= with =31.1-1= pending among
+720 updates (last full upgrade 2026-08-01; kernel 7.1.5 → 7.1.9 also pending,
+btrfs root, uptime 3.5 weeks). The daemon is a plain =emacs --daemon= (not a
+user unit) holding 2 live frames, so the restart step will drop those frames.
+What remains: the =pacman -Syu= on ratio, the daemon restart, and the
+=(pgtk-backend-display-class)= check.
+
+*** 2026-08-25 Tue @ 18:35:00 -0600 Upgraded ratio to Emacs 31.1 and verified the Wayland backend
+Ran the upgrade over tailscale as a transient unit (=ratio-upgrade.service=,
+log at =/var/log/ratio-upgrade.log=): 714 packages, =--ignore= on the six
+packages the live-update guard would have blocked (aquamarine, hyprland,
+hyprutils, mesa, vulkan-radeon, wayland — still pending, apply from a TTY
+before the reboot). One orphan cleared first: =qemu-block-gluster= had been
+dropped from the repo and pinned =qemu-common=; the new =qemu-full= no
+longer needs it. Killed the plain =emacs --daemon= (no modified buffers, no
+graphical frames), started =emacs.service= instead so the daemon carries the
+systemd user environment, and probed from a throwaway frame:
+=(pgtk-backend-display-class)= → =GdkWaylandDisplay=, =*Warnings*= alive.
+Ratio still wants a reboot for =linux 7.1.9=. Pacnews to review there:
+=/etc/ssh/sshd_config.pacnew= and two =/etc/tpm2-tss/fapi-profiles/*.json=.