Skip to content

Rules

The rules that govern this repo. THE NUMBERING IS API: the code cites "rule 11", "rule 14" and so on in ~180 comments, and renumbering would break all of them silently. A new rule goes in at the END; a rule that dies gets struck through instead of disappearing.

  1. Always research the best practices and what the NixOS community is using most for each package/software (to have a reference and suggestions)
  2. COMMENTS ARE SHORT, in EVERY file and with no exception. AT MOST 2 LINES, ANYWHERE: that is the cap for the module header AND for every comment inside it, per config, per package, per list item. The header says what the module is and where the detail lives. The detail itself goes to notes/, never into the file. A comment records the why and the trap in one line, never the thing the code already says. THE REASON THIS RULE CHANGED TWICE: it first forbade the header block, then allowed it because the repo had them anyway, and the blocks grew until 36% of the tree was comment and one module carried a 123-line header (measured on 16/08/2026, 6062 comment lines in 16634). A header that long is not documentation, it is a wall you scroll past to reach the code, and the reasoning inside it was invisible to anyone reading docs/. So the reasoning MOVED instead of being deleted: notes/<module>.md holds the why, the measurements and what was tried and rejected, and the 2-line header points at it. The sweep landed on 16/08/2026: 1601 comment lines in 13299, 12%, with NOTHING deleted, only relocated. Whatever you touch, you shorten.
  3. Always declarative and never "manual" (so it works on any hardware later on)
  4. Keep system/ and home/ apart: system level (services, drivers, root packages) in system/; the app and the user config in home/ (programs.* when there is a module, otherwise home.packages). Never the same package in both.
  5. Organize by category: each subject in its own subfolder with its default.nix (adding a module = 1 line in the category's default.nix; the top level does not change).
  6. Nix = app + config; state = restic: saves, Wine prefixes, app tokens/sessions are not declared, they go to the backup.
  7. No loose .sh: the logic lives in the build (Nix) or in systemd; runtime is a 1-line command (shellcheck at build time catches mistakes early).
  8. Validate before applying: nixos-rebuild build / nix eval OK and atomic commits per feature/task, before the switch.
  9. Everything in the TokyoNight theme, centralized in a Nix PALETTE of my own (home/desktop/palette.nix, option my.theme.name), so changing themes = 1 line (presets: tokyo-night/catppuccin-mocha/gruvbox-dark). nix-colors was DISCARDED: archived (apr/2026) and a base16 of only 16 colors does not reproduce the exact hexes.
  10. The UI FONT has its OWN SSOT, separate from the colors: my.fonts.ui in system/hardware/fonts.nix (next to the package, because a font is system level, rule 4; and fontconfig also needs the name, and a system module cannot read a home-manager option). Changing the font = 1 line + the package. A user-side consumer reads it through osConfig.my.fonts.ui, never as a literal.
  11. SSOT ALWAYS: a value repeated in 2+ places becomes a my.<domain>.<thing> option and a consumer NEVER holds a literal. Today those are my.theme.name/.palette (colors, rule 9), my.fonts.ui (font, rule 10) and my.services.<n> (optional services). The option lives at the LOWEST level that needs it: if any module in system/ consumes it, it is a system option and home/ reads it through osConfig. The opposite does NOT exist (a system module cannot read a home-manager option). A HOT-RELOAD consumer (Quickshell/Hyprland) does not accept Nix interpolation, because the tree is a symlink: the module GENERATES a data file (JSON/Lua) that it reads, and then the only legitimate literal is the "file was missing" fallback. VALIDATE by swapping the option for a SENTINEL: rebuild, check that ALL consumers changed, revert and check that the store path came back identical.
  12. SECRETS are a SEPARATE layer and the repo NEVER holds a credential: the source is Bitwarden, the delivery is sops-nix (root's age key). A consumer reads /run/secrets/<name> at RUNTIME, never at build time, because /nix/store is world-readable, so a secret interpolated into a derivation LEAKS. Editing a secret requires a rebuild, otherwise /run/secrets does not update.
  13. The flake.lock PINS the dependency universe: no nix-channel, no fetch without a hash, no implicit "latest". Bumps only through update/upgrade, and update runs as the USER because that is who holds the SSH key for the private inputs, and the lock goes into the SAME commit as the change that required it, otherwise yesterday's build is not reproducible today.
  14. ONE OWNER per artifact: if Nix generates the file, only Nix writes to it; if the app rewrites it at runtime, Nix does NOT manage it as a file, it uses an idempotent activation or an immutability marker (ViewMode[$i]). Two layers on the same file = SILENT DRIFT, the worst kind: nothing fails, it just ends up wrong. Real cases from this repo: hyprpaper (the HM module generating the old format against the config the daemon required, so a black screen for months), ~/.config/theme/* (deleted as "temporary" when they were HM symlinks, so a boot with no session), dolphinrc (Dolphin rewrites it, so activation + [$i]).
  15. Every piece of AUTOMATION has an explicit and SINGLE owner: whoever starts it is declared (a systemd unit, the compositor's exec-once, a timer). An orphan process parented to some shell dies with it. And a single owner with NO FALLBACK is a point of failure: if the automation sustains remote access, it needs a safety net independent of the config that can break (that was the case with graphical-session.target, which only exec-once brought up).
  16. 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 system/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.
  17. EVERYTHING IN THIS REPO IS WRITTEN IN en-US: code comments, module header blocks, docs, option descriptions, commit messages, and file/directory names. The reason is REACH, not style. This repo is public, it is the most detailed record of how I work, and it is meant to be read by people who do not speak Portuguese. THE MIGRATION IS INCREMENTAL, AND HALF-TRANSLATED IS THE WORST OF THE THREE STATES, so it needs a hard boundary instead of good intentions: whatever you touch, you leave in en-US. New files are born in en-US; an edited file gets translated IN THE SAME COMMIT that edits it, never in a "translation pass later" that never comes (that is rule 16's drift, applied to language). Rules 1-16 above, all of docs/, the .nix tree, the Hyprland Lua, the Quickshell QML, scripts/, the CI workflow and the tooling files were all translated and renamed on 15/08/2026, the day this rule was written, so THE MIGRATION IS DONE and this rule now only governs what comes next. What deliberately stays in pt-BR is a short, closed list, and each item says why where it lives: the LOCKSCREEN (a product decision, recorded in the july history), the names of Brazilian holidays plus the month and weekday names in the bar's calendar (official names of local events, the same class of literal as a city's name), and runtime identifiers whose rename would be a behavior change and not a translation (the my.archAntigo option, the arch-antigo-mount unit, /mnt/arch-antigo, the "Arch antigo" Dolphin bookmark, /srv/media/media/Filmes). RENAME WITH git mv, NEVER delete+create, because the history of a file IS the product here and delete+create severs it. THE RULE NUMBERING SURVIVES TRANSLATION: docs/regras.md became docs/rules.md, and the comments citing "regra N" became "rule N" with the same N. The numbering is API (see this file's header), so translating it renames the WORD, never the number. COMMITS: conventional commits (feat|fix|docs|chore(scope): subject), subject AND body in en-US, and one commit per feature/task, never one blob at the end of the day. That is rule 8 seen from the git side, and it matters for the same reason the header blocks matter: the history is the diary that explains WHY, and a blob erases it. NEVER a Co-Authored-By: trailer, for Claude or any other tool: who typed is not who decided, and the authorship of this repo is not shared. NO EM DASHES () in prose, anywhere. A comma, a colon or a new sentence always reads better, and leaning on the em dash is a tic that flattens every paragraph into the same shape. The exception is the em dash as a LITERAL and not as prose: the "—" glyph used on screen to mean "no value", and a regex matching somebody else's window title that contains one. NO EMOJI, on the same terms and for a stricter reason: not in docs, not in comments, not in commit messages, not in an option description. A marker like the warning sign is not emphasis, it is a claim that THIS paragraph matters more than the one next to it, and when every trap carries one the marker stops meaning anything. What already earned emphasis has CAPS and bold, which survive grep, a diff and a terminal with no font for pictographs. The exception is anything that is a literal being quoted and not decoration: a program's own output (sbctl status prints a check mark) or a codepoint the text is discussing (U+2764). Same exception as the em dash, for the same reason. FIRST PERSON, NOT MY OWN NAME: this repo is mine, so it says "my dotfiles", never "v1cferr's dotfiles" or the impersonal "the user's dotfiles", which read like a third party documenting my machine. Second person is reserved for the READER ("the ones you need to read this tree"), which keeps the two voices from colliding. The exception is anything that is a literal identifier and not a figure of speech: ssh.v1cferr.dev, the v1cferr user account, paths under /home/v1cferr.
  18. THE AGENT CONTRACT IS DECLARED, NOT RETYPED: what I expect from Claude Code in EVERY project lives in /etc/claude-code/CLAUDE.md, generated by system/services/claude-code.nix, and NOT at the top of each prompt. This is rule 3 seen from the assistant's side, and manual has the failure mode manual always has: it works until the day I forget, and the day I forget produces a repo in two languages with one blob commit signed by a coauthor I did not want. THE THREE MANDATORY ONES ARE RULE 17's, promoted from this repo to the whole machine: incremental commits (one per feature/task), everything in en-US, and NEVER a Co-Authored-By: trailer. WHY THE MANAGED LAYER (/etc) AND NOT $CLAUDE_CONFIG_DIR/CLAUDE.md, which is the path everybody knows: the user file is PER ACCOUNT, so with the two accounts of home/shell/claude-code.nix it would be two copies of the same text drifting apart, and Claude Code WRITES to it (the # shortcut appends a memory to exactly that file), so declaring it would put two owners on one artifact (rule 14). The managed file is read-only by nature and CC only ever reads it. IT COSTS CONTEXT IN EVERY SESSION on this machine, this repo's included, so it holds the rules and NOTHING else: the reasoning behind each one is here, where whoever wants it can come and read. A project's own CLAUDE.md REFINES it and does not contradict it, because what is specific to a repo (its commands, its layout, its traps) belongs to the repo.
  19. A MODULE DECLARES ITS PACKAGES AT THE TOP, ONCE: the let opens with an inherit (pkgs) ...; naming everything that module reaches for, and the body uses the bare name. THE POINT IS MAINTENANCE AT SIZE, which is the problem this repo actually has: 126 .nix files and growing, so "what does this module pull in" has to be answerable by looking at one block, not by reading 29 shell interpolations, which is what lockscreen.nix cost before the sweep of 29/08/2026. THE IDEA IS NOT MINE, IT IS NIXPKGS': a derivation's header ({ lib, stdenv, curl, jq }:) IS its dependency list, filled in by callPackage. A NixOS module CANNOT have that, and it is worth knowing why before trying: the arguments are fixed by the module system (config, options, lib, pkgs plus specialArgs), so { pkgs, curl, ... }: fails at eval, and forcing it through _module.args = pkgs would dump the whole of nixpkgs into every module's scope and risk infinite recursion, since module arguments resolve before imports do. The let is the closest legitimate form. WHAT IT BUYS BEYOND READING BETTER, and this is the half that survives me forgetting: deadnix checks it, because an inherited name that stops being used is an unused binding and fails nix flake check and the pre-commit hook, so rule 16 stops depending on somebody remembering to audit. And it KILLS THE MIXED-SCOPE LIST, which was a real bug in waiting here and not a style complaint: runtimeInputs = with pkgs; [ systemd coreutils gawk streamActive ] in sunshine.nix and home.packages = with pkgs; [ minimizeOthers wl-clipboard ... ] in hypr.nix both mixed packages with shell applications built a few lines above, resolved by with losing to a let binding, with NOTHING on the page saying which name was which. That is the case nix.dev has in mind when it says not to use with: it defeats static analysis and hides where a name comes from. WHAT DOES NOT GO IN THE BLOCK, because a list captioned "the module's packages" that holds something else is a lie, which is rule 16's drift: platform queries keep their prefix (pkgs.stdenv.hostPlatform.system is not something a module installs); namespaces are resolved to the attribute actually used (inherit (pkgs.kdePackages) kconfig;, never the whole set); and the unstable channel is the deliberate exception that STAYS a namespace, inherited as itself so every use site still reads unstable.spotify, because the table in notes/repo/packages.md is kept honest by grepping for unstable. across the tree and a package that stops spelling out its channel vanishes from that grep while still being on it. WHERE IT DOES NOT APPLY: a FLAT INSTALL LIST (home.packages, environment.systemPackages, fonts.packages, hardware.graphics.extraPackages) keeps its with pkgs;, since every name in it comes from the same place and there is nothing to disambiguate; and a module with no let block and one or two single-use references gains nothing but five lines of preamble (mouse.nix, razer.nix, vm-disko.nix, git.nix, cli.nix, left alone ON PURPOSE, so do not read them as an unfinished sweep). HOW TO VALIDATE A SWEEP LIKE THIS, and it applies to any change that claims to be textual: read the system's drvPath before the first edit and after every commit, and it must not move. On 29/08/2026, across 35 modules, it stayed 5hakbijzd1nq2fxh6mxf3vp3rfvglgds, and since home-manager enters as a NixOS module that ONE hash covers both trees.