`sync` on a box with a live session on it was unsafe in two independent ways, found by preflighting the run rather than performing it. Refs #44. **`enable --now` would have armed a screen lock mid-session.** The unit loop started every `systemd-user/` unit whose `ExecStart` resolves. `swayidle-lock.service` on blankpad is `enabled`/`inactive` — nothing started it, no swayidle process exists, `autostart.kdl` does not launch one — so one `deploy.sh` would have begun blanking and locking the monitors while the operator was mid-sentence. `live-sync.service` is `linked`, driven by its timer, and `--now` forces a heartbeat commit at an arbitrary moment. A unit is now started only when systemd reports it as nothing-yet (`""`/`disabled`), which is precisely a fresh box; anything already installed is merely `enable`d. The state has to come from the **printed state**: `is-enabled` exits 1 for `linked` exactly as for `disabled`, so exit status cannot distinguish "installed, timer-driven" from "never installed" — the first version of this fix got that wrong and the test caught it. **`--check` output was being read as more than it can mean.** 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`, `.zshrc` all did on blankpad), because the walk visits the base package first and reports the intermediate owner change. The sentence committed in #43 — RELINK noise is "the sound of a stale override waiting" — is too strong and is corrected: the line proves a pending change only when `readlink` shows the live target is not already the `zz-$HOST` file, and `actions=0` is the honest summary. The stale-override hazard #43 removed was and is real; the signal for it is the target, not the line. **The dconf dump carried six of its 24 keys from another box**, none of them preferences: a Firefox file-chooser `last-folder-path` of `/home/dank/Downloads`, a nautilus `window-state` section, and `picture-uri`/`picture-uri-dark` pointing at `file:///home/dank/.config/background`. The last five showed no conflict because blankpad's live tree *already* held those `/home/dank` values — an earlier deploy wrote them here — and a foreign value that agrees with live is invisible to any diff while still being wrong. They are dropped; `picture-options='zoom'` stays, because it is a preference. The live wallpaper URIs are cleared on the box as part of landing this (niri draws the wallpaper, noctalia owns it, nothing reads a dead path). `tests/deploy.bats` grows by four, over a stub `systemctl` — the real one on this box is exactly what must not be invoked from a test. Red before: an already-`enabled` unit and a `linked` unit both get `--now`. Green after, including the two directions that keep the change honest: a `disabled` unit is still `enable --now`'d so a fresh box comes up, and a unit whose `ExecStart` is absent here is never touched at all. Verification: `bats tests/` 70/70, `kb-lint.sh omp-docs` 0, `kb-coverage.py` 0. The dump now reconciles against live state with **0 conflicts and no `/home/` values in 18 keys**, proven by key-by-key comparison rather than `diff` — `dconf load` writes only the keys present, so a diff overstates the damage one way and hides it the other.
11 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. |
|
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: 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.