Decision: Toolchain pinning
Context
Braid’s parser-critical runtime tools (btrfs-progs, cryptsetup, util-linux, NUT, smartmontools, ethtool) are parsed by the Rust CLI. Output formats change between tool versions – a flake update to nixpkgs-unstable could silently break parsers. Generic helpers (coreutils, systemd) are used for basic system operations and are outside braid’s parser contract. Browse has one tolerant UI-only systemd exception: it parses systemctl list-units --output=json for a picker and falls back to raw output on parse failure.
Decision
Pin flake.nix to a specific NixOS stable release (nixos-26.05). Pin only parser-critical tools — those whose output braid parses or whose behavior is part of braid’s correctness model. Generic helpers come from the consumer’s system package set.
How it works
- Flake input:
nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"— braid’s own pinned channel, and the source of parser-critical tool packages unless the consumer redirects braid’snixpkgsinput (see the follows note below). - Module options:
braid.packages.*(cryptsetup, btrfsProgs, utilLinux, nut, smartmontools, ethtool) default to braid’snixpkgsflake input but can be overridden per-system. - PATH wrapping: The wrapper injects
cfg.packages.*into PATH. Generic helpers (coreutils, systemd) are resolved from the consumer’spkgs, not pinned. - Two wrapping sites: flake.nix wraps with
pkgs.*defaults (fornix runand tests); the module wrapscfg.packagewithcfg.packages.*(for deployed NixOS systems where package options may be overridden).
Consumer follows decides the actual source
nixosModules.default builds the braid.packages.* defaults with import self.inputs.nixpkgs – braid’s nixpkgs flake input, instantiated cleanly (no consumer overlays). Whether the consumer sets braid.inputs.nixpkgs.follows = "nixpkgs" decides where the pinned tools actually come from.
The recommended default is no follows. With no follows, braid’s nixpkgs input stays on its pinned nixos-26.05, so the pinned tools resolve from braid’s release channel and braid-cli-unwrapped matches the exact binary the release cache publishes – a cache hit instead of a from-source rebuild on the NAS. ADR 029 is the authoritative home for that cache-path-identity rationale; the short version is that follows rebuilds braid against the consumer’s nixpkgs, changing the store path and forcing a recompile.
follows = "nixpkgs" is a valid advanced opt-out (smaller closure via nixpkgs dedup), but it redirects braid’s nixpkgs input to the consumer’s nixpkgs, so the pinned tools then resolve from the consumer’s nixpkgs. The pin therefore guarantees stable parser output only while the consumer’s nixpkgs stays on the same NixOS stable release braid targets (currently nixos-26.05). Within one stable release tool output formats change only for security fixes, so a consumer aligned on braid’s release is safe; a consumer who bumps nixpkgs ahead of braid moves the storage toolchain with it and re-introduces the parser-drift risk this decision otherwise prevents. If you do opt into follows, mitigate by keeping nixpkgs aligned with braid’s release or pinning braid.packages.*.
Operational escape hatch
Parser-critical tools are pinned by default to the flake’s nixpkgs release, but braid.packages.* overrides are intentional – operators may need a newer upstream version for urgent bugfixes or security patches before braid’s next nixpkgs bump. The override takes precedence. Operator-set braid.packages.* overrides sit outside braid’s committed parser contract: the standard fixture-capture and golden-test recipes build fixed flake checks against the flake’s pkgs, so they do not validate an arbitrary override. Treating an override as supported requires a maintainer to reproduce the fixture-refresh workflow under a temporary local input swap (e.g. --override-input nixpkgs on the capture/test commands, or a local flake edit) at the override’s package version, then re-run just test-rust against the resulting fixtures. Operators who skip this step are running unverified parser inputs.
Classification guideline
Pin when: braid parses the tool’s output, or the tool’s behavior is part of braid’s correctness/safety model.
Use system pkgs when: the tool is a generic helper, braid doesn’t parse its output as a correctness contract, and version drift is unlikely to affect correctness. The Browse Systemd picker is a UI-only exception because it parses systemctl list-units --output=json tolerantly and disables drill-in on parse failure.
New runtime dependencies must be classified into one of these two groups when added.
| Tool | Pinned by default? | Overrideable? | Reason |
|---|---|---|---|
| btrfs-progs | Yes | Yes (braid.packages.btrfsProgs) | Output parsed by nom combinators and serde JSON |
| cryptsetup | Yes | Yes (braid.packages.cryptsetup) | Output parsed by nom combinators |
| util-linux (lsblk) | Yes | Yes (braid.packages.utilLinux) | lsblk JSON output parsed by serde |
NUT (upsc) | Yes | Yes (braid.packages.nut) | upsc key: value output parsed by parse_upsc for preflight safety and operator visibility |
| smartmontools | Yes | Yes (braid.packages.smartmontools) | smartctl --json output parsed by parse_smartctl |
| ethtool | Yes | Yes (braid.packages.ethtool) | Wake-on: line parsed by the doctor wake_on_lan check |
| coreutils | No — system pkgs | No option | chown/chmod/realpath/stat — output not parsed |
| systemd | No — system pkgs | No option | systemctl/ask-password commodity behavior; Browse’s list-units JSON picker is tolerant UI-only, not parser-critical |
Upgrading tools
A nixpkgs bump can move parser-critical tools to new output formats, so an upgrade must refresh fixtures and re-run every parser-validation lane – not just confirm tool provenance. These steps mirror the canonical sequence in dev/overview.md (“Refresh fixtures and run tests”); keep the two in sync.
- Bump the nixpkgs input to the next stable release and run
nix flake update nixpkgs. - Refresh fixtures:
just capture-all-fixtureswrites golden files undercli/tests/fixtures/nixos-<release>/(withupsc/holding thecapture-ups-fixturesoutputs).just capture-all-fixtures-unstableis the unstable-lane mirror. - Run the parser-validation lanes, updating parsers/tests for any output that
changed:
just test-rust– golden-fixture parser tests.just test-parsers– live-tool parser canary.just test-vm– VM suite. Itstool-versionscheck verifies provenance: each pinned tool resolves to a/nix/store/path on the VM’s PATH and its self-reported version matchespkgs.<tool>.versionfrom the same evaluation. Provenance only –tool-versionsdoes not detect that nixpkgs moved a tool to a new version (both sides advance together), so the fixture and parser tests above are the actual drift gate. Run it alone withjust test-vm tool-versionsfor a quick provenance-only check.
NUT specifically: parse_upsc depends on the key: value shape emitted by pkgs.nut’s upsc client (see reference/nut/clients/upsc.c). A nixpkgs bump that touches networkupstools triggers the same fixture-refresh obligation as the other pinned tools – run just capture-ups-fixtures and just test-rust before merging. The braid-status-ups check under just test-parsers is the live-tool mirror of the golden fixtures.
ethtool specifically: wake_on_lan depends on the Supports Wake-on: and Wake-on: lines emitted by pkgs.ethtool. VM virtio NICs do not provide useful Wake-on-LAN state, so there is no live fixture-capture lane; parser coverage is hand-authored in Rust unit tests, and wrapper provenance is covered by the tool-versions and braid-auto-suspend VM tests.
Alternatives considered
BRAID_*_BIN environment variables
Rejected. Adds a second resolution mechanism alongside PATH. Every callsite would need to check the env var, falling back to PATH. More complexity, same result — Nix already controls PATH.
Absolute paths in Rust (no PATH at all)
Rejected. Would require threading Nix store paths into the Rust binary at build time (via build.rs or env vars). Fragile and non-standard — NixOS convention is PATH wrapping via makeWrapper.
Stay on nixpkgs-unstable
Rejected. Unstable channel updates tool versions without notice. A routine nix flake update could change btrfs-progs output format and break parsers silently. Stable releases change only for security fixes.
Pin all runtime tools (blanket pinning)
Previously active, now superseded. Blanket pinning created unnecessary closure duplication for generic helpers (jq, coreutils) that braid does not parse. The braid.packages.coreutils option was also inconsistently wired — storage.nix used pkgs.coreutils directly, bypassing the option. Selective pinning is simpler and honest about what braid actually depends on.
See
- NixOS-native — follow NixOS conventions (PATH wrapping via makeWrapper)
- Release process – cache-path-identity rationale for the no-follows default
- Principle 10 in principles.md