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.
- Always research the best practices and what the NixOS community is using most for each package/software (to have a reference and suggestions)
- 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 readingdocs/. So the reasoning MOVED instead of being deleted:notes/<module>.mdholds 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.- Always declarative and never "manual" (so it works on any hardware later on)
- Keep
system/andhome/apart: system level (services, drivers, root packages) insystem/; the app and the user config inhome/(programs.*when there is a module, otherwisehome.packages). Never the same package in both.- Organize by category: each subject in its own subfolder with its
default.nix(adding a module = 1 line in the category'sdefault.nix; the top level does not change).- Nix = app + config; state = restic: saves, Wine prefixes, app tokens/sessions are not declared, they go to the backup.
- 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).- Validate before applying:
nixos-rebuild build/nix evalOK and atomic commits per feature/task, before the switch.- Everything in the TokyoNight theme, centralized in a Nix PALETTE of my own (
home/desktop/palette.nix, optionmy.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.- The UI FONT has its OWN SSOT, separate from the colors:
my.fonts.uiinsystem/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 throughosConfig.my.fonts.ui, never as a literal.- SSOT ALWAYS: a value repeated in 2+ places becomes a
my.<domain>.<thing>option and a consumer NEVER holds a literal. Today those aremy.theme.name/.palette(colors, rule 9),my.fonts.ui(font, rule 10) andmy.services.<n>(optional services). The option lives at the LOWEST level that needs it: if any module insystem/consumes it, it is a system option andhome/reads it throughosConfig. 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.- 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/storeis world-readable, so a secret interpolated into a derivation LEAKS. Editing a secret requires arebuild, otherwise/run/secretsdoes not update.- The
flake.lockPINS the dependency universe: nonix-channel, no fetch without a hash, no implicit "latest". Bumps only throughupdate/upgrade, andupdateruns 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.- 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]).- 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 withgraphical-session.target, which onlyexec-oncebrought up).- 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 calledcaddy_pos_hash_v1cferr/_jporphans because the grep over.nixreturned zero, and they were declared the whole time throughsecrets/bitwarden-secrets.json, whichsystem/core/secrets.nixwalks withmapAttrs. Deleting them from the vault alone created "declared and absent", whichvalidateSopsFilesfails at BUILD time (andsync-secretswould 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 insecrets.yaml, never by a textgrep. It holds for every index-to-generator pair: what declares can be JSON, not Nix. And dead config BITES, it does not just clutter: therequiredSecretsin 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:xembedsniproxycited in 3 comments without EVER having been installed;tray-native-menu.shpointing at the path of a waybar that was already removed; a.envat 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-dumpproducingextensions.txt), or a direct write into the repo throughmkOutOfStoreSymlink(settings.json/keybindings.json, the same link ashyprland.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 ("replacestrustedInterfaces = [ tailscale0 ]") is rule 2 working, not drift. What dies is the executable DECLARATION, not the memory of why it existed.- 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 ofdocs/, the.nixtree, 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 (themy.archAntigooption, thearch-antigo-mountunit,/mnt/arch-antigo, the "Arch antigo" Dolphin bookmark,/srv/media/media/Filmes). RENAME WITHgit 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.mdbecamedocs/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 aCo-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 optiondescription. 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 survivegrep, 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 statusprints 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, thev1cferruser account, paths under/home/v1cferr.- THE AGENT CONTRACT IS DECLARED, NOT RETYPED: what I expect from Claude Code in EVERY project lives in
/etc/claude-code/CLAUDE.md, generated bysystem/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 aCo-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 ofhome/shell/claude-code.nixit 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 ownCLAUDE.mdREFINES it and does not contradict it, because what is specific to a repo (its commands, its layout, its traps) belongs to the repo.- A MODULE DECLARES ITS PACKAGES AT THE TOP, ONCE: the
letopens with aninherit (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.nixfiles 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 whatlockscreen.nixcost 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 bycallPackage. 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,pkgsplusspecialArgs), so{ pkgs, curl, ... }:fails at eval, and forcing it through_module.args = pkgswould dump the whole of nixpkgs into every module's scope and risk infinite recursion, since module arguments resolve before imports do. Theletis 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 failsnix flake checkand 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 ]insunshine.nixandhome.packages = with pkgs; [ minimizeOthers wl-clipboard ... ]inhypr.nixboth mixed packages with shell applications built a few lines above, resolved bywithlosing to aletbinding, with NOTHING on the page saying which name was which. That is the case nix.dev has in mind when it says not to usewith: 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.systemis not something a module installs); namespaces are resolved to the attribute actually used (inherit (pkgs.kdePackages) kconfig;, never the whole set); and theunstablechannel is the deliberate exception that STAYS a namespace, inherited as itself so every use site still readsunstable.spotify, because the table innotes/repo/packages.mdis kept honest by grepping forunstable.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 itswith pkgs;, since every name in it comes from the same place and there is nothing to disambiguate; and a module with noletblock 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'sdrvPathbefore the first edit and after every commit, and it must not move. On 29/08/2026, across 35 modules, it stayed5hakbijzd1nq2fxh6mxf3vp3rfvglgds, and since home-manager enters as a NixOS module that ONE hash covers both trees.