aboutsummaryrefslogtreecommitdiff
path: root/docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org
diff options
context:
space:
mode:
Diffstat (limited to 'docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org')
-rw-r--r--docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org165
1 files changed, 165 insertions, 0 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
new file mode 100644
index 0000000..c2461be
--- /dev/null
+++ b/docs/specs/2026-08-25-topgrade-guarded-upgrade-spec.org
@@ -0,0 +1,165 @@
+#+TITLE: Guarded-Upgrade Completion — keeping topgrade freshness honest
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-08-25
+#+TODO: TODO | DONE
+#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
+
+* DRAFT Guarded-upgrade completion
+:PROPERTIES:
+:ID: 81cdfd72-db96-43d3-aa03-779878c99f3e
+:END:
+- [2026-08-25 Tue @ 06:39:42 -0600] drafted. Grounded in a live read of the maint engine, the pacman hooks, and the boot path on velox, not memory. The topgrade-freshness diagnosis that motivates it is in this session's log.
+
+* Metadata
+
+| Status | draft |
+|----------+-------------------------------------------------------------|
+| Owner | Craig Jennings |
+|----------+-------------------------------------------------------------|
+| Reviewer | Craig Jennings |
+|----------+-------------------------------------------------------------|
+| Related | maint =topgrade_age= metric; =hypr-live-update-guard= hook |
+
+* Summary
+
+The waybar maintenance module shows topgrade freshness as permanently stale. The cause is a real one: on a machine running Hyprland, a full =topgrade= almost never exits 0, because its system step upgrades GPU/compositor libraries that the =hypr-live-update-guard= pacman hook correctly refuses to swap under a live session. The freshness stamp is gated on topgrade's exit code, so a correct, protective refusal reads as "you never run updates." This spec designs a safe path to actually complete a guarded upgrade, and makes that completion record the freshness stamp, so the metric tracks the true state of the system.
+
+* Problem / Context
+
+The metric reads one cache key, =topgrade_run= (=~/.local/state/maint/topgrade_run.json=). Absent, the probe (=maint/src/maint/probes/updates.py:114=) returns WARN, "no topgrade run recorded". Two writers stamp it: the =topgrade= PATH wrapper (=~/.dotfiles/hyprland/.local/bin/topgrade=) on =rc -eq 0=, and the panel's TOPGRADE lever (=doctor.py=), which returns before the stamp on any non-zero exit. The read path is sound (a sandboxed =maint stamp topgrade= writes the file and =maint status= then reads freshness 0); the file is simply never written.
+
+It is never written because topgrade rarely exits 0 on this machine, and the reason is specific rather than flaky. =/etc/pacman.d/hooks/10-hypr-live-update-guard.hook= is a =PreTransaction=/=AbortOnFail= hook that, when Hyprland is running and an upgrade changes the on-disk version of a GPU/compositor library, prints a BLOCKED banner and exits 1 — aborting the whole transaction before any file is swapped. Its trigger set is =mesa=, =mesa-*=, =wayland=, =libdrm=, =libglvnd=, =hyprland=, =aquamarine=, =hyprutils=, =hyprgraphics=, =vulkan-radeon=, =vulkan-intel=, =vulkan-mesa-layers=, =nvidia-utils=, =lib32-nvidia-utils=, =xorg-xwayland=. The guard exists for a proven failure: replacing those libraries under a live compositor makes the next GPU call hit a now-deleted mapping and SIGABRT, taking every Wayland client down (hit on ratio 2026-06-07).
+
+So when any of those libraries has an update pending — a frequent event — topgrade's =system= step (it runs =yay=) aborts non-zero, topgrade returns non-zero, and neither writer stamps. The observed case: on 2026-08-24 topgrade ran at 17:49, hit the guard on =mesa= (26.1.7 → 26.2.1), and failed; the upgrade was then finished by hand with the guard's sentinel override, entirely outside the wrapper, so nothing stamped. The metric has read stale ever since.
+
+Two framings of the fix are in tension, and choosing between them is the spec's central decision. Either the metric means "how recently did you run the sweep" (recency), so the stamp should decouple from topgrade's exit; or it means "is the system up to date" (state), so staying stale while a guarded upgrade is deferred is *correct* and the only real defect is that safely completing that upgrade doesn't stamp. This spec takes the state framing (see Decisions).
+
+* Goals and Non-Goals
+
+** Goals
+- A safe, low-friction way to apply a guarded (GPU/compositor-library) upgrade, with Hyprland not live at swap time.
+- That completion records the =topgrade_run= freshness stamp, so the metric clears when the system is genuinely current.
+- A boot-time upgrade path that can never lock the machine out of its session, however it fails.
+- The installer owns the durable pieces so a rebuilt machine has them without hand-setup.
+
+** Non-Goals
+- Weakening or bypassing the =hypr-live-update-guard= hook. 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.
+- 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=).
+
+* 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.
+
+*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.
+
+*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.
+
+The stamp also needs to happen when the upgrade is completed by other safe means — the by-hand sentinel-override path, or a =maint= command that does the same thing. The cleanest single home for the stamp is a small =maint apply-upgrade= (or a flag in the existing lever) that performs the guarded system upgrade and stamps on success, which both the boot unit and an interactive TTY run call. That keeps one code path that "completes a guarded upgrade and records it," rather than three writers that can drift.
+
+* Alternatives Considered
+
+** A. Decouple the stamp from topgrade's exit code (stamp on any real run)
+- Good, because it is a one-line change to the wrapper and needs no boot machinery.
+- Bad, because it throws away honest signal: a topgrade that was blocked from applying a real upgrade would read as "fresh," so the metric stops meaning "up to date." On this machine the blocked case is the common case, so the metric would be fresh precisely when an upgrade is outstanding.
+- Neutral, because the failed steps still surface elsewhere (pending-updates count), so freshness would become redundant rather than wrong.
+
+** B. Run the full topgrade live with the guard overridden, then reboot
+- Good, because it needs no new unit — arm the sentinel, run, reboot.
+- Bad, because the dangerous window is the whole rest of the run: mesa swaps early, then topgrade spends minutes on other ecosystems while the live compositor is one new GL context (a new window, the wallpaper daemon) away from SIGABRT. topgrade's own reboot-at-end is that window, not a fix for it.
+- Neutral, because it would stamp naturally on success — if it survived.
+
+** C. Manual TTY ritual only (log out, run topgrade at the console, reboot), plus stamp
+- Good, because it is the safest path and needs almost no code — just make the completion stamp.
+- Bad, because it is all manual, every guarded-upgrade day; the friction is why it won't happen consistently, which is how the metric got stale in the first place.
+- Neutral, because it is exactly what the boot unit automates, so it is really "v1 minus the automation."
+
+** D. Boot-time armed oneshot, arch-only (this spec)
+- Good, because the risky swap happens with nothing live, the run is one bounded transaction with a tiny prompt surface, it stamps on success, and a failure degrades to "boots normally, try again."
+- Bad, because it puts a unit on the boot critical path, which must be bounded and non-fatal with care, and it is the most to build.
+- Neutral, because it composes with C: the same =maint apply-upgrade= path serves both an interactive TTY run and the boot unit.
+
+* Decisions [/]
+
+** TODO Metric means state, not recency
+- Owner / by-when: Craig / at spec-review
+- 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
+- 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 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 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 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, 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).
+- [ ] A guarded upgrade completed from a TTY via the Phase-1 path stamps freshness identically to the boot unit.
+- [ ] The =hypr-live-update-guard= hook is unchanged and still blocks a live guarded swap.
+
+* Readiness dimensions
+Answer each, or write "N/A because…".
+- Data model & ownership: the arm flag (=/var/lib/archsetup/=, installer-owned) and the =topgrade_run= cache key (maint-owned). No user-authored data.
+- Errors, empty states & failure: the boot unit is best-effort and self-disarming; every failure path lands in "boot normally, metric stays stale, re-arm to retry." Named, non-silent.
+- 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.
+- 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.
+- Dev tooling: installer-step pytest for Phase 2; maint unit tests for Phases 1 and 3; a manual boot test in =todo.org=.
+- Rollout, compatibility & rollback: additive; removing the unit and flag reverts fully. Existing machines need a one-time install; a rebuild gets it from the installer. Rollback leaves the guard and manual TTY path intact.
+- External APIs & deps: topgrade =--only system=, =informant read=, =yay=, =maint stamp= — all verified present on velox this session. No external service.
+
+* 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.
+
+* 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 @ 06:39:42 -0600 — Craig Jennings — author
+- What: initial draft.
+- Why: the topgrade-freshness metric reads permanently stale because the guard blocks the arch step; designing a safe completion path rather than loosening the gate.
+- Artifacts: this session's log; =hypr-live-update-guard= hook; maint =topgrade_age= probe.