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