From a9f3a642ee83bbfdd38f9ca8f9099acd76c4a70a Mon Sep 17 00:00:00 2001 From: Bernardo Magri Date: Tue, 14 Jul 2026 16:03:12 +0100 Subject: [PATCH] =?UTF-8?q?fix(nixos):=20two=20toggles=20that=20reported?= =?UTF-8?q?=20success=20and=20did=20nothing=20=E2=80=94=20wire=20the=20sta?= =?UTF-8?q?te=20bridges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BACKLOG #116. `nomarchy.settings` is declared in exactly one place — modules/home/options.nix:412, the Home Manager side. On NixOS the attribute does not exist, and `or ` swallows the missing-attribute error, so four options that "defaulted from the state" had silently been their fallback on every machine ever built. The item said three options, and called them benign. Both halves were wrong, and re-grepping rather than trusting the account is what found it: * There were four. The original enumeration read options.nix instead of modules/nixos/ and missed services.nix's printing.enable — the same mistake in miniature as the bug it was filing. * Two were live user-facing bugs. Control Center is shipped (default.nix:337) and reachable from the menu; its Bluetooth and Printing toggles wrote settings.{bluetooth,printing}.enable and printed "requires rebuild", and the rebuild changed nothing. They had never worked. The fix is one shape, now uniform: the option declares a STATIC default, and the implementing module reads the state via theme-state-read.nix (fails closed on bad JSON, unlike greeter.nix's raw fromJSON — also moved onto the reader here) and mkDefaults it behind `mkIf (state != null)`. An absent key leaves the option default as the single source of the fallback; a hand-set system.nix value still pins it. batteryChargeLimit gets no bridge and loses its dead read: power.nix's oneshot already reads that key with jq at RUNTIME and prefers it over the baked value, which is why that menu worked all along. V2. The bug is proved real before/after on the same flipped state: BEFORE, bluetooth stays true and printing stays false; AFTER, both flip, and a hand-set value still outranks the state. Nothing in a build fails when a bridge dies, so the guards are the point — checks.state-bridges asserts 11 eval cases, and checks.printing-from-state boots a VM whose only input is the state file and waits for a running cups.service. The guard was itself proved to fail: re-breaking the bluetooth bridge makes it throw, naming both assertions. A check that passes whether or not the property holds is worse than no check (625b7e3). flake check, option-docs, template-sot, downstream-template-*, installer-safety, hardware-toggles and battery-charge-limit all pass. No V3: the mechanism is fully proved headlessly. Design record in ROADMAP § NixOS-side state bridges (#116); new #117 (PROPOSED) for the control-center toggles still leaving the rebuild to the user. Co-Authored-By: Claude Opus 4.8 --- agent/BACKLOG.md | 63 ++++++++-------------- agent/JOURNAL.md | 38 +++++++++++++ docs/ROADMAP.md | 31 +++++++++++ flake.nix | 106 +++++++++++++++++++++++++++++++++++++ modules/nixos/default.nix | 20 +++++++ modules/nixos/greeter.nix | 5 +- modules/nixos/options.nix | 20 ++++++- modules/nixos/services.nix | 31 ++++++++++- 8 files changed, 268 insertions(+), 46 deletions(-) diff --git a/agent/BACKLOG.md b/agent/BACKLOG.md index 0b2f03a..efb6110 100644 --- a/agent/BACKLOG.md +++ b/agent/BACKLOG.md @@ -118,52 +118,13 @@ already answers with `${onAc} ||`. In-flake state like the rest (`settings.*`), menu-writable, and read via `theme-state-read.nix` on the NixOS side — NOT `config.nomarchy.settings`, -which does not exist there (see #116). Pass = the toggle survives a rebuild, +which does not exist there (ROADMAP § *NixOS-side state bridges (#116)*; copy +the `bluetooth.enable` shape in `modules/nixos/default.nix` and add a case to +`checks.state-bridges`). Pass = the toggle survives a rebuild, a suspended machine hibernates after the configured delay and resumes, and a machine without hibernate support hides the row instead of offering a suspend that never wakes. -### 116. Four NixOS options read a `config.nomarchy.settings` that does not exist — two are live broken toggles - -Found while wiring System › Auto-login (2026-07-14); **enumeration corrected -2026-07-14** — the original said "three options … benign today", and both -halves were wrong (see the Bluetooth/Printing rows). - -`nomarchy.settings` is declared in exactly ONE place — -`modules/home/options.nix:412`, the Home Manager side, filled by `theme.nix`. -On the NixOS side the attribute does not exist at all, and `or ` -swallows the missing-attribute error, so every read below has silently always -been its fallback on every machine ever built: - -- `modules/nixos/greeter.nix` `system.greeter.autoLogin` — **fixed** in - eb38008 (reads the state via `theme-state-read.nix`, the working pattern - from `hardware.nix`/`timezone.nix`). The worked example; copy this shape. -- `modules/nixos/options.nix` `bluetooth.enable` — **live bug, not benign.** - The TUI control center (`nomarchy-control-center.sh:192`, shipped in - `modules/nixos/default.nix:337`, reachable from the rofi menu's "Control - Center" row) has a Bluetooth toggle that writes `settings.bluetooth.enable` - and prints "Bluetooth disabled (requires rebuild)". Nothing reads that key, - so the fallback `true` wins and Bluetooth is still on after the rebuild. - The toggle reports success and does nothing. -- `modules/nixos/services.nix:80` `printing.enable` — **live bug, same shape** - (`nomarchy-control-center.sh:203`). Missed by the original enumeration - entirely, which only looked at `options.nix`. -- `modules/nixos/options.nix:112` `power.batteryChargeLimit` — never read, but - the Battery limit menu works anyway because it patches the baked option in - `system.nix` instead (`modules/home/rofi.nix:342`), the older model. Dead - read, working feature. - -Decide per option whether the state key should work (wire it like the greeter) -or the phantom read should just go — but the two control-center toggles need -one or the other, since today they lie to the user. The trap is that `or` -makes a live bridge and a dead one look identical, which is exactly how the -first pass undercounted: **grep the whole of `modules/nixos/`, not -`options.nix`.** Pass = `grep -rn 'config\.nomarchy\.settings' modules/nixos/` -returns nothing but comments, no NixOS option claims a state default it cannot -read, any bridge kept is proved by an eval that flips with the state file, and -the control-center Bluetooth/Printing toggles are proved to actually change -the built config (or are removed). - ### 107. Rename `theme.json` to reflect that it is the system state The state file long ago stopped being about themes: it carries night-light, @@ -291,6 +252,24 @@ high-ROI, etc.) live in the journal + ROADMAP — not here.* ### Product / day-2 +### 117. Control Center says "requires rebuild" and leaves the user to do it + +Noticed while fixing #116 (2026-07-14), not fixed there — the bug was that +its toggles wrote JSON nothing read; this is that they stop one step short +even now that the JSON lands. `nomarchy-control-center.sh`'s Bluetooth, +Printing and Updates toggles set the state and print "… (requires rebuild)", +so the setting only takes effect whenever the user next thinks to run +`nomarchy-rebuild`. Every menu toggle written since does the rebuild itself +and toasts the outcome (`nomarchy-autologin`, `nomarchy-fingerprint`, +`nomarchy-autotimezone`) — the state-write-then-rebuild shape. A user who +toggles Bluetooth off and sees Bluetooth still running has no way to tell +"needs a rebuild" from "the toggle is broken again", which is precisely the +symptom #116 just removed. Cost: small — reuse the autologin shape (the TUI +is already a terminal, so a rebuild's output has somewhere to go). Worth +settling first (`[human]`): whether the TUI should rebuild per-toggle, or +offer one "apply changes" at exit, since a user flipping three toggles +should not sit through three rebuilds. + ### 114. Greeter ignores per-device keyboard layouts Found by Bernardo 2026-07-14: logging out while docked lands on tuigreet, diff --git a/agent/JOURNAL.md b/agent/JOURNAL.md index 3676e63..dac6548 100644 --- a/agent/JOURNAL.md +++ b/agent/JOURNAL.md @@ -19,6 +19,44 @@ Template: --- +## 2026-07-14 — #116: the state bridges that never were (this commit) +- **Task:** BACKLOG #116 — NixOS options defaulting from + `config.nomarchy.settings`, an attribute that exists only on the HM side. +- **Did:** `nomarchy.settings` is declared in exactly ONE place + (`modules/home/options.nix:412`), so `or ` had been swallowing a + missing-attribute error on the NixOS side forever. Options now declare a + *static* default and the implementing module reads the state via + `theme-state-read.nix` + `lib.mkIf (state != null) (lib.mkDefault state)` — + done for `bluetooth.enable` (default.nix) and `services.printing.enable` + (services.nix). `batteryChargeLimit`'s read is simply deleted: power.nix's + oneshot reads the key with `jq` at *runtime*, which is why that menu worked. + greeter.nix moved off a raw `fromJSON` onto the fail-closed reader. +- **The finding that mattered:** the item said "three options … benign today" + and both halves were wrong. There were four (it grepped `options.nix`, not + `modules/nixos/` — the same mistake in miniature as the bug it filed), and + two were **live user-facing bugs**: Control Center's Bluetooth and Printing + toggles wrote state nothing read and printed "requires rebuild", so they + reported success and did nothing, for as long as they had existed. +- **Verified:** **V2.** Before/after eval on the *same* flipped state proves + the bug was real: BEFORE `hardware.bluetooth.enable=true` + `printing=false` + (toggle inert); AFTER both flip, and a hand-set value still outranks the + state. Permanent guards, since nothing in a build fails when a bridge dies: + `checks.state-bridges` (11 eval assertions — absent key → default, state → + config, hand-set → wins) and `checks.printing-from-state`, a runNixOSTest + whose node's ONLY input is the state file and which waits for a running + `cups.service` (+ avahi). **The guard was proved to fail**: re-breaking the + bluetooth bridge made it throw, naming both broken assertions — a check that + cannot fail is worse than none (625b7e3's lesson, applied). V0 flake check; + option-docs, template-sot, downstream-template-{system,home}, + installer-safety, hardware-toggles, battery-charge-limit all green. +- **Pending:** no V3 — the mechanism is fully proved headlessly. New **#117** + (PROPOSED): the control-center toggles still leave the rebuild to the user, + unlike every menu toggle since; a user can't tell "needs a rebuild" from + "broken again", which is the symptom #116 just removed. +- **Next suggestion:** #117 (needs Bernardo's call: rebuild per-toggle vs one + apply-at-exit), or #115 suspend-then-hibernate — also `[human]`-gated, and + its state read now has a worked shape to copy. + > The five entries below were reconstructed from the commits on 2026-07-14 > after a session crash — the work was committed and pushed, and each commit > did its own sync sweep (BACKLOG #115/#116, HARDWARE-QUEUE, docs); only the diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index eaf1346..8cd828b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -318,6 +318,37 @@ Design/decision records and a running log of shipped work (items marked generic apply path is exercised by every manual theme change). The only on-hardware residual is the live user timer firing on its `OnCalendar` schedule (the schedule is `systemd-analyze`-validated). +- ✓ **NixOS-side state bridges (#116):** `nomarchy.settings` is declared in + exactly one place — `modules/home/options.nix`, the **Home Manager** side. + On the NixOS side the attribute does not exist, so four options that + defaulted from `config.nomarchy.settings.… or ` silently got the + fallback on every machine ever built: `or` swallows the missing-attribute + error, which makes a dead bridge and a live one look identical in the + source. Two of them were **user-facing toggles that reported success and + did nothing** — Control Center's Bluetooth and Printing wrote + `settings.{bluetooth,printing}.enable` and printed "requires rebuild", and + the rebuild changed nothing, for as long as those toggles had existed. + **The pattern, now uniform:** the option declares a *static* default and + the implementing module reads the state via `modules/theme-state-read.nix` + (fails closed on bad JSON) and `mkDefault`s it — + `lib.mkIf (stateX != null) (lib.mkDefault stateX)`, so an absent key leaves + the option default as the single source of the fallback and a hand-set + value in `system.nix` still pins it. Done for `greeter.autoLogin` + (eb38008), `bluetooth.enable` (`modules/nixos/default.nix`) and + `services.printing.enable` (`modules/nixos/services.nix`). + `power.batteryChargeLimit` deliberately has **no** eval bridge: `power.nix`'s + oneshot reads the key out of the live state with `jq` at *runtime* and + prefers it over the baked value, which is why its menu worked all along — + the phantom read was dead but harmless, and is simply gone. + **The guard is the point:** nothing in a build fails when a bridge dies, so + `checks.state-bridges` asserts at eval that a flipped state file reaches + the config (and that a hand-set value still outranks it), and + `checks.printing-from-state` boots a VM whose *only* input is the state + file and waits for a running `cups.service`. Adding a bridge = copy the + shape and add a case. **Lesson:** `or ` on a config read is an + error-swallower — prefer an explicit `!= null` test over a fallback + expression, and grep the whole of `modules/nixos/`, not just `options.nix` + (the first pass at this item did the latter and undercounted). - **Nicer shell out of the box:** ✓ zsh is the default login shell, with a starship prompt themed from the JSON, autosuggestions + syntax highlighting, and modern-CLI ergonomics — `cat`→bat (theme "ansi", so it diff --git a/flake.nix b/flake.nix index b065b42..f3dc084 100644 --- a/flake.nix +++ b/flake.nix @@ -148,6 +148,22 @@ username = "me"; hardwareProfile = "framework-13-7040-amd"; # exercises the mapping }; + + # theme-state.json fixtures for the state-bridge checks below, + # built off the real shipped template so they stay honest about + # what a machine actually carries. + baseState = builtins.fromJSON + (builtins.readFile ./templates/downstream/theme-state.json); + stateFileOf = name: overrides: pkgs.writeText name + (builtins.toJSON (nixpkgs.lib.recursiveUpdate baseState overrides)); + # The shipped template carries none of these keys — the + # absent-key path every existing machine is on. + absentState = stateFileOf "state-absent.json" { }; + flippedState = stateFileOf "state-flipped.json" { + settings.bluetooth.enable = false; # default true → flipped + settings.printing.enable = true; # default false → flipped + settings.greeter.autoLogin = "me"; + }; in { downstream-template-system = @@ -155,6 +171,96 @@ downstream-template-home = downstream.homeConfigurations.me.activationPackage; + # The NixOS-side state bridges (ROADMAP § state bridges, #116). `nomarchy.settings` + # exists ONLY on the Home Manager side, so a NixOS option that + # defaulted from `config.nomarchy.settings.… or X` silently got X + # forever — and `or` makes a dead bridge look exactly like a live + # one, which is why two Control Center toggles wrote JSON nothing + # read for as long as they existed. Nothing in a build fails when a + # bridge dies, so assert the flip: no VM, pure eval. + state-bridges = + let + configWith = stateFile: extra: + (self.nixosConfigurations.nomarchy.extendModules { + modules = [ + ({ lib, ... }: lib.mkMerge [ + { nomarchy.system.stateFile = lib.mkForce stateFile; } + extra + ]) + ]; + }).config; + + absent = configWith absentState { }; + flipped = configWith flippedState { }; + # A hand-set value in system.nix must still outrank the state, + # or the documented "setting it by hand pins it" is a lie. + pinned = configWith flippedState { + nomarchy.system.bluetooth.enable = true; + nomarchy.services.printing.enable = false; + nomarchy.system.greeter.autoLogin = null; + }; + + cases = [ + { n = "absent key leaves bluetooth at its default (true)"; + ok = absent.hardware.bluetooth.enable == true; } + { n = "absent key leaves printing at its default (false)"; + ok = absent.services.printing.enable == false; } + { n = "absent key leaves autoLogin unset"; + ok = !(absent.services.greetd.settings ? initial_session); } + { n = "state bluetooth=false reaches hardware.bluetooth"; + ok = flipped.hardware.bluetooth.enable == false; } + { n = "state bluetooth=false reaches services.blueman"; + ok = flipped.services.blueman.enable == false; } + { n = "state printing=true reaches services.printing"; + ok = flipped.services.printing.enable == true; } + { n = "state printing=true reaches services.avahi (mDNS)"; + ok = flipped.services.avahi.enable == true; } + { n = "state autoLogin reaches greetd initial_session"; + ok = (flipped.services.greetd.settings.initial_session.user or null) == "me"; } + { n = "hand-set bluetooth outranks the state"; + ok = pinned.hardware.bluetooth.enable == true; } + { n = "hand-set printing outranks the state"; + ok = pinned.services.printing.enable == false; } + { n = "hand-set autoLogin outranks the state"; + ok = !(pinned.services.greetd.settings ? initial_session); } + ]; + failed = builtins.filter (c: !c.ok) cases; + in + if failed != [ ] then + throw '' + State bridge broken — a settings.* key no longer reaches the config: + ${nixpkgs.lib.concatMapStringsSep "\n" (c: " ✗ ${c.n}") failed} + Read the state via modules/theme-state-read.nix and mkDefault it + in the module — `config.nomarchy.settings` does NOT exist on the + NixOS side, and `or ` swallows that silently. + See ROADMAP § "NixOS-side state bridges (#116)".'' + else + pkgs.runCommand "nomarchy-state-bridges" { } + "touch $out"; # all ${toString (builtins.length cases)} assertions held at eval + + # The bridge, end to end on a booted machine: checks.state-bridges + # proves the option flips at eval, this proves a state key alone + # reaches a RUNNING service. The node sets no + # nomarchy.services.printing.enable anywhere — the only thing + # asking for CUPS is settings.printing.enable in the state file, + # which is exactly what Control Center's Printing toggle writes and + # what nothing read until #116 (ROADMAP § state bridges). + printing-from-state = pkgs.testers.runNixOSTest { + name = "nomarchy-printing-from-state"; + nodes.machine = { ... }: { + imports = [ ./modules/nixos/options.nix ./modules/nixos/services.nix ]; + nomarchy.system.stateFile = flippedState; + }; + testScript = '' + machine.wait_for_unit("multi-user.target") + # CUPS is up because a JSON key said so, and for no other reason. + machine.wait_for_unit("cups.service") + # mDNS rides along, so a network printer is discoverable — + # the half of the toggle's promise that isn't the daemon. + machine.wait_for_unit("avahi-daemon.service") + ''; + }; + # Every hex-on-hex text pairing the generated swaync CSS uses # must contrast in EVERY palette — summer-day's body text was # invisible on hardware (item 25: subtext==base there). Cheap diff --git a/modules/nixos/default.nix b/modules/nixos/default.nix index 59ea7be..2790d14 100644 --- a/modules/nixos/default.nix +++ b/modules/nixos/default.nix @@ -20,6 +20,19 @@ let nomarchyLogoFont = pkgs.runCommandLocal "nomarchy-logo-font" { } '' install -Dm444 ${./branding/Nomarchy.ttf} $out/share/fonts/truetype/Nomarchy.ttf ''; + + # Control Center's Bluetooth toggle writes settings.bluetooth.enable; read + # it from the state file, the only place it exists on the NixOS side (the + # hardware.nix/timezone.nix bridge). Missing/invalid JSON fails closed via + # theme-state-read.nix rather than a raw stack. null = key absent, which + # leaves the option's own default (true) alone. + sysState = + if cfg.stateFile != null + then import ../theme-state-read.nix { inherit lib; } cfg.stateFile + else { }; + stateBluetooth = + let v = (sysState.settings or { }).bluetooth.enable or null; + in if builtins.isBool v then v else null; in { imports = [ ./options.nix ./plymouth.nix ./greeter.nix ./file-manager.nix ./power.nix ./services.nix ./hardware.nix ./timezone.nix ./oom.nix ]; @@ -217,6 +230,13 @@ in # overrides this regardless. users.defaultUserShell = lib.mkOverride 500 pkgs.zsh; + # The in-flake state drives the toggle; mkDefault so a hand-set + # nomarchy.system.bluetooth.enable in system.nix still pins it (the + # greeter.autoLogin shape). mkIf, not a fallback expression, so an absent + # key leaves the option default as the single source of `true`. + nomarchy.system.bluetooth.enable = + lib.mkIf (stateBluetooth != null) (lib.mkDefault stateBluetooth); + hardware.bluetooth.enable = lib.mkDefault cfg.bluetooth.enable; services.blueman.enable = lib.mkDefault cfg.bluetooth.enable; diff --git a/modules/nixos/greeter.nix b/modules/nixos/greeter.nix index 28a488c..a00a42a 100644 --- a/modules/nixos/greeter.nix +++ b/modules/nixos/greeter.nix @@ -62,9 +62,12 @@ let fi ''; + # Fails closed with an actionable message via theme-state-read.nix, like + # every other stateFile consumer — a raw fromJSON here would bury a bad + # state file under a Nix stack pointing at greeter.nix. state = if cfg.stateFile != null - then builtins.fromJSON (builtins.readFile cfg.stateFile) + then import ../theme-state-read.nix { inherit lib; } cfg.stateFile else { }; # The auto-login user from the state, or null. Read here via the state file diff --git a/modules/nixos/options.nix b/modules/nixos/options.nix index 82f7fb7..52df6c0 100644 --- a/modules/nixos/options.nix +++ b/modules/nixos/options.nix @@ -51,7 +51,16 @@ option'' // { default = true; }; audio.enable = lib.mkEnableOption "the Pipewire audio stack" // { default = true; }; - bluetooth.enable = lib.mkEnableOption "Bluetooth support with blueman" // { default = config.nomarchy.settings.bluetooth.enable or true; }; + # default stays a plain `true` here; ./default.nix mkDefaults it from + # settings.bluetooth.enable (Control Center's Bluetooth toggle). Reading + # the state in the option default is the trap ROADMAP § "NixOS-side state + # bridges (#116)" documents: `config.nomarchy.settings` does not exist on + # the NixOS side, and `or true` silently swallowed that for years. + bluetooth.enable = lib.mkEnableOption "Bluetooth support with blueman" // { + default = true; + defaultText = lib.literalExpression + "(settings.bluetooth.enable from theme-state.json) or true"; + }; autoTimezone.enable = lib.mkEnableOption '' automatic timezone detection (geoclue + automatic-timezoned): the @@ -109,7 +118,14 @@ batteryChargeLimit = lib.mkOption { type = lib.types.nullOr (lib.types.ints.between 50 100); - default = config.nomarchy.settings.power.batteryChargeLimit or null; + # No state bridge at eval time, by design: ./power.nix's oneshot reads + # settings.power.batteryChargeLimit out of the live theme-state.json + # with jq at *runtime* and prefers it over this baked value, so the + # menu applies before (and without) a rebuild. This used to read + # `config.nomarchy.settings…`, which does not exist on the NixOS side + # and so was always null — dead, but harmless precisely because the + # runtime path never depended on it (ROADMAP § state bridges, #116). + default = null; # Dell Adaptive charge mode ignores the end threshold unless we # also select Custom (power.nix oneshot); see Latitude 5310 QA. example = 80; diff --git a/modules/nixos/services.nix b/modules/nixos/services.nix index f37b362..4fd71b3 100644 --- a/modules/nixos/services.nix +++ b/modules/nixos/services.nix @@ -7,6 +7,19 @@ let cfg = config.nomarchy.services; + + # Control Center's Printing toggle writes settings.printing.enable; read it + # from the state file, the only place it exists on the NixOS side (the + # hardware.nix/timezone.nix bridge). Missing/invalid JSON fails closed via + # theme-state-read.nix rather than a raw stack. null = key absent, which + # leaves the option's own default alone. + svcState = + if config.nomarchy.system.stateFile != null + then import ../theme-state-read.nix { inherit lib; } config.nomarchy.system.stateFile + else { }; + statePrinting = + let v = (svcState.settings or { }).printing.enable or null; + in if builtins.isBool v then v else null; in { options.nomarchy.services = { @@ -74,10 +87,18 @@ in with the lmstudio/alpaca GUIs). CPU by default — set `services.ollama.acceleration` natively for GPU offload''; + # default stays a plain `false` here; the state bridge is a mkDefault + # below, from settings.printing.enable (Control Center's Printing + # toggle) — see ROADMAP § state bridges (#116) for why the old read of + # `config.nomarchy.settings` never worked. printing.enable = lib.mkEnableOption '' CUPS printing with Avahi/mDNS, so network printers are auto-discovered (add vendor drivers via `services.printing.drivers`); the menu's - System ▸ Printers entry opens the system-config-printer GUI'' // { default = config.nomarchy.settings.printing.enable or false; }; + System ▸ Printers entry opens the system-config-printer GUI'' // { + default = false; + defaultText = lib.literalExpression + "(settings.printing.enable from theme-state.json) or false"; + }; openrgb.enable = lib.mkEnableOption '' the OpenRGB daemon and GUI for controlling RGB lighting on peripherals @@ -124,6 +145,14 @@ in }; config = lib.mkMerge [ + # The in-flake state drives the toggle; mkDefault so a hand-set + # nomarchy.services.printing.enable in system.nix still pins it (the + # greeter.autoLogin shape). mkIf, not a fallback expression, so an absent + # key leaves the option default as the single source of `false`. + (lib.mkIf (statePrinting != null) { + nomarchy.services.printing.enable = lib.mkDefault statePrinting; + }) + (lib.mkIf cfg.tailscale.enable { services.tailscale.enable = true; # Let the login user drive tailscale (up/down/set — and so the VPN menu's