Files
command-center/omp-docs/tools/dotfiles-deploy.md
T
Frank 4666d0dce0 Correct the lock-watcher law: the trigger is the session restart, and suspend was covered
Two statements in the law added by #48 need measurement rather than inference, and one of them was
wrong.

**Attributed.** `niri` and `noctalia` both started `Mon Oct 12 16:22:31 2026` (`ps -o lstart=`);
swayidle's last state change was `16:22:23`. The compositor session restarted and the watcher died
with it — eight seconds of agreement, which is what `PartOf=graphical-session.target` is for. The
fix that #48 justified by reasoning is now justified by the event itself. The user journal holds no
entries for that window, so the evidence is process ages, not logs.

**Corrected.** #48 said closing the lid suspended blankpad unlocked, because `before-sleep` lived in
the dead unit. `systemd-inhibit --list` says otherwise: `noctalia … sleep "Lock before sleep" delay`
— the shell holds its own sleep inhibitor and locks on suspend independently of swayidle, so the
suspend path held all along. What was dead for eight days is the **idle** lock and `after-resume`.
The method error is worth keeping: `pgrep` for lockers came back empty and I read that as "no lock
path", when a locker can be a hook inside a running shell. The inhibitor table is the instrument.

Added with them, because it answers a question people will ask: while an `omp` session runs, swayidle
logs `Not enabling timeouts: idle inhibitor found` and arms no idle timeout (`omp … idle "omp agent
session" block`). A box with an agent working does not lock on inactivity, by harness design.

Docs only; the unit file from #48 is unchanged and the live watcher is `active` with `NRestarts=1`
after the SIGTERM proof. `kb-lint.sh omp-docs` 0 violations, `kb-coverage.py` 0 issues, `bats tests/`
71/71. Refs #47.
2026-10-20 17:52:59 -07:00

15 KiB

type, title, description, tags, timestamp, verified
type title description tags timestamp verified
reference Dotfiles deployment tier How dotfiles/ packages become live configuration — stow-equivalent symlinks, host override packages, and the two laws about theme media and systemd enable symlinks.
dotfiles
deployment
systemd
noctalia
2026-10-20 2026-10-20

Dotfiles deployment tier

dotfiles/ holds home-relative trees and scripts/deploy.sh is a stow-equivalent: the repo checkout is the deployment, and deploying replaces live files with symlinks into the repo, so git pull + deploy.sh is the whole propagation story. One directory per tool (niri, ghostty, gtk-3.0, systemd-user, …); a package named zz-$(hostname) is stowed last and its files replace symlinks coming from base packages.

Mode Effect
deploy.sh --check dry run — adopts identicals, reports what would be linked, lists divergents, writes nothing. Run this first, always
deploy.sh (sync) adopt identicals, link new, quarantine divergents into .quarantine/ and replace them
deploy.sh --adopt like sync, but divergents are reported and skipped rather than quarantined

Two package shapes are not symlinks. dconf/dump.txt is loaded with dconf load /, and systemd-user units get daemon-reload plus enable; a unit is started only when systemd reports it as nothing-yet (""/disabled), and never when its ExecStart binary is absent here, so a unit belonging to another box stays inert instead of crash-looping. Starting something already installed is what makes deploy.sh unsafe to run beside a live session — see Never start a unit a session may already be running.

The tracked unit is the file, not the directory. A live directory can be a real directory and still be fully tracked, because every file inside it is a symlink into the repo (niri is 8 tracked / 2 box-only; gtk-3.0 is 3/0). Any audit that classifies by directory mislabels the tree — classify per file with readlink -f. The live census and its classifier command are in home-config-census.

Law: media the theme pipeline consumes is content, not config

Wallpapers live at dotfiles/noctalia/.local/share/noctalia/wallpapers/ and are gitignored. They were tracked until 2026-10-20 — 23 files, 80 MiB — which meant every clone of a configuration repo carried a media library no diff can review, and the payload only ever grows because deleting a tracked blob from the tree does not delete it from history. The topology is not what one would guess: Noctalia keeps its own library at ~/.local/share/noctalia/wallpapers/ as a real directory of real files, so the tracked tree was a duplicate of it and never its source — measured 2026-10-20, when the merge of #41 deleted the repo copies and the desktop did not flicker. Two consequences to know before untracking media: the copy that survives belongs to the application, not to the repo; and untracking deletes the file in every other checkout, git rm --cached on the branch notwithstanding.

Restore on a fresh box: the library lives in ~/.local/share/noctalia/wallpapers/, a path deploy.sh does not manage. Every filename is wallhaven_<id>.<ext> and the ID is the whole address — curl -L -o <name> "https://wallhaven.cc/w/<id>" into that directory puts the set back. Losing it is an acceptable outcome; 80 MB per clone forever is not.

Law: never systemctl --user enable a unit from a worktree

enable writes ~/.config/systemd/user/default.target.wants/<unit> as a symlink to the unit path it was handed, and that path is absolute. The heartbeat reaps wt/* worktrees older than seven days (live-sync), so a unit enabled from a worktree turns into a dangling symlink while systemctl --user is-enabled still answers enabled — the unit is dead and the status lies about it. Found on 2026-10-20: blankpad-desk-autoswitch.service pointed into the reaped wt/de-redesign-20261009, so desk auto-switching had been silently off since the reap.

Check the link target before the unit file:

ls -l ~/.config/systemd/user/default.target.wants/
# every target must resolve; a dangling one is a reaped worktree, not a config bug

Fix is to relink into the deployment checkout (~/.omp/dotfiles/…), systemctl --user daemon-reload, then confirm with is-active — is-enabled proves nothing.

Law: a generated file must never be a required include

~/.config/niri/config.kdl ends with include "noctalia.kdl" — no optional=true. That file is noctalia's palette output (#ff8c00, #f43841, one re-stamp 2026-10-18 17:36:27), so it is machine state and correctly untracked. But on a fresh box deploy.sh links config.kdl first, and niri then refuses to start until noctalia has run once and written the file — the compositor is bricked by the order of operations, not by a bad config. Close it at the include, which survives someone skipping the theme pipeline: mark generated includes optional=true, as the display.kdl and switch-events.kdl lines in the same file already are.

Desk choice is live state, never config

~/.config/niri/cfg/display.kdl is a relative symlink to whichever profile is active (desk-1-home.kdl, desk-2-portable.kdl), written by desk.sh. Both profiles are tracked in dotfiles/zz-blankpad/; the pointer is not, because which desk the box sits on is not a fact about the repo. A fresh box therefore starts with no output configuration — config.kdl includes display.kdl with optional=true, so niri comes up usable but with default monitors until desk.sh is run once.

Law: generated theme output is never deployed

noctalia rewrites palette output in place — noctalia.*, themes/noctalia*, colors/noctalia*, noctalia-* (the classes the census proves by same-millisecond mtime). A tracked copy of one is a stale snapshot of a colour scheme, and the failure is quiet: dotfiles/zz-blankpad/niri/.config/niri/noctalia.kdl (commit b3d3df4, 2026-10-18) sat byte-identical to the live file for two days, harmless, while --check cheerfully proposed ADOPT … noctalia.kdl. The moment the palette changed, the next sync would have quarantined the live file and restored 2026-10-18's colours — a tool silently reverting a user-visible theme choice.

deploy.sh now refuses the class (SKIP … (generated theme output)), .gitignore carries the matching patterns so it cannot be re-added, and tests/deploy.bats pins both directions: generated output is never adopted, and an ordinary identical config still is. Untracked in #43, Refs #42.

Law: a zz override older than the file it overrides is a time bomb

Stow order means zz-$HOST wins, and when both packages carry the same path, sync relinks it silently — the script's own comment: "managed file changing owners between packages: silent relink, nothing to quarantine". dotfiles/zz-blankpad/niri/.config/niri/cfg/layout.kdl held focus ring width 2 / active-color "#e01b24" from 7b7dbf5 (2026-10-17) while the base package absorbed b3d3df4 (2026-10-18): width 4, "#ff8c00". Live was linked to base, the newer intent, so --check printed RELINK .config/niri/cfg/layout.kdl on every run and one unattended deploy.sh would have reverted the amber focus ring with no quarantine and no warning. The duplicate is deleted in #43.

Rule: a zz file exists only to differ. When base absorbs the change, delete the zz copy in the same commit.

How to read --check, measured on blankpad: it prints RELINK for every path the zz tier carries, even when the live link already points at the override — rules.kdl, keybinds.kdl, misc.kdl, autostart.kdl and .zshrc all did — because the walk processes the base package first and reports the intermediate owner change. A RELINK line therefore proves a pending change only when readlink shows the live target is not already the zz-$HOST file. actions=0 is the only honest summary.

Law: never start a unit a session may already be running

sync used to end with systemctl --user enable --now for every unit in systemd-user/ whose ExecStart resolves. Measured on blankpad before one such run:

unit UnitFileState ActiveState
live-sync.service linked inactive
shelly-notifications.service enabled active
swayidle-lock.service enabled inactive

swayidle-lock.service was installed and not running — nothing started it, no swayidle process existed (pgrep -c swayidle = 0), and autostart.kdl does not launch one. A deploy.sh run would have armed it: monitors blanking and locking on the idle timer while the operator was mid-sentence. live-sync.service is linked, driven by its timer; --now forces a heartbeat commit at an arbitrary moment.

Two rules follow. Install state must be read from the printed state, not the exit status — is-enabled exits 1 for linked exactly as for disabled, so exit status cannot tell "installed, driven by a timer" apart from "never installed". And a unit is started only in the latter case: a machine with a session on it has already decided what runs, while a fresh box — where everything reports disabled — still gets its units up. Both directions are pinned in tests/deploy.bats over a stub systemctl, because the real one on this box is exactly what must not be invoked from a test. Refs #44.

Law: expand systemd specifiers before calling a unit foreign

ExecStart=%h/.omp/scripts/live-sync.sh is valid systemd and meaningless to a shell. The resolution check handed that literal string to command -v, which fails everywhere, so the deployment concluded the unit belonged to another machine and skipped it — silently, through the same code path that is supposed to keep a foreign unit inert. The unit it kept skipping was this repo's own heartbeat, and %h is precisely where this checkout lives, so the false verdict was the same on every box that deployed it. Measured on blankpad: the first real deploy.sh run printed SKIP live-sync.service (ExecStart '%h/.omp/scripts/live-sync.sh' absent on this host) while /home/blank/.omp/scripts/live-sync.sh sits right there.

Expand the specifiers a user unit can carry — %h, %u, %U — before resolving, and let an unknown specifier fail rather than guess at it. The probe needs no privileges and writes nothing:

bin=$(sed -n 's/^ExecStart=\([^ ]*\).*/\1/p' \
  ~/.omp/dotfiles/systemd-user/.config/systemd/user/live-sync.service | head -1)
command -v "${bin//%h/$HOME}"   # resolves → the unit is this box's; the SKIP was the lie

Law: the dconf dump holds preferences, not per-box state

dconf load / writes every key in dotfiles/dconf/dump.txt, so the dump carries whatever box it was taken from. Comparing key by key against live state on blankpad found six of 24 keys that were not this box's, and not one of them was a preference:

[org/gnome/portal/filechooser/firefox] last-folder-path
  repo: '/home/dank/Downloads'      live: '/home/blank/Downloads'
[org/gnome/nautilus/window-state]  initial-size, initial-size-file-chooser, maximized
[org/gnome/desktop/background]     picture-uri, picture-uri-dark
  both: 'file:///home/dank/.config/background'

A file-chooser memory, a window geometry, a wallpaper URI: application state that arrived with the dotfiles import. The last five did not appear as conflicts at all, because blankpad's live tree already held the same /home/dank values — an earlier deploy had written them here. A foreign value that agrees with live is invisible to any diff and still wrong. All six come out in #44; the two wallpaper URIs are also cleared on this box (gsettings set org.gnome.desktop.background picture-uri '', and its -dark twin), because niri draws the wallpaper, noctalia owns it, and nothing reads a dead path.

Compare key by key, not with a plain diff: dconf load writes only the keys present, so live-only keys survive and a diff overstates the damage in one direction and understates it in the other. Re-dump on the box whose settings are canonical (dconf dump / > dotfiles/dconf/dump.txt), then delete the state sections before committing — the census's Never track: app state class applies inside a tracked file, not just to whole directories. Refs #44.

Law: a watcher must restart on a clean exit too

swayidle-lock.service was enabled, present in default.target.wants/, and dead for eight days: Result=success, NRestarts=0, gone since 2026-10-12 16:22 after 2d 21h of uptime. Restart=on-failure cannot see a clean exit, and swayidle -w exits 0 on SIGTERM. The trigger is attributable to eight seconds: niri and noctalia both started Mon Oct 12 16:22:31 2026 (ps -o lstart=) while swayidle's last state change was 16:22:23 — the compositor session restarted, the watcher went with it, and because it was wanted by default.target rather than tied to the session target, nothing brought it back. The user journal keeps no entries for that window, so the attribution rests on process ages rather than logs, which is its own lesson: diagnose a lapse within the retention you actually have.

The severity claim in this page's first draft was wrong, and is corrected here. It said closing the lid suspended the machine unlocked, because the before-sleep hook lived in the dead unit. But systemd-inhibit --list shows noctalia … sleep "Lock before sleep" delay — the shell holds its own sleep inhibitor and locks on suspend independently of swayidle, so the suspend path was covered the whole time. What was actually dead for eight days is the idle lock: five minutes of inactivity locked nothing, and after-resume never fired. pgrep -c -u blankpad 'swayidle|swaylock|hyprlock|gtklock' = 0 remains true; it simply did not mean no lock path at all, because a locker can be a hook inside a running shell rather than a name on the process list. Read the inhibitor table, not only pgrep.

Three settings, each with a job: Restart=always, because a supervisor must not assume it can only die badly; StartLimitIntervalSec=0, because a fast loop must never wedge it into failed — a lock that keeps trying beats one that stopped trying; and PartOf=graphical-session.target, because the unit is wanted by default.target, so the session coming back did not bring it back.

Verify by behaviour rather than by reading the file — the point is surviving the event that killed it in October:

systemctl --user start swayidle-lock.service
kill -TERM "$(systemctl --user show swayidle-lock.service -p MainPID --value)"
sleep 8 && systemctl --user show swayidle-lock.service -p NRestarts -p MainPID -p ActiveState

An idle inhibitor also disarms the timeout outright, which surprises people: while an omp agent session is running, swayidle logs Not enabling timeouts: idle inhibitor found and arms no idle timeout at all — systemd-inhibit --list shows omp … idle "omp agent session" block. A box with an agent working therefore does not lock on inactivity, by harness design, while before-sleep and after-resume keep working. Anyone asking "why did the screen never lock" should read the inhibitor table before suspecting the unit.

The general shape: deploy.sh only ever enables. Nothing on any box re-checks that a unit the system depends on is still running, so a security posture can lapse silently and stay lapsed for a week. A liveness check for this tier belongs in the heartbeat, not in a human's memory. Refs #47.