Decision: Toolchain pinning
Context
Braid’s runtime parser surface covers btrfs-progs, cryptsetup, util-linux (lsblk), NUT, smartmontools, and ethtool. Most of those tools expose fragile text or safety-critical behavior where version drift can silently break parsers. util-linux is different: braid consumes lsblk --json through tolerant serde, accepts added keys, and fails closed only when a requested column disappears. 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 the fragile parser/safety tools whose output format or behavior is part of braid’s correctness model: btrfs-progs, cryptsetup, NUT, smartmontools, and ethtool. Source util-linux and systemd from the consumer’s package set because braid uses them through stable contracts.
How it works
- Flake input:
nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"– braid’s own pinned channel, and the source of the five pinned fragile parser/safety packages unless the consumer redirects braid’snixpkgsinput (see the follows note below). - Module options:
braid.packages.*keeps override knobs for cryptsetup, btrfsProgs, utilLinux, nut, smartmontools, and ethtool.nixosModules.defaultdefaults the five pinned tools to braid’snixpkgsflake input;utilLinuxfalls back to the option’s consumer-pkgsdefault. - PATH wrapping: The module wrapper injects
cfg.packages.*into PATH. util-linux is present there throughcfg.packages.utilLinux, but its default is the consumer’spkgs.util-linux. Generic helpers (coreutils, systemd) are resolved from the consumer’spkgs, not pinned. - Two wrapping sites: flake.nix wraps only the five pinned tools for
nix runand tests, relying on the caller-supplied ambient host PATH for util-linux and systemd. 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 five pinned braid.packages.* defaults with import self.inputs.nixpkgs – braid’s nixpkgs flake input, instantiated cleanly (no consumer overlays). braid.packages.utilLinux falls back to the option default from the consumer’s pkgs, and systemd has no braid package option. Whether the consumer sets braid.inputs.nixpkgs.follows = "nixpkgs" decides where the five 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 five 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 five 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 fragile storage toolchain with it and re-introduces the parser-drift risk this decision otherwise prevents. util-linux and systemd resolve from the consumer’s nixpkgs regardless of follows. If you do opt into follows, mitigate by keeping nixpkgs aligned with braid’s release or pinning braid.packages.*.
Operational escape hatch
The five fragile parser/safety 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 overrides for parsed tools 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 fragile output, or the tool’s behavior is part of braid’s correctness/safety model.
Use system pkgs for a stable parsed contract when: braid parses the tool, but only through a contract stable enough that pinning is unnecessary: tolerant structured JSON, fail-closed on missing requested keys, and fixture-covered. util-linux is in this category for lsblk --json.
Use system pkgs for a generic helper 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 three 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) | No – host pkgs | Yes (braid.packages.utilLinux) | lsblk --json is a stable structured contract: serde tolerates added columns and fails closed only when a requested column is missing |
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, including
the host-provided lsblk --json contract, 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 configured 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 parser 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