dotfiles
Repo

dead-config: what is declared and never used

Edit on GitHub

tools/dead-config/package.nix, wired into checks and into the pre-commit hooks. Run it by hand with nix run .#dead-config.

Rule 16 says dead config leaves the repo. Until this existed, the only thing enforcing that was me remembering, which is the same "intention, not a standard" the CI's own header rejects.

The seven checks

CheckWhat is deadWhy it is silent
modulea .nix under modules/nixos/ or modules/home/ that no imports reachesit evaluates to nothing, so nothing fails; the file just sits there looking live
inputa flake input nothing consumesit is still fetched, locked and evaluated on every build
optiona my.* option nobody reads through config/osConfigan SSOT with no consumer, which is the thing rule 11 exists to prevent
notea page in docs/notes/ no module points atrule 2 made the pointer the ONLY path in, so an unpointed page is unreachable
secreta key in secrets.yaml nothing consumesa credential kept, re-encrypted for two recipients and rotated for nobody
secret-indexa key in bitwarden-secrets.json with no value in the vaultit breaks at nixos-rebuild, which is a far slower loop than a hook
artifacta tracked build output or editor droppinga .gitignore only stops what is not tracked YET

What it found on the first run

jellyfin_api_key: in secrets.yaml, consumed by nothing. It belongs to the OLD Jellyfin server and answers 401, so it was not merely unused, it was unusable. REMOVED on 16/08/2026, and ALLOWED is empty again, which is where it should stay.

Everything else came back clean, so the other four checks are REGRESSION GUARDS rather than bug-finders. That is the honest description of them.

Removing a secret takes THREE deletions, not one

The first attempt deleted only the key from secrets.yaml and broke the build:

sops-install-secrets: manifest is not valid: secret jellyfin_api_key in
/nix/store/…-secrets.yaml is not valid: the key 'jellyfin_api_key' cannot be found

The vault is the VALUE; the DECLARATION lives elsewhere, in two places at once:

  1. secrets/secrets.yaml, the encrypted value.
  2. secrets/bitwarden-secrets.json, the index, which modules/nixos/core/secrets.nix turns into one sops.secrets.<name> per entry through lib.mapAttrs.
  3. modules/nixos/core/secrets.nix itself, when the secret also has a hand-written override (this one had owner/mode, so it was declared twice over).

Delete from the yaml alone and sops-nix still declares the secret, then fails at BUILD time because the key it was told to install is gone. That is a loud failure, which is the good case; the reverse (an entry in the index with no value in the vault) is the same error from the other side.

This is also why dead-config reports a secret as dead from the CONSUMPTION side and not the declaration side: a declaration is not a use, and here there were two declarations and zero uses.

The artifact check exists because .gitignore is not retroactive

scripts/__pycache__/router-sync.cpython-313.pyc was tracked for months. .gitignore had no rule for python bytecode, and by the time one was added the file was already in the index, where the ignore file has no effect. Nothing surfaced it, because a .pyc breaks nothing: it is simply regenerated and never read.

That is the whole shape of the problem this tool exists for. The rule and the check are two different guarantees: the rule stops the NEXT one, the check finds the one already in.

The naive version of each check is wrong, and that matters

A lint you learn to ignore is worse than a lint turned off, which is the same argument flake.md records for the two statix rules that are disabled. Every check here was prototyped, produced a false positive, and was fixed before being written down:

  • inputs: grepping for inputs.<name> misses nixpkgs-unstable, which is destructured as a bare argument of outputs and used as import nixpkgs-unstable. The check subtracts the declaration block and then looks for the name ANYWHERE, which catches both forms. It also has to scan the whole tree and not just flake.nix, because zen-browser is consumed in modules/home/packages.nix through specialArgs. The block ends at the outputs = attribute. Until 22/10/2026 it ended at the first outputs in the FILE, which is a comment inside the block ("exposing no Nix outputs"), so every input declared below that comment went unchecked.
  • options: "declared and used at most once" flagged four live options (my.fai.workstation, my.disk, my.net.domain, my.ingress), because a consumer reads a CHILD (config.my.ingress.<svc>) or reads it several times in one file. The check looks for a read through config. or osConfig. specifically.
  • modules: resolving every ./path in the file flags nothing, but it also PROVES nothing, since it counts a path mentioned in a comment. The check parses imports = [ … ] blocks and walks reachability from the real roots (flake.nix, modules/nixos/default.nix, modules/home/default.nix, hosts/*). For flake.nix and flake/ it also follows a plain import ./flake/x.nix, which is how the outputs reach their implementation. Both ./x and ../x resolve, so a host importing a shared module by a relative path counts as a reach; before 30/09/2026 a ../ was resolved one level too shallow and read as unreached. pkgs/ and tools/ are deliberately exempt: they are reached by callPackage in flake.nix, not by an imports list.

Shell since 22/10/2026 (rule 24)

The Python version left in phase A of languages. Each check is one or a few rg calls; the module graph is the one place with real logic, so rg --json hands every imports block and import ./x to ONE jq program that resolves the paths, normalizes them and walks from the roots. 0.4s on the tree.

MEASURED against the Python version: the same output on the tree in both modes, and on a fixture with one case of each kind, the same findings plus ONE: an input declared below the comment the old cut stopped at, which only the shell version reports.

A QUIRK kept as it was, for a separate decision: an exception is keyed by kind and BASENAME, so note:docs/notes/repo/license.md can never match a finding (that key would be note:license.md). The note is still not reported, but for another reason: this checker's own source holds the path, and a path inside the code is what counts as a pointer. The exception keeps itself alive.

Two checks that were considered and REJECTED

A secret consumed but NOT provisioned. It sounds like the more valuable direction, and it produces a false positive immediately: modules/home/shell/ntfy.nix reads /run/secrets/ntfy_topic, which does not exist, and that is BY DESIGN. The script tests [ ! -r "$secret" ], warns and exits 0, because "the caller must not break because the warning did not go out". A check that cannot tell a guarded read from an unguarded one would flag good code.

Input age from flake.lock. lastModified is the UPSTREAM commit date, not the date we last fetched. disko reads as 66 days old only because disko has not had a commit in 66 days, and nix flake update already ran. Measuring "our" staleness would mean measuring the lock file's own git mtime, which is a different and much weaker signal. See flake.md for why the DeterminateSystems flake-checker was rejected for the same reason plus a vendor one.

The ALLOWED list

A tracked exception needs a REASON string, so it appears in the diff instead of rotting in silence. Emptying that list is the goal, not growing it. If a finding is real, the fix is deleting the thing, not adding a line.

Every exception has a review date (rule 22, 29/09/2026)

Each entry is (reason, "YYYY-MM-DD"). A reason alone explains why an exception exists TODAY, and says nothing about whether it still does in 2029; without a date the list only grows, because nobody deletes a line that looks deliberate. The date is not a deadline for the THING, it is the day the question comes back: on it, the exception is deleted or the date moves in a commit that says why, which is the same contract as a budget in tools/eval-metrics/budget.json.

ExceptionReview byWhy that date
secret:restic_password2026-12-31new storage was the plan; a quarter is when to ask again
note:docs/notes/repo/license.md2027-09-29a policy, so a yearly look is enough

--expired also reads .config/gitleaks.toml: the # review-by: line above each allowlist block, keyed by its description (github-settings). One clock for every exception list in the repo, instead of one per tool.

The date check is dead-config --expired, and it runs in the CANARY, never in the gate. The gate is hermetic and its result is cached by input: a date would make the same commit pass on Monday and fail on Tuesday, and a cached green would hide the Tuesday anyway. "The answer changes with no commit of mine" is the canary's definition (flake.md), so the job lives there and a red run reaches the phone through the ntfy job. MEASURED: with the restic date moved to 2026-01-01 by hand, --expired printed the entry and exited 1; reverted, it exits 0.

The long form of rule 16

Moved here VERBATIM from rules.md on 30/09/2026, when rule 16 became a card. Nothing was cut; the card links back here.

DEAD CONFIG GOES OUT IN THE SAME COMMIT THAT REMOVED ITS USE, and DRIFT IS A BUG, not tidying for later. There are three forms, and all three LIE instead of failing: dead (a declaration nobody reads), orphan (the use went away, the declaration stayed) and drift (the text describes a system that no longer exists). Rule 14 governs WHO WRITES an artifact; this one governs whether it is still ALIVE and TRUE. The cost never shows up on the day it is born: it is charged to the next reader, who has no way to tell "this is necessary" from "this is leftover", and when in doubt they preserve it, so the junk becomes permanent. HOW TO DETECT IT: grep -rn '<name>' --include='*.nix' ., and if only the declaration shows up, it is dead. THAT GREP IS NOT ENOUGH, and getting this wrong is WORSE than not auditing: a declaration can be GENERATED from DATA, and then the name does not exist in any .nix. That is what caught me on 11/08/2026: I called caddy_pos_hash_v1cferr/_jp orphans because the grep over .nix returned zero, and they were declared the whole time through secrets/bitwarden-secrets.json, which modules/nixos/core/secrets.nix walks with mapAttrs. Deleting them from the vault alone created "declared and absent", which validateSopsFiles fails at BUILD time (and sync-secrets would bring them back from Bitwarden on the next run). DERIVED RULE: always audit for orphans by reconciling the two RESOLVED ends, config.sops.secrets (declared, with the generators already applied) against the names in secrets.yaml, never by a text grep. It holds for every index-to-generator pair: what declares can be JSON, not Nix. And dead config BITES, it does not just clutter: the requiredSecrets in caddy.nix was a literal, so a password nobody read anymore was enough to leave Caddy inert on the switch, taking jellyfin, torrent, ai and duo down with it. Other real cases: xembedsniproxy cited in 3 comments without EVER having been installed; tray-native-menu.sh pointing at the path of a waybar that was already removed; a .env at the root with a Tailscale auth key that nothing read. FOR WHAT IS NOT DECLARED (rule 6, an app that rewrites itself) the antidote is not to declare it, it is to give git VISIBILITY: a mirror regenerated by a COMMAND and never by hand (vscode-extensions-dump producing extensions.txt), or a direct write into the repo through mkOutOfStoreSymlink (settings.json/keybindings.json, the same link as hyprland.lua). What git can see does not drift silently. DO NOT CONFUSE THIS with erasing history: a comment explaining why something is the way it is TODAY by citing what died ("replaces trustedInterfaces = [ tailscale0 ]") is rule 2 working, not drift. What dies is the executable DECLARATION, not the memory of why it existed.