Files
moonix/DECISIONS.md
T
nevaforget 7fc45ae0b8 fix(thinkpad): ready the host for its first install
The host still carried snapper, which the desktop dropped in d3cc03c:
disk.nix creates no .snapshots subvolumes, so the timer would fail every
hour without ever taking a snapshot. The account sat in the docker group
while docker itself was off, and stateVersion named 26.05 although the
machine is first installed on 26.11.

Also drops the stale snapper mentions elsewhere and adds git-firefly to
Zed's auto-installed extensions.
2026-09-23 13:14:30 +02:00

50 KiB
Raw Blame History

Decisions

2026-09-23 – ThinkPad host reviewed before its first install

  • Who: Dominik, ClaudeCode
  • Why: The ThinkPad moves from Arch to NixOS, and its host file had not been built since the desktop went through the same review. Three things were wrong for a first install: (1) it still carried the snapper configs the desktop dropped in a04fa91 — modules/disk.nix creates no .snapshots subvolumes, so snapper-timeline would fail every hour and never take a snapshot; (2) the account is listed in the docker group, but virtualisation.docker.enable was off, so neither dockerd nor the group existed, and the work projects on this machine run their services in containers; (3) stateVersion was 26.05 while nixpkgs is 26.11, the release the machine is actually first installed with.
  • Tradeoffs: snapper was removed rather than repaired, for the reasons in a04fa91: generations cover rollbacks of /, and /home snapshots on the same disk do not survive the disk. plugdev stays in extraGroups although no such group exists — it is a leftover of an old corsair udev rule, harmless, and not part of this change.
  • How: hosts/thinkpad/default.nix drops services.snapper, enables virtualisation.docker, sets stateVersion = "26.11". The stale snapper mentions in modules/user.nix, modules/disk.nix and README.md are rewritten. git-firefly joins Zed's auto_install_extensions, matching the extensions installed on the Arch side. Verified with nix build of the thinkpad and desktop toplevels.

2026-09-14 – moonarch-lock follows moonarch; the wrapper needs a PATH of its own

  • Who: Dominik, ClaudeCode
  • Why: moonarch replaced the direct gtklock calls with defaults/bin/moonarch-lock, a wrapper that passes the focused output as -M so the lock screen's input form lands on the monitor the user is at (rationale: moonarch/DECISIONS.md 2026-09-14). Both files carrying those calls — stasis/stasis.rune and PowerPopout.qml — come from moonarchSrc and are deployed here unchanged, so the new command arrived on NixOS immediately while the script did not: pkgs/moonarch-scripts.nix installs an explicit whitelist, and moonarch-lock was not on it. The failure would have been silent in exactly the way modules/services.nix already warns about — stasis logs the action as started, sees the immediate exit and stops there, the same shape as the swaylock outage.
  • Tradeoffs: (1) Wrapped, unlike the other seven scripts. They reach their tools through whatever starts them; moonarch-lock has two callers (the stasis unit and Quickshell) and must not depend on either one's PATH. Its three dependencies are not equal in weight: a missing gtklock means no lock screen at all, a missing niri or jq only the wrong monitor, since the script falls back to plain gtklock. That asymmetry is what makes makeWrapper worth breaking the pattern for. (2) gtklock leaves the stasis unit's path. The wrapper carries it now, and naming it twice would leave two places to keep in step. (3) No substitute on stasis.rune. The 2026-09-11 rewrite of swaylock→gtklock is gone — moonarch names the same command this repo wants, so the file is copied as it is. That stays true only as long as the wrapper keeps its name on both distributions.
  • How: pkgs/moonarch-scripts.nix gains moonarch-lock in scripts, makeWrapper in nativeBuildInputs, and a wrapProgram prefixing gtklock niri jq onto PATH; its arguments resolve through the existing callPackage, so flake.nix is unchanged. modules/services.nix: gtklock in the stasis unit's path gives way to moonarchPkgs.moonarch-scripts. README.md's deployment table no longer claims a rewrite for stasis.rune.

2026-09-11 – gtklock replaces swaylock; GTK theme moves from Frappé to Mocha

  • Who: Dominik, ClaudeCode
  • Why: Two findings from looking at whether gtklock was worth it. (1) The GTK layer was never on Mocha. colloid-gtk-theme with tweaks = [ "catppuccin" ] produces Frappé with a grey accent — base #292c3c, selection #ccd0da, error #ea999c; #1e1e2e, #b4befe and #cba6f7 appear nowhere in its gtk.css. Kvantum (catppuccin-mocha-lavender), the swaylock ring and Zed were all on Mocha, so GTK was the one layer out of step, and nobody had noticed because the two dark bases are close. (2) The old objection to gtklock is gone: 4.0.0 links libgtk-session-lock.so.0, so it speaks ext-session-lock-v1 like swaylock and the compositor still holds the lock if it crashes. The layer-shell versions that made a crash unlock the screen were ≤2.x.
  • Tradeoffs: (1) Locker — swaylock's case was "one protocol, no toolkit, least likely to break" (2026-08-14). gtklock adds GTK3 and two dlopened modules to the path that decides whether the machine unlocks. Accepted for what the modules give: avatar, clock and power buttons, none of which swaylock can do at all. Crash safety is unchanged, which was the part that actually mattered. (2) Blur — no gain. GTK3 3.24.52 has no filter CSS property (verified against libgtk-3.so.0.2420.32: it knows background-image and box-shadow, not filter or backdrop-filter), and gtklock takes no screenshot, so there is nothing to blur at lock time. The pre-blurred wallpaper stays, exactly as under swaylock. (3) GTK theme — catppuccin-gtk is Colloid: it carries vinceliuice/Colloid-gtk-theme as a git submodule and only recolors it. Identical widget shapes, so the switch is a recolor with no layout risk. Its icon counterpart does not exist, and Colloid-Grey-Catppuccin-Dark stays — an icon set has no background or accent surface to clash. (4) Where the gtklock config lives — /etc/xdg/gtklock/config.ini, generated rather than copied, because both the background and the module paths are store paths. gtklock searches $XDG_CONFIG_DIRS, which swaylock did not, so the $HOME seed that swaylock needed is gone. (5) Module paths — absolute store paths in the config, not /run/current-system/sw/lib. -m resolves a bare name against the directory compiled into gtklock, which under Nix is gtklock's own store path and holds no module. (6) stasis — its config is moonarch's and names swaylock in four places. Rewritten here with substitute, the same treatment waypaper gets, until moonarch switches too. The Quickshell power popout spawns the locker itself (PowerPopout.qml:56) and needed the same, except that its config is deployed as a whole directory, so the tree is copied and the one file patched. Both take the command from one lockCommand binding, the popout's argv form derived from it, so the locker is named once in this repo rather than three times. (7) Why both spawn it directly — there is no Wayland protocol for "lock the session": ext-session-lock-v1 is how a client becomes the lock, not a request a third party can send, and no portal covers it either. logind's Session.Lock only emits a signal someone has to be subscribed to, and nothing here is — niri owns org.freedesktop.ScreenSaver with Inhibit/UnInhibit and no Lock, and stasis ignores the signal on purpose (services/dbus.rs: "login1's Lock/Unlock signals are requests, not completed state changes"). xdg-screensaver lock is no answer either: it is an X11 script whose generic path runs xautolock -locknow. (8) stasis trigger lock-screen for the popout — rejected. It would name the locker in one place, but the Lock button would then fail silently whenever stasis is down, which is the worst direction for a button whose only job is security. The reason to want it turned out not to exist: the plan continues after a manual lock regardless, because stasis takes the state from logind's LockedHint (LockSource::LockedHint, core/events.rs) and does not need to have started the locker. Verified by locking by hand and watching DPMS fire on schedule. (9) -d is not quite -f — swaylock forks once the screen is locked, gtklock forks in main() before it has read its config (source.c:284), so a returned gtklock -d means "forked", not "locked". Nothing chains off the exit here; it would matter if something ever wanted to suspend only after the screen is covered.
  • How: flake.nix themePackages.gtk → catppuccin-gtk.override { variant = "mocha"; accents = [ "lavender" ]; }; theme name catppuccin-mocha-lavender-standard in the new gtkTheme binding, in programs.dconf and in modules/greetd.nix. New gtkSettings rewrites the Colloid name out of moonarch's gtk-3.0/gtk-4.0 settings.ini — necessary because GTK3 reads those files directly and the dconf entry does not reach it on Wayland; without it every GTK3 application would have fallen back to Adwaita. swaylockConfig → gtklockConfig (writeText, deployed to /etc/xdg/gtklock/config.ini) plus stasisConfig; swaylock out of systemPackages, security.pam.services and the tmpfiles seeds, gtklock + gtklock-userinfo-module + gtklock-powerbar-module in. modules/services.nix: the stasis unit's path carries gtklock. playerctl-module deliberately not installed — it needs playerctld, which nothing here runs.
  • Remaining exception: the userinfo module reads the avatar through AccountsService, which resolves to ~/.face. With no such file it warns once and falls back to avatar-default-symbolic from the icon theme. There is no option for the path; drop an image at ~/.face to have one.
  • Open on the moonarch side: defaults/etc/swaylock/, the swaylock entry in packages/official.txt, check_swaylock_config in moonarch-doctor and the four swaylock lines in stasis.rune. Once those move, stasisConfig here can go.

2026-09-08 – nwg-displays replaces wdisplays; kanshi dropped for niri's own outputs

  • Who: Dominik, ClaudeCode
  • Why: Monitors could not be set persistently at all. moonarch pairs wdisplays-persistent (AUR fork sfs-pra/wdisplays, see moonarch README.md:25 and its DECISIONS.md:178) with kanshi: the fork saves straight into the kanshi config. The port took the nixpkgs wdisplays instead — upstream, with no save function — and never recorded the difference. The result was a GUI that applies a layout and forgets it, an always-empty ~/.config/kanshi/config, and a kanshi.service whose ConditionPathExists therefore skipped it on every boot.
  • Tradeoffs: Packaging the fork would match moonarch exactly and keeps kanshi's per-combination profiles, but it is unmaintained and would be ours to carry. nwg-displays is in nixpkgs, actively developed, and supports niri as a first-class target alongside sway and Hyprland; it writes ~/.config/niri/monitor.kdl and adds the include for it. That makes kanshi redundant — niri applies output blocks itself, on hotplug and with live reload — so it goes, together with the hardening set it had just been given. What is given up is kanshi's profiles per attached combination: niri has one setting per output. Fixed monitors do not care; several docks with different arrangements would, and then kanshi has to come back. The seed is the same mechanism moonarch chose for stasis (its DECISIONS.md, 2026-04-22): niri has no config hierarchy either, so system-default-plus-user-override cannot be modelled — the home file is seeded once and then belongs to the user.
  • How: modules/desktop.nix — wdisplays → nwg-displays, kanshi dropped from environment.systemPackages, new niriUserConfig in the let block holding include "/etc/niri/config.kdl", seeded through the existing systemd.user.tmpfiles.rules block with C so later edits survive. The stale comment about the undeployed kanshi config is gone from environment.etc, the paragraph in README.md rewritten. modules/services.nix — systemd.user.services.kanshi removed and wlsunset's after reduced to graphical-session.target. Verified: niri 26.04 does load include (an include pointing at a deliberately broken file makes niri validate fail on that file, so it is parsed, not ignored); nwg-displays 0.4.4 logs Running on niri and sees both outputs; without ~/.config/niri/ it exits 0 with no window, and it creates monitor.kdl but no config.kdl — which is what the seed is for.

2026-09-07 – A timer reports the age of the nixpkgs lock

  • Who: Dominik, ClaudeCode
  • Why: nixpkgs carries no patch backports, it bumps versions, so the age of flake.lock is the exposure to known holes. Rebuilding often hides that: nixos-rebuild switch against an unchanged lock installs nothing new, so a machine built daily can still run months-old packages. The date in the lock is the signal, not the date of the last build.
  • Tradeoffs: system.autoUpgrade would drop the manual step, but nixpkgs tracks unstable and an update can fail to build — the rebuild belongs in front of someone. A CVE scanner (vulnix, vulnxscan) was weighed and dropped: on a single desktop it reports what the lock date already says, and the update, not the report, is what closes a hole. The notification carries an "Update now" action: notify-send -A implies --wait, so the process lives while the toast stands, and nothing has to bound that — the notification is on screen for exactly as long, and a second one a day later would repeat itself. Text alone was tried first and discarded: a command in a toast can be neither clicked nor copied, only retyped. The terminal comes from xdg-terminal-exec rather than a named emulator, so switching terminals is one entry in xdg-terminals.list instead of an edit here.
  • How: modules/services.nix builds moonix-flake-age with writeShellApplication and drives it from a user timer, OnStartupSec=5min and OnUnitActiveSec=1d — relative to the user manager rather than the calendar, so a machine that is off at midnight does not report into a session nobody sees. The flake is found through the /etc/nixos/flake.nix symlink, so a machine built straight from the remote reference has no lock and exits silently. .nodes[.nodes.root.inputs.nixpkgs] resolves the input indirection: the node is named nixpkgs_2 as soon as a dependency locks a nixpkgs of its own, and reading .nodes.nixpkgs would report the date of a transitive input.

2026-08-24 – power-profiles-daemon replaces auto-cpufreq

  • Who: Dominik, ClaudeCode
  • Why: auto-cpufreq arrived a day earlier as the backend of the quickshell CpuGov widget, not because it fits the hardware. Upstream calls it a laptop tool and points AMD users at amd_pstate.enable=0, while the kernel documents that amd-pstate in active mode ignores the requested performance level and reads only the minimum, maximum and EPP registers — so the governor auto-cpufreq switches decides nothing.
  • Tradeoffs: ppd writes those registers (ppd-driver-amd-pstate.c) and the ACPI platform_profile, and its polkit policy allows the active session to switch profiles without pkexec. Given up is the load-dependent automatic: ppd has three fixed profiles and leaves reacting to the firmware. The module lives in modules/services.nix, so the testvm gets it too — there ppd finds no driver and falls back to ppd-driver-placeholder.c, which touches no hardware, whereas auto-cpufreq was a daemon polling sysfs that a virtio guest does not have. The NixOS module asserts against auto-cpufreq, so a leftover elsewhere fails evaluation rather than running two daemons at one knob.
  • How: services.power-profiles-daemon.enable in modules/services.nix. The widget itself lives in the moonarch tree this flake deploys from and switched with it.

2026-08-20 – Input denoising was deployed to a path PipeWire never reads

  • Who: Dominik, ClaudeCode
  • Why: The Arch machine runs RNNoise input denoising, and the config for it lives in the shared tree as defaults/xdg/pipewire/pipewire.conf.d/99-input-denoising.conf. It was in modules/desktop.nix too, in the generic sweep to /etc/xdg/ — and inert there. PipeWire resolves its config directory from $PIPEWIRE_CONFIG_DIR, then $XDG_CONFIG_HOME/pipewire or $HOME/.config/pipewire, then the compiled-in /etc/pipewire; XDG_CONFIG_DIRS appears nowhere in src/pipewire/conf.c. On Arch the file works because post-install.sh seeds it into the home directory. Same class as the swaylock config on 2026-08-18: a file installed to a plausible path that the program does not read.
  • Tradeoffs: (1) Which option — the module accepts both services.pipewire.extraConfig.pipewire.<name> (Nix attributes, rendered to JSON) and services.pipewire.configPackages (a package carrying share/pipewire/pipewire.conf.d/*.conf); both end up in the same merged directory (pipewire.nix:59-75). The NixOS wiki uses extraConfig for its examples, which are written by hand and have no file behind them. Taking that route here would mean translating the shared file into Nix and maintaining the VAD values in two places, so configPackages was chosen — which is also what the module's own option documentation shows for a conf.d file (pipewire.nix:258-279). (2) The plugin path — the file names the plugin as librnnoise_ladspa, resolved through LADSPA_PATH, which is /usr/lib/ladspa on Arch and does not exist here. There is no LADSPA counterpart to services.pipewire.extraLv2Packages, so the absolute store path is substituted into the file, the same treatment the wallpaper paths get.
  • How: denoisingConfig via runCommand + substitute, handed to services.pipewire.configPackages. The xdg/pipewire/ entry in environment.etc is removed rather than kept for layout parity — after the swaylock case, an inert copy is a liability. Verified by building the package: the conf.d file is there, the plugin line points at rnnoise-plugin-1.10/lib/ladspa/librnnoise_ladspa.so, and that file exists.
  • Note: the filter chain shows up as a separate "Noise Canceling source" input. It does not replace the raw microphone; applications have to select it.

2026-08-20 – Steam, Bitwig and rtkit belong to the desktop alone

  • Who: Dominik, ClaudeCode
  • Why: Both programs are wanted on the desktop and on no other machine, so they go into hosts/desktop/default.nix rather than the shared module every host imports.
  • Tradeoffs: (1) Steam — pkgs.steam in systemPackages is the wrong handle: the module wraps the binary in an FHS environment and turns on hardware.graphics.enable32Bit itself (nixos/modules/programs/steam.nix:215-218), which the bare package would leave off. (2) Bitwig — the bitwig-studio attribute currently resolves to bitwig-studio6-6.0.11; bitwig-studio5 (5.3.13) exists as a separate attribute if a downgrade is ever needed. Dominik chose 6. (3) Realtime — security.rtkit.enable was off. The rlimits the PipeWire module installs (rtprio 95, nice -19, memlock) apply to members of the pipewire group, and the account is in networkmanager wheel video input plugdev docker only, so a DAW had no path to realtime scheduling at all. Enabled on this host, where the DAW is.
  • How: programs.steam.enable, security.rtkit.enable and environment.systemPackages = [ pkgs.bitwig-studio ] in the host file. Verified by evaluating both hosts: on desktop all three are set and 32-bit graphics came along; on thinkpad Steam and rtkit stay false.

2026-08-20 – The browser is LibreWolf from nixpkgs, not a self-packaged Waterfox

  • Who: Dominik, ClaudeCode
  • Why: A rebuilt desktop had no browser at all — nothing in the package set provided one, while Arch installs waterfox-bin. nixpkgs has no waterfox attribute (checked against the pinned revision, with firefox resolving in the same package set as a control), so matching Arch meant packaging it here.
  • Tradeoffs: (1) Waterfox from elsewhere — the one community flake, sammypanda/nixos-waterfox, is a single commit whose README says "Currently broken?". Flathub's net.waterfox.waterfox tracks releases itself but carries no verified badge (the listing reads "Medium Risk"), needs the Flatpak stack, and a NixOS Discourse thread reports crashes and system freezes with that exact package. Both rejected. (2) Waterfox packaged here — this was built and it worked: the upstream tarball plus autoPatchelfHook, waterfox --version reporting 6.6.17, no missing library, and a SHA-512 identical to the one the Arch package pins. It was then discarded on the maintenance argument: version and hash are bumped by hand, nix flake update does not move a plain tarball, and nothing signals a missed release. For stasis or sweet-cursors that is irrelevant; for the most exposed program on the machine it means silently running a browser with known holes. A version-check script was offered as a mitigation and not taken. (3) What replaces it — librewolf is in nixpkgs, so its bumps arrive with nix flake update, and it is fully substitutable: 179.6 MiB fetched, nothing built locally (librewolf-bin would build six derivations and was not taken). Firefox was the other candidate; LibreWolf is closer to what Waterfox is for. The price is stricter defaults — resistFingerprinting, clearing on shutdown, restricted WebGL — which have to be relaxed by hand where they get in the way.
  • How: librewolf in environment.systemPackages in modules/desktop.nix. pkgs/waterfox.nix is deleted rather than left lying around unused. The browser therefore differs from Arch, which keeps waterfox-bin.

2026-08-20 – Printing is driverless, VPN gets its plugin, VLC replaces mpv

  • Who: Dominik, ClaudeCode
  • Why: Comparing the package set against the Arch lists turned up gaps that were never decided, only missing: no media player, no VPN plugin, no printing. networking.networkmanager.plugins was at its default [ ], which means NetworkManager cannot create an OpenVPN connection at all — the type is not offered by nmcli, nmtui or nm-applet — while Arch installs networkmanager-openvpn. Printing was absent on both sides.
  • Tradeoffs: (1) Printing — the Arch host drives the Samsung M267x 287x the old way: a socket://192.168.1.165 queue with the ULD PPD. Reproducing that here would have meant an unfree driver package plus the printer's address in version control, and Dominik ruled the printer out of the repository. Asked over IPP, the device answers with image/urf and application/PCLm among its document formats and IPP 2.0 — so driverless printing works and CUPS builds the queue from what the printer reports about itself. hardware.printers. ensurePrinters and samsung-unified-linux-driver (which does carry a matching M267x.ppd) are therefore both unused. The price is a dependency on mDNS discovery, and Avahi has to run for it; the gain is that any AirPrint-capable printer works, not just this one. (2) Media player — mpv is what Arch installs, with two AUR scripts; Dominik chose VLC instead, so this is deliberately not an alignment. (3) Where it lives — modules/desktop.nix, which every host imports (flake.nix:95), rather than per host: none of it is machine specific.
  • How: services.printing.enable, services.avahi with nssmdns4 (the part that makes .local resolve) and openFirewall, which opens UDP 5353. networking.networkmanager.plugins = [ pkgs.networkmanager-openvpn ] plus the openvpn binary, and vlc, in environment.systemPackages. Verified by evaluating both desktop and thinkpad: printing and Avahi on, 5353 in allowedUDPPorts, the plugin and both packages present.
  • Still missing against the Arch lists: no browser at all — Arch installs waterfox-bin, which nixpkgs does not have. Also absent: viewnior, blueberry, auto-cpufreq, snapper/snap-pac, plocate, nmap, sshfs, smbclient and walker with the elephant providers. ufw is deliberately not missing: NixOS' own firewall is enabled on every host with no port open.

2026-08-20 – The desktop declares its real disks

  • Who: Dominik, ClaudeCode
  • Why: hosts/desktop still carried the two SET-VIA-disko-install--disk-* placeholders from the entry below, so the file did not say which disks the machine has. The ThinkPad names its disk, and nothing about the desktop's two is secret: read off the machine, the system disk is the 512G nvme-XPG_GAMMIX_S11_Pro_2K322LAES7JG and /home goes on the 1T nvme-CT1000P1SSD8_2030E2BAC109.
  • Tradeoffs: The invalid paths were a safety net — a forgotten --disk flag failed the install instead of erasing something. That net only ever caught the case of a missing flag, and it paid for it by making a rebuild of an installed desktop impossible to evaluate honestly and by leaving the hardware undocumented. With correct paths a forgotten flag installs to the intended disk, which is the outcome the flag would have asked for anyway. A wrong flag was never caught by either variant.
  • How: The paths in hosts/desktop/default.nix, with the comment now saying where they came from and that disko-install --disk main|home still overrides them. Note that the 1T currently holds an ext4 Data partition — installing erases it. README's "Adding another machine" step 4 no longer advertises the placeholders.

2026-08-20 – A placeholder ~/.zshrc stops the zsh new-user wizard

  • Who: Dominik, ClaudeCode
  • Why: zsh-newuser-install came up on every interactive login on the test VM. Per zsh's own documentation the zsh/newuser module runs it for as long as the user has none of .zshenv, .zprofile, .zshrc or .zlogin in $HOME/$ZDOTDIR (zshall, "New User Installation", and the function description in zshcontrib); it exits at once for root, which is why only the user account saw it. Here the whole configuration is /etc/zshrc, which is not a personal file, so the condition never cleared. Independent of the ordering fixes below — the trigger is the set of files in the home directory, not the content of /etc/zshrc. The NixOS programs.zsh module does not cover the case.
  • Tradeoffs: (1) Which side — disabling the zsh/newuser module system-wide would need no home state, but the documented off switch is on the administrator's zsh build, not a NixOS option, and it would also take the wizard away from a user who wants it. Seeding the file uses the mechanism zsh itself names and reuses the systemd.user.tmpfiles seed already in the module for waypaper and swaylock. (2) What is in the file — on Arch this file reads source /etc/zsh/zshrc.moonarch, and copying that here would source the shared zshrc twice, once from /etc/zshrc and once from the home file: duplicate aliases, a second compinit, the plugins loaded twice and $HOME/.local/bin prepended to PATH twice. The seeded file therefore carries comments only. It doubles as the documented place for the user's own additions, which zsh reads after /etc/zshrc.
  • How: modules/desktop.nix — userZshrc via pkgs.writeText, seeded as C %h/.zshrc 0644 - - - ${userZshrc} next to the two existing rules. C copies once and never overwrites, so later edits to the file survive a rebuild. Verified by evaluating systemd.user.tmpfiles.rules for the testvm host.

2026-08-20 – The sourced zshrc is defended against the /etc/zshrc defaults

  • Who: Dominik, ClaudeCode
  • Why: programs.zsh.interactiveShellInit sources the shared defaults/shell/zshrc, but the shell on the test VM did not look like the one on Arch. The file is not at fault — the pinned moonarch input is the same revision as the working tree and the file is byte-identical. The generated /etc/zshrc is: nixos/modules/programs/zsh/zsh.nix places interactiveShellInit at line 277, then environment.shellAliases at 285, then promptInit at 288. Three defaults therefore land after our file and win. (1) promptInit defaults to autoload -U promptinit && promptinit && prompt suse, and prompt suse assigns PS1 — the same parameter as PROMPT — so the Catppuccin prompt was replaced by the SUSE one. (2) environment.shellAliases carries ls, ll and l as mkDefault (nixos/modules/config/shells-environment.nix:235), shadowing the eza aliases. (3) Unrelated to ordering: the zshrc sources zsh-syntax-highlighting and zsh-autosuggestions from /usr/share/zsh/plugins, the path of the Arch packages. The lookups are [[ -f ]]-guarded, so both plugins were silently absent.
  • Tradeoffs: (1) Where the prompt is owned — moving the prompt into promptInit would place it in the slot NixOS intends for it, at the price of splitting one shared file across two options and diverging from Arch. Setting promptInit = "" keeps the zshrc the single owner. (2) Alias removal — redefining ls/ll/l in Nix would duplicate the eza commands in a second place, where they can drift from the zshrc. null removes the alias (zsh.nix:20 filters null values out) and leaves the definition in one file. Scoped to programs.zsh.shellAliases rather than environment.shellAliases, so bash keeps the coreutils ls — it has no eza aliases to fall back on. (3) Plugins — a /usr/share/zsh/plugins symlink farm via tmpfiles would make the existing lookups succeed and need no Nix-side option, but it rebuilds an FHS path for a file that the NixOS modules already know how to source. The modules were chosen, as with the polkit exception the other way around (2026-08-07).
  • How: modules/desktop.nix — programs.zsh.promptInit = "", programs.zsh.shellAliases with ls/ll/l set to null, programs.zsh.syntaxHighlighting.enable and programs.zsh.autosuggestions.enable with strategy = [ "history" "completion" ], the value the zshrc sets on Arch. Verified by evaluating environment.etc.zshrc.text for the testvm host before and after: the prompt and alias blocks are now empty, and both plugins are sourced from the store.
  • Remaining exception: the zshrc's orphans and uninstall aliases call pacman. They are dead on NixOS but harmless, and rewriting them per distro would fork the shared file.
  • Not changed: compinit runs twice — once from enableGlobalCompInit (zsh.nix:266), once in the zshrc. Duplicated work, no defect, left alone.

2026-08-20 – Installing from an own ISO, one command

  • Who: Maintainer, ClaudeCode
  • Why: The documented install was a five-line nix run invocation typed at the guest console, reading the configuration from Gitea — so only pushed states were installable, and a typo in a long command cost the whole run.
  • Tradeoffs: nixos-anywhere and an SSH-driven install were ruled out: the machine is installed in front of it, not over the network. A script or flake app in the repository still has to be fetched before it can run, which is the problem it was meant to solve. Baking the target closure into the image would make the install work offline, at four to six gigabytes per rebuild — not taken.
  • How: hosts/installer builds an ISO (nix build .#installer-iso) that carries this flake as /etc/moonix, disko and one wrapper. The guest runs moonix-install <host>: partition and format per the host file, install, then clone the repository into the account's ~/flakes/moonix, hand it to the account and point /etc/nixos/flake.nix at it. The image is built from the local tree, so unpushed changes are installable. Network is still needed — the packages come from the binary cache.
  • Also: disko aborts the whole format script when the two passphrase entries differ, because exit 1 sits inside a function called from the until loop meant to repeat the prompt (share/disko/lib/types/luks.nix:151). The installer uses a disko patched to return 1 there, so a typo asks again instead of restarting the install.

2026-08-20 – The checkout on a machine lives in ~/flakes/moonix

  • Who: Maintainer, ClaudeCode
  • Why: disko-install leaves no checkout behind, and the README documented one that was invented rather than derived: a clone in the home root plus a symlink, justified with reasons that did not hold. The configuration has to be editable and committable on the machine it describes, with the account's identity on the commits and the account's key on the push.
  • Tradeoffs: /etc/nixos is nixos-rebuild's default path, but a repository there belongs to root: commits would be root's and pushing would need root's key. Handing the directory to the account with install -d -o fixes that by making a system directory user-writable, which is a workaround rather than a layout. /opt/moonix would mirror moonarch, which archinstall clones to /opt/moonarch, but that copy is root-owned and never committed from — these files are. ~/Repos is the scratch area for development checkouts on the workstation, not a permanent location. ~/.config/flakes is configuration space for programs reading it on this machine; the repository is not read as configuration, it is the source a system is built from.
  • How: The checkout is ~/flakes/moonix, owned by the account, and /etc/nixos/flake.nix is a symlink to its flake.nix. man 8 nixos-rebuild documents exactly this: the file "may be a symlink to a flake.nix in an actual flake; thus /etc/nixos need not be a flake", and --flake "defaults to the directory containing the target of the symlink". sudo nixos-rebuild switch therefore needs no arguments and picks the host by hostname. The flakes directory is plural on purpose: moonarch leaving its flake = false state, dev flakes for the compiled projects and devShells would live beside it. A machine that is never edited locally keeps no checkout and builds from the Gitea reference instead, which is what disko-install did.

2026-08-20 – Desktop host, /home on a second disk

  • Who: Maintainer, ClaudeCode
  • Why: The desktop has a 512G system disk and a 1T disk, and /home belongs on the large one. modules/disk.nix only knew a single disk, so the layout had nowhere to put the second.
  • Tradeoffs: A single btrfs spanning both disks would be one filesystem and one LUKS container, but it ties the two together: losing either loses the pool, and the system disk stops being restorable on its own. Mounting the 1T somewhere under /mnt and leaving /home small keeps the module untouched, at the price of configs and data living apart and snapper needing two configs with different meanings. A second disk carrying /home as its own btrfs keeps both disks independent and leaves the existing subvolume names alone.
  • How: moonarch.disk.home.enable adds a second GPT with one partition, wrapped in LUKS crypted-home when encrypt is set, holding the /home subvolume. The main disk drops /home in that case, so the subvolume exists exactly once. No second ESP: the firmware boots from the main disk. Both disks want the same passphrase — systemd's initrd caches the first one and retries it on the second container, so the boot prompts once even though formatting asks four times. hosts/desktop declares deliberately invalid device paths, because disko-install --disk main|home overrides them and a forgotten flag should fail rather than erase the wrong disk. Without a nixos-hardware profile the host sets enableRedistributableFirmware and hardware.cpu.amd.updateMicrocode itself, and forces services.fwupd off — desktop mainboard vendors mostly do not publish to the LVFS.

2026-08-20 – ThinkPad: redistributable firmware and the fingerprint reader

  • Who: Maintainer, ClaudeCode
  • Why: hardware.enableRedistributableFirmware was never set. The nixos-hardware profile does not set it either — common/cpu/amd reads it (hardware.cpu.amd.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware), so with the option off the machine ran on the microcode in its BIOS and without the firmware the WLAN card and amdgpu load.
  • Tradeoffs: The firmware is redistributable but not free, so enabling it is a deliberate step rather than a default. Declining it means no microcode updates on a CPU whose errata are fixed exactly there.
  • How: hardware.enableRedistributableFirmware = true in the host file, which the profile's mkDefault then turns into a microcode update. services.fprintd.enable alongside it — the NixOS module wires pam_fprintd into the PAM stacks it owns; a finger is enrolled with fprintd-enroll after installing.

2026-08-19 – Initial password in the host file, account name as an option

  • Who: Maintainer, ClaudeCode
  • Why: An install that leaves the account without a password produces a system whose lock screen never opens — swaylock authenticates through pam_unix. The documented remedy, nixos-enter --root /mnt -c passwd, is the path that fails after disko-install with chmod: changing permission of '/mnt/dev': Operation not permitted (disko issue #638, closed as not planned). Alongside that, the host files carried a real name in five places.
  • Tradeoffs: initialPassword puts a plaintext password in the repository. Weighed against hashedPasswordFile, which keeps the secret out of the repo but needs the file on the target disk before the first boot — either through disko-install --extra-files or a cp into the still-mounted /mnt. Both are extra steps in exchange for protecting a value that is meant to be replaced on first login anyway. The 2026-08-11 decision to keep any password out of the repository is therefore reversed for the initial one.
  • How: initialPassword = "moonarch" in both hosts, mutableUsers left at its default so passwd overrides it permanently. New modules/user.nix declares moonarch.user without a default, because the name belongs to the machine; users.users, the autologin initial_session.user and snapper's ALLOW_USERS all read it, so the login name exists once per host instead of five times. README loses the nixos-enter step and the stale "Known gaps" section — Super+C reaches the Quickshell clipboard popout, which is wired up and whose cliphist backend is installed.

2026-08-14 – moonset and moonlock dropped; the power menu is a Quickshell popout

  • Who: Maintainer, ClaudeCode
  • Why: The power menu became PowerPopout.qml in the moonarch Quickshell config (see moonarch/DECISIONS.md, same date), and moonset was the last thing invoking moonlock. Both packages therefore leave this flake with nothing to replace them: the popout ships inside the config tree that is already deployed.
  • Tradeoffs: The flake keeps the ability to package a moonarch program — stasis and sweet-cursors still use that path — so nothing was generalised away. What is lost is the pinning discipline for two programs that no longer exist here; the tags in flake.nix were the only record of which version was deployed, and that record now lives in the retired repos' own history.
  • How: Inputs moonlock and moonset removed, moonarchPackages entries with them, pkgs/moonlock.nix and pkgs/moonset.nix deleted, flake.lock regenerated. modules/desktop.nix drops both packages from systemPackages, the generated environment.etc."moonlock/moonlock.toml" and "moonset/moonset.toml", and the moonlock PAM stack — swaylock's comes from programs.niri.enable.

2026-08-14 – regreet and swaylock from nixpkgs, autologin after LUKS

  • Who: Maintainer, ClaudeCode
  • Why: moongreet and moonlock were dropped as self-maintained projects; the reasoning is in moonarch/DECISIONS.md for the same date. Here the change is mostly subtraction: two flake inputs, two build recipes and a generated config disappear in favour of a nixpkgs module and a nixpkgs package. Since LUKS already asks for a passphrase at boot, the greeter was also asking for a second one for no gain, so autologin replaces it on both hosts.
  • Tradeoffs: (1) Greeter host — the regreet module sets services.greetd.settings.default_session.command only as mkDefault (dbus-run-session cage -- regreet), so the existing unconditional niri line keeps winning. Taking cage would have meant one Wayland compositor fewer in the greeter path, but niri-greeter.kdl is where layout "de" and numlock live, and nothing in nixpkgs 26.11 exports XKB_DEFAULT_LAYOUT from services.xserver.xkb.layout — verified. A greeter on a US layout rejects every password with an affected character, so niri stays and Arch and NixOS keep sharing that one file. (2) regreet.toml — the module writes /etc/greetd/regreet.toml itself, so the committed file from defaults/etc/greetd/ is deliberately not deployed verbatim; declaring the same path in environment.etc would collide. The values are duplicated into module options, which is the price for the wallpaper store path. (3) swaylock config — nixpkgs passes only --prefix to meson, so swaylock's SYSCONFDIR sits inside the store and /etc/swaylock/config is never read. The config is therefore seeded into $HOME with the same C tmpfiles rule waypaper uses, which also means later user edits survive but updates do not propagate. (4) Colloid overrides — desktop and greeter need the same derivation, and two module files cannot share a let. Moved into flake.nix and passed as _module.args.moonarchThemes rather than duplicating the override, which would drift.
  • How: moongreet input, moonarchPackages.moongreet and pkgs/moongreet.nix removed. modules/greetd.nix now configures services.displayManager.regreet (background, GTK, commands, theme, icon theme, cursor theme, font, extraCss pointing at the CSS in the moonarch input) and keeps the niri default_session. modules/desktop.nix gains swaylock, the seeded ~/.config/swaylock/config and — moved out of greetd.nix, next to the package it belongs to — the moonlock PAM stack. initial_session is set per host, because the accounts are named differently.
  • Remaining exception: moonlock stays packaged and installed. moonset/src/power.rs spawns it by name and has no lock_command option, so its Lock action would otherwise fail. Idle and lid-close already go through swaylock. Two lockers coexist until moonset is released with that call changed; then the input, the recipe, the PAM stack and environment.etc."moonlock/moonlock.toml" all go.
  • Consequence: With autologin, the greeter no longer forces an account to have a password, but swaylock still needs one — passwd after installing stays mandatory, and hosts/testvm has no password declared at all.

2026-08-11 – Disk layout via disko after all, as a shared module

  • Who: Maintainer, ClaudeCode
  • Why: The Calamares route decided below turned out to be six manual steps, one of which — copying hardware-configuration.nix into the repo — fails silently unless the file is also git added, because Nix only sees tracked files in a git repository. It also does not scale: there will be more machines than this one, and each would repeat the whole procedure.
  • Tradeoffs: The argument against disko was that no target machine existed and device paths would have to be guessed. That has expired. Keeping Calamares would avoid writing a layout module, but leaves partitioning, encryption and subvolumes outside version control, where they cannot be reviewed or repeated.
  • How: modules/disk.nix defines moonarch.disk.{enable,device,encrypt, espSize,swapSize} and builds disko.devices from it, so a new machine is a device path rather than a new layout. Installation is a single disko-install --flake … --disk main <device> from the ISO. hardware-configuration.nix is not needed: nvme, ahci, sd_mod and the USB HID modules are in the initrd by default via boot.initrd.includeDefaultModules, and nixpkgs.hostPlatform comes from nixosSystem. hosts/testvm uses the same module against /dev/vda; its generated file was deleted, having described the UUIDs of a qcow2 that no longer exists.
  • Consequence: The user password no longer arrives with the installer. users.users.<name> declares none, so it is set once via nixos-enter after installing rather than committing a hash to the repository.

2026-08-11 – Swapfile without hibernation

  • Who: ClaudeCode, on the maintainer's request for a recommendation
  • Why: The layout module needs a swap default.
  • Tradeoffs: Hibernation needs swap at least the size of RAM plus a resume_offset kernel parameter. On btrfs that offset changes whenever the swapfile is recreated, which is a standing source of breakage. Avoiding it means LUKS → LVM → btrfs LV plus a swap LV, one layer more. A swap partition cannot be resized later without repartitioning; a swapfile can.
  • How: 8G swapfile in a /.swapvol subvolume, no hibernation, no LVM. Sized as a safety valve against memory pressure, not as a replacement for the 30 GiB of RAM. Revisit by growing the swapfile and adding resume_offset — neither needs a reinstall.

2026-08-07 – Separate repo instead of Nix files in the application repos

  • Who: Maintainer, ClaudeCode
  • Why: The Nix build recipes had to live somewhere without disturbing the existing repos.
  • Tradeoffs: Files inside each project repo would keep version, code and build recipe in one commit — no drift between PKGBUILD pkgver and flake input. A separate repo keeps the Arch repos untouched, at the cost of one extra commit per version bump (tag in flake.nix, then nix flake update).
  • How: New repo moonix, consuming the application repos as flake inputs pinned to release tags.

2026-08-07 – Configs deployed as files, not translated to Nix options

  • Who: Maintainer
  • Why: One set of config files should stay valid on NixOS and Arch alike.
  • Tradeoffs: Native Nix options (programs.niri.settings) are idiomatic and type checked, but every config would exist twice and drift on each change.
  • How: environment.etc.<path>.source pointing into the moonarch flake input. Only files containing FHS paths are generated instead of copied.

2026-08-07 – No Home Manager

  • Who: Maintainer
  • Why: Initially proposed for the three home-directory tasks in post-install.sh (GTK4 symlinks, gsettings, seeding the stasis config).
  • Tradeoffs: None in favour, as it turned out. Every one of those tasks is equally doable with ln, gsettings and install; the argument for Home Manager was convention, not need. Two of the three cases disappeared entirely: dconf defaults can be set system-wide via programs.dconf.profiles.user.databases, and stasis reads /etc/stasis/stasis.rune as a documented fallback.
  • How: Rejected. For files that genuinely have no system-wide fallback, systemd.user.tmpfiles with the C directive seeds a copy into $HOME without overwriting later edits.
  • Who: Maintainer
  • Why: moonlock, moongreet and moonset invoked systemctl, loginctl and moonlock through absolute /usr/bin paths, and moongreet scanned /usr/share/{wayland-,x}sessions. All of it fails on NixOS.
  • Tradeoffs: systemd.tmpfiles symlinks would have avoided touching the code, but they recreate an FHS layout under NixOS and only cover the paths known at the time — a new one surfaces at runtime, when the user presses the button.
  • How: Programs resolve via PATH, session directories walk XDG_DATA_DIRS with /usr/share as fallback. Behaviour on Arch is unchanged. Released as moonlock 0.6.21, moongreet 0.10.2, moonset 0.9.2.
  • Remaining exception: the polkit agent is spawned by its FHS path from defaults/xdg/niri/config.kdl, which is shared with Arch, where the binary lives outside PATH. That one stays a symlink.

2026-08-07 – waybar and swaync not installed

  • Who: Maintainer
  • Why: Quickshell provides both the bar and the notifications. The config source still carries waybar and swaync, marked "kept as a reserve" — a note left over from an earlier migration.
  • Tradeoffs: Installing them would mirror the config source exactly, at the price of carrying two unused implementations.
  • How: Neither package, config nor the waybar-only helper scripts are installed. That also retires moonarch-cpugov, moonarch-sink-switcher and moonarch-nightlight: the waybar config was their only caller, and the Quickshell popouts cover the same ground.

2026-08-07 – QEMU/KVM as the test host, with a caveat

  • Who: Maintainer, ClaudeCode
  • Why: VirtualBox was the first choice, but its 3D acceleration is documented as broken with Wayland compositors.
  • Tradeoffs: QEMU needs no DKMS module on linux-zen and virtio-gpu carries Wayland. However, passing the guest through to the host GPU triggered an amdgpu hard recovery twice, killing the QEMU process. Software rendering is not an alternative: without a 3D capable virtio GPU, niri's TTY backend fails every buffer import with Error::DeviceMissing and never creates an output.
  • How: virtio-vga-gl with gl=on, and the VM is shut down when not in use. The underlying amdgpu problem is a host issue, unrelated to Moonarch.

2026-08-07 – Disk layout via Calamares, not disko

  • Who: Maintainer
  • Why: Stage 3 targets bare metal, but no target machine exists yet — neither the number of disks nor their device paths are known.
  • Tradeoffs: disko would put partitioning and encryption under version control, which pays off when reinstalling often or setting up several machines alike. For a single machine it is extra work without return, and it would require guessing device paths. Calamares already covers partitioning, LUKS and hardware detection, and produces hardware-configuration.nix — the one file that cannot be written in advance.
  • How: hosts/moonarch/ holds only machine independent settings. Installation runs through Calamares, then the generated hardware file is copied into the host directory. See README.md.