History: august 2026¶
88 entries. Index in README.md.
- [x] Disk monitoring, and the obvious answer (atime) being the wrong one (30/08/2026).
664 GiB used of 953. The alarm from 30/07 existed and had NEVER fired once (it speaks below
100 GiB free, and there were 282), so there was no data behind "what can I delete".
• THE LIST HAD ROTTED IN A MONTH, which is the lesson and not an aside.
/srv/mediawas off by 3x (132 to 45),~/Downloadshad gone 2.5 to 35 GiB, and~/.confighad become the 4th biggest consumer at 27 GiB WITHOUT EVER BEING ON THE LIST, so the alarm was structurally blind to it. A hardcoded ranking is a snapshot pretending to be a policy, which is why the answer was a trend LOG and not a better list. • ATIME REFUSED, TWICE OVER, and this was the whole design decision.lazytimedoes not exist on btrfs: the original series covered it and that support was dropped before merge over dirty inode tracking, so the mount ACCEPTS the option and silently does nothing. And plainrelatimeis expensive precisely here: its documented worst case is a file older than 24 h that is SNAPSHOTTED, because the atime update has to CoW the metadata shared with the snapshot. btrbk keeps 36 snapshots of@homeand@homeholds 370 GiB of games, so opening Diablo IV once reads ~88 GiB against 36 snapshots. Upstream's example is 4 GiB with 10 snapshots taking metadata from 300 MB to 2.59 GiB. Turning atime on would have SPENT disk to measure disk. • SO IT SAMPLES/procINSTEAD, which is also the better question: whether the game RAN, not whether something read its files (an indexer and a backup do the latter without anyone playing). The rule that keeps it honest:exeandcwdare EVIDENCE,argvis only a MENTION and counts for launchers alone. RPCS3 needs argv (its binary is in/nix/storeand the ISO is an argument), but trusting argv everywhere makes any shell that types the path look like a session, which I tripped over while testing. The ignore list is the backstop for the sweepers,cs2-saves-backupbeing the one that would have marked Cities Skylines II played forever, hourly, from its rsync argv. • THEduTRAP THAT ALMOST SHIPPED SILENT:dudeduplicates by inode WITHIN one invocation, so a parent and something nested under it cannot share a call, the nested one disappears from the output ENTIRELY, not even as a zero. Every game path sits under a bucket path, so the first trend snapshot logged the 11 buckets and lost all 7 games, and nothing looked wrong: a shorter file is not an error. Two invocations, two inode sets. • DOCKER HAD NO VOLUME POLICY AT ALL, worth 4.5 GiB in 36 orphaned anonymous volumes of ~127 MB, one per container recreation.system prunedoes not touch volumes and the only NixOS knob isallVolumes, refused here because it takes named ones. That refusal is now DEMONSTRATED rather than argued: with grad-radar merely stopped,caddy_configandcaddy_datawere already dangling.docker volume prunewithout-ais the middle ground the option does not expose, anonymous only by construction. Safe to lose too, since grad-radar declares one on purpose for the container venv and docker refills it FROM THE IMAGE on first use: a copy at start, not a rebuild. The system prune also went daily, because six days after the last run the build cache was back to 10.45 GB. •~/.cacheAND THE TWO REAPERS THAT WOULD HAVE BEEN BUGS.-atimeis meaningless undernoatime(every access time is frozen at the day the file landed on this disk), and-mtime -deletewould CORRUPT rather than trim, because~/.cache/nix/tarball-cache-v2is 1.2 GiB of bare GIT OBJECT STORE and nix would only notice at the next unresolvable flake input. What was left wasuv cache prune, which knows what is still reachable: 23230 files and 1.1 GiB on the first run. It exits 0 when it loses the lock, because an ad-hocuv run ... servewas holding it and the default 300 s wait ends in exit 2, and a weekly unit that goes red to say "somebody was building" is one you learn to ignore. • NOT SHORTENED, and measured before deciding:nix.gc's--delete-older-than 30d. The store had already fallen 58 to 46 GiB on its own, the CURRENT system closure alone is 25.9 of those, and only 4 generations exist. A shorter window would trade real rollback depth for a couple of GB thatnix-optimiselargely shares anyway. -
[x] Secure Boot ON in both systems, and the two gates that were NOT the signature (30/08/2026). The keys had been enrolled since 02/08 and the runbook said the only thing left was flipping it in the BIOS. Flipping it killed BOTH entries, and neither failure was a bad signature: the firmware accepted
grubx64.efievery single time, and what refused was GRUB, twice, for two unrelated reasons. • GATE 1,verification requested but nobody cares.kern/efi/init.c:123callsgrub_lockdown()UNCONDITIONALLY when SB is on and only THEN the shim_lock setup, so--disable-shim-locktakes out the verifier that WOULD answer and leaves the lockdown one, which merely defers (kern/lockdown.c:55);verifiers.c:118refuses a file that everybody deferred and nobody claimed. The fix istpmEMBEDDED in the module list: itsinitreturnsSINGLE_CHUNK(commands/tpm.c:33), so it claims the file, measures it into PCR 9 and lets it through. Embedded and not on disk, because lockdown defers onGRUB_MODULEtoo, and it only works because this board's TPM 2.0 publishes TCG2. Measured afterwards: PCR 9 is not zeroed. • GATE 2, the kernel, and WINDOWS is what proved it: with gate 1 open, Windows 11 booted with SB on and NixOS still did not. Onx86_64-efithelinuxmodule isloader/efi/linux.c, and line 211 loads nothing, it hands the buffer to the FIRMWARE'sLoadImage, which validates againstdblike any other image.bootmgfw.efipasses on its own, since Microsoft signed it and that CA is indbbecause of theenroll-keys -m. The locally builtbzImagehad nobody to sign it, sosecureboot.nixnow also sweeps/boot/kernels/*bzImage, with no-s: the name carries the store hash, the GC deletes it, and recording it would fill sbctl's database with dead entries. • THE CHAIN IS WORTH MORE THAN THIS REPO CLAIMED, for a reason that is not GRUB's doing.boot.mdsaid verification stopped at GRUB. It does not: GRUB itself checks nothing, sincetpmmeasures and flag 2 literally says "do not verify anything after me", but the kernel leaves throughLoadImage, which puts the firmware back in the loop. What stays uncovered is the INITRD, handed over by GRUB's own protocol and filed under "does not affect secureboot state" (sb.c:149) even on the shim road. Closing that means a UKI, which means lanzaboote, and then there is no menu and no theme. • A PREFLIGHT NUMBER THAT WAS A GUESS almost aborted a binary that was correct: the runbook demandedgrubx64.efiat ">= 1.5 MB" and the signed image is 1.16 MB. The honest check is a reference image, not a round number:grub-mkimagewith the same 48 names gives 1159168 bytes against 1161272 on the ESP, and the 2104 of difference are sbctl's PKCS#7 signature. Without the list it would be 139264, which is what that threshold was really trying to catch. The whole mechanism: docs/notes/boot-and-storage/boot.md -
[x] grill-me declared as a managed skill, so both accounts get it (27/08/2026). It is an interview that grills a plan until every branch of the decision tree resolves, and the most installed skill in the ecosystem after the
find-skillsthat everynpx skillsinstall carries along. It went into/etc/claude-code/.claude/skills/, next to the managedCLAUDE.md, which is rule 18 gaining a third item. • TWO SKILLS AND NOT ONE:grill-meis a one-line SHIM that calls the Skill tool withgrilling, so installed alone it is a command that does nothing, and it fails SILENTLY: the agent guesses what grilling means and dumps every question at once. • THE LAYER WAS CHOSEN BY MEASUREMENT (2.1.234), since a skill that is never read fails exactly like a skill being ignored: the managed loader readsjoin(managedDir, ".claude", "skills"), which is/etc/claude-codeon Linux, while the personal one followsCLAUDE_CONFIG_DIRand here would be~/.claude-fai/skillsAND~/.claude-pessoal/skills, two copies of one artifact (rule 14). What makes the Nix delivery possible at all is that CC FOLLOWS a symlink in that directory, so the entry is the store path itself. • WHAT WAS REJECTED, and the tempting one was a single line:enabledPluginswithmattpocock-skills@claude-plugins-official, a marketplace already registered here, fetches the CONTENT at runtime intoplugins/cache/, so the version becomes the day of the fetch instead of the lock (rule 13) and git sees none of it (rule 16). The whole input as a@skills-dirplugin would pin all 25 skills with ONE symlink, but the docs list that shape for the personal and project directories only, and 25 is not what I want machine-wide. • THE CRITERION FOR THIS LAYER IS STATELESSNESS, not popularity: the neighbouringgrill-with-docsandimprove-codebase-architecturewrite aCONTEXT.mdglossary and ADRs underdocs/adr/, a second documentation system alongsidedocs/, which already splits by function. They stay out, and belong to the.claude/skills/of the repo whose conventions they obey. • CONTEXT WAS NOT THE ARGUMENT, and the correction is worth keeping because it was my first one: the listing of names and descriptions gets 1% of the model's window, so on 1M even the full set of 25 fits with room to spare. What is expensive about a machine-wide skill is its blast radius. The detail: docs/notes/apps/claude-code.md -
[x] One memory for the three agent CLIs, declared and pinned (24/08/2026, second piece of the day). The
~/contextMarkdown is the PRODUCT; basic-memory is the index over it, and that order is the whole design: the day the index stops paying for itself, the directory is still readable and still an Obsidian vault. • WHY IT NEEDED uv2nix: it is in no nixpkgs channel, and its tree says why nobody bothered.fastmcp==4.0.0b1pinned to a BETA, plus fastembed with ONNX, litellm, sqlite-vec, asyncpg and psycopg. uv2nix builds a package set FROM auv.lock, so the exact resolution is the pin and every wheel carries its hash, which is rule 13 satisfied by a lock instead of by hand. Measured: 173 packages, 712 MiB closure, two sdists needing a build-system fix (pybars3andpymeta3never declared setuptools). • THE WORKSPACE IS MINE, not upstream's, and that was the second good decision.pkgs/basic-memory/pyproject.tomldeclares ONE dependency and the lock next to it is the pin. Upstream builds throughuv-dynamic-versioning, which reads the version from GIT metadata, so building their tree from a tarball would mean faking a tag; the published wheel does not have the problem.prerelease = "allow"has to be in that file or uv refuses the resolution outright, withBecause there is no version of fastmcp==4.0.0b1. • HTTP AND NOT STDIO, which is the default everywhere and the wrong default here: each client SPAWNS its own server over stdio, so three processes would index and write one SQLite over one directory. Three owners of one artifact (rules 14 and 15) before the first note gets written. One systemd user unit on loopback, three clients, and the handshake answers protocol 2025-06-18. • THE CONFIG QUESTION GOT ITS THIRD ANSWER IN ONE DAY. codex tolerates a symlinked mirror,agyclobbers it, and basic-memory needs neither: its 88 settings all read fromBASIC_MEMORY_<KEY>, so the unit'sEnvironment=owns what I declare and~/.basic-memory/config.jsonstays state. The tool itself reports the winner:bm config get auto_updateanswersOverridden by $BASIC_MEMORY_AUTO_UPDATE.PROJECT_ROOTis a guard and not decoration: every project stays under~/context, so an agent cannot point the knowledge base at another corner of my home. • THE FIRST-START TRAP: before the server has run once,bm tool write-notefails withNo projects are set up yetwhilebm project addanswers that the project already EXISTS. The config knows it and the database does not, and the MCP server's lifespan is what reconciles the two. So the unit comes first and the CLI second. • THE THREE CLIENTS, each with its own mechanism and none holding a literal (rule 11): Claude Code gets a generated--mcp-configwith"type": "http"on BOTH accounts, because splitting memory per account would rebuild the archive-per-agent I am leaving; codex gets[mcp_servers.basic-memory] urlin its versioned mirror, verified withcodex mcp list; andagygets a jq merge at activation, withserverUrland NOTurl, since the legacy key is refused and the failure mode is silence. • VERIFIED AFTER THE SWITCH, and two of the three answers look like failures and are not.agyprintsRestarted server: basic-memory; codex reportsenabledwithauth_status: unsupported, which is a local server correctly advertising no OAuth; andclaude mcp listdoes NOT show it at all, because that command lists CONFIGURED scopes and this arrives through--mcp-config, exactly like the Azure MCP that has been invisible there for weeks. The proof was the permission error namingmcp__basic-memory__recent_activity, and a fresh session answering from the index once the permission was declared. •~/contextIS A GIT REPO NOW, with archive/knowledge/sources/schema, the frontmatter contract and one worked example. And the index WRITES BACK: withensure_frontmatter_on_syncthe watcher addedtitle,typeandpermalinkto the README seconds after I saved it, touching the frontmatter and never the body. • WHAT I DID NOT DO:uvx(a fetch with no hash at runtime), the official container (a Docker daemon in the loop for a local CLI), and their paid cloud (the entire point is that the files stay here). The importers that made this worth doing are real and in the source, not just the docs:import_chatgpt,import_claude_conversations,import_claude_projectsandimport_memory_json. There is NO Gemini importer, so a Takeout needs a converter of my own. -
[x] The third agent CLI is NOT gemini-cli, because that door closed in June (24/08/2026). I went to install it on the same terms as Claude Code and codex, a subscription login and no API key, and the research killed the premise before the first line of Nix. • THE FACT: Google announced the transition to Antigravity CLI on 19/05/2026, and on 18/06/2026 the Gemini CLI STOPPED SERVING free tier, AI Pro and Ultra. Only paid enterprise licenses kept it. The CLI's own answer is
UNSUPPORTED_CLIENT ... migrate to the Antigravity suite of products(google-gemini/gemini-cli#28846, open), and nixpkgs already evaluates the package with aproblem: removalmarker naming the replacement. • THE UPSTREAM README STILL ADVERTISES THE FREE TIER with a personal Google account, which is the trap worth recording: a project's own front page is not evidence about a backend that was switched off. What settled it was the error message plus the issue tracker plus the distro's marker, three sources that agree. • WHAT WENT IN:pkgs/antigravity-cli.nix, the official release binary,agy. Upstream and not nixpkgs for the codex reason, counted today: the publisher'slatestsaid 1.1.20 andnixpkgs-unstablewas on 1.1.13, with the nixpkgs bumps landing every 8 to 10 days against an upstream that ships almost daily. One file in the tarball, Go, glibc-linked, soautoPatchelfHookis the whole build, and ripgrep is EMBEDDED (its own error string saysno embedded ripgrep binary), so unlike codex there is no wrapper and no PATH dependency. • THE MEASUREMENT THAT DECIDED THE CONFIG: I tried the codex contract, amkOutOfStoreSymlinkto a mirror versioned here, andagyREFUSES it. Pointed at a mirror holding{}, the first write brought the path back as a regular file with the app's own content while the mirror still held{}; pointed at a mirror that already had the key, the symlink survived because nothing was written. So the link only holds while the app has nothing to say, which is drift wearing the costume of ownership (rule 14). Nothing under~/.geminiis declared: the package is the declaration, the rest is state and restic already covers it. • THE BUMP IS THE CHEAPEST OF THE FOUR. "Did it change?" is alatestfile holding the bare version, and the release manifest publishes the sha512 of every platform, so the new hash is a base16 to SRI conversion withnix hash convertand NOT a 56 MiB download. Three values move together, not two: the URL carries an opaque build id (6563996145418240) that is not derivable from the version. Tested by rolling a copy back to 1.1.13 and running it: the file it produced is byte for byte the one I had written by hand for 1.1.20. • AND IT UNCOVERED A LIVE BUG.codex-bumphad been in the overlay and in the flake's packages since 19/08 and on NOBODY'S PATH, so theupdatealias, which calls it by name, died atcodex-bump: command not foundand, because the chain is&&, never reachednix flake update. Five days of an update that updated nothing and said so only in a line nobody reads. vscode-bump and curseforge-bump were right all along: each is installed next to the thing it bumps, and codex-bump now is too. • WHAT IS NOT DONE: the login itself, which is mine to do in a browser, and the shared-memory question that started all of this.~/.gemini/config/mcp_config.jsonis where an MCP server would land foragy, and basic-memory is the candidate; it is an open item, not a decision. -
[x] The disk layout got formatted from scratch in a VM, and it found a first-boot bug that had been there since the cutover (23/08/2026, last piece of the day). The layout is the one thing in this repo that cannot be fixed after the fact, and until today nothing checked it. •
nix run .#disko-vmbuilds a 24 GiB image, runs the REAL disko script against it, boots the config on the result and PRINTS a report: subvolumes, btrfs mounts with their real options, the ESP, the active swap, and any failed unit with its journal. A console you cannot log into shows nothing, so the report is the interface. • THE REPORT PROVED THIS MORNING'S CLAIM IN BOTH DIRECTIONS.@swapcame up withrelatimewhile every other subvolume hasnoatime, because atime is a VFS flag and IS per mount. And@swapcame up WITHcompress=zstd:1, which this config never asks for there, because compression is a btrfs option and the FIRST mount decides for the whole filesystem. The swapfile works anyway, which proves what matters ismkswapfile's attributes on the FILE. • THE BUG:home-manager-v1cferr.servicefailed withcd: /home/v1cferr: No such file or directoryand/homewas empty. The chain, every step measured inside the VM: 26.05 boots with the systemd initrd by default and the WHOLE activation runs there (stage 2'snixos-activation.servicelogs literally "-- No entries --"); at that point only theneededForBootfilesystems are mounted and@homeis not one; socreateHomecreated/home/v1cferron@; stage 2 then mounted@homeover/homeand MASKED it. Proven by mountingsubvolid=5inside the VM and findingdrwx------ v1cferr users @/home/v1cferrsitting there invisible. • WHY IT NEVER SHOWED UP HERE: the cutover copied files into@home, so the directory existed, and any laterswitchruns the activation with everything mounted. It only bites on a FIRST boot after a fresh install, which is exactly the disaster-recovery path. A machine restored from this repo would have come up with an empty home and a broken home-manager. • THE FIX issystemd.tmpfiles.rulesinsystem/core/users.nix, because tmpfiles runs in STAGE 2, afterlocal-fs.target. The path is read fromconfig.users.users.v1cferr.homeinstead of becoming a ninth copy of the literal. Verified in the same VM: home in the right place, nothing masked underneath, zero failed units. • THE BOOT TEST CANNOT CATCH IT, and that is why both VMs exist:vm-boot.nixclearsdisko.devices, so there is no separate/homefor anything to mask. • TWO MORE THINGS THE REPORT SHOWED, neither of them declared anywhere here: systemd createssrv,var/tmp,var/lib/machinesandvar/lib/portablesas subvolumes INSIDE@(which is the /srv risk the impermanence item flags, now with evidence), andspace_cache=v2is on by default. • AND THE AGE KEY QUESTION got answered in a guide instead of a.env: the key does not enter the repo, because this one is public, a working-tree key is onegit add -ffrom being published with no way to rotate out of it, and$HOMEis inside restic's reach. The drill reads it into the ENVIRONMENT or a tmpfs, which is the same rulesync-secrets.shalready follows withsudo cat. -
[x] The CI stopped paying full price on every push, and the hash pins got a bot (23/08/2026, after the canary). Two small pieces that only make sense together with what came before them. • THE STORE CACHE,
nix-community/cache-nix-action@v7keyed onflake.lock. Until now every push refetched ~1.43 GiB of inputs and recompiled the btop fork.nix-communityand NOTmagic-nix-cache: that one is Determinate's and pulls toward FlakeHub Cache, which is the same vendor objection already recorded against their installer. This one uses GitHub's own cache, so no account and no third party. • THE CONFIGURATION IS ALL CAVEAT, and the caveat is the 10 GB per REPOSITORY with LRU eviction:gc-max-store-size-linux: 5Gcollects the store down before saving,purge-createddrops entries older than a week andpurge-primary-key: neverprotects the one the run just wrote. The key is the lock's hash, so a bump is a deliberate miss and the prefix fallback restores the newest older entry instead of nothing. • THE CANARY GETS NO CACHE, on purpose, and it is worth writing down because the instinct is to cache the slowest job: it resolves every input at HEAD, so its paths change every week, the hit rate would be near zero, and its saves would EVICT the gate's entry, which is the one that actually pays. A cold canary is also the honest canary. • DEPENDABOT, actions only, weekly, grouped into ONE pr, withcommit-message.prefixset tochore(ci)so the bump satisfies rule 17. It closes the cost accepted earlier in the day when the actions were pinned by hash: a hash is immune to somebody moving a tag under me, and equally immune to a security release, so it needs a bot or it needs me remembering. • actionlint and zizmor over both workflows after the change: clean, no findings. -
[x] The canary went in, and today the answer is that nothing upstream is broken (23/08/2026, closing the day). Everything until now answered "is this tree correct". This asks the other question, the one whose answer changes with NO commit of mine: is the world still compatible with this tree? •
nix flake check --recreate-lock-file --no-write-lock-fileresolves EVERY input at its branch head and leaves the committed pin alone. So a failure means "the nextupdatewould break you", and nothing in it measures the AGE of a pin, which is the precise objection that gotflake-checkerrejected on 16/08: that one fights a release pin on purpose. • MEASURED, warm store, locally, on a throwaway copy so the real lock could not be touched: 1m41s, andall checks passedagainst the heads of nixos-26.05, nixos-unstable and release-26.05. The lock of the copy came out byte-identical, which is what--no-write-lock-filepromises and what I wanted proof of before putting it in a workflow. • WEEKLY AND IN ITS OWN WORKFLOW, not in the gate: it refetches ~1.43 GiB on a cold runner, so nothing should block on it, and a red canary is information rather than a blocked push. The alarm is GitHub's own email for a failed scheduled run, so there is no machinery to maintain. • THE SECOND JOB is lychee, and it can NEVER be a gate hook for two structural reasons: a build sandbox has no network, and a 429 from somebody's rate limiter is not a defect here. It is declared at themanualstage, so the linter set still has ONE definition inflake.nixwhilepre-commit run --all-filesskips it. • MARKDOWN ONLY, and that was measured both ways. Over the.md: 15 external links, 278 checks, 0 errors, with no config file and no exclude list. Over EVERY tracked file: 17 errors, all false, and they name themselves (the DoH endpoints answering 400 to a GET with no DNS query, the loopback and LAN addresses of this machine, CurseForge's 403 bot wall). A URL in a code comment is a citation, not a link a reader follows, and the exclude list needed to silence 17 of them is the "lint you learn to ignore" this repo keeps arguing against. -
[x] The config got BOOTED on hardware that is not this machine, and the test found a bug before it ever booted (23/08/2026, second half of the day). The question was drift: rule 8 proves the tree evaluates and builds, and nothing proved it BOOTS anywhere else, which is the only question that matters the day the hardware dies. •
nix build .#vm-bootboots this host in QEMU and asserts the config was APPLIED, not just that it evaluated: multi-user reached, sshd up, the user existing with zsh, the whole home-manager generation activated withResult=success, and no failed unit. MEASURED: 9.96s to boot (666ms kernel, 3.675s initrd, 5.618s userspace), 11.26s for the whole test. • IT FOUND A REAL BUG AT EVAL TIME, before QEMU ever started:my.services.jellyfin = falsedid not evaluate, becauseusers.users.jellyfin.extraGroupssat outside the toggle and left a half-declared user. The panel inhosts/<host>/services.nixwas offering a switch that did not work, and nothing could have caught it while this machine kept jellyfin ON. That is the whole class this test exists for: a declaration that only works because another module happens to complete it. • THE FIRST VERSION OF THE TEST LIED, and this is the part worth remembering: it asserted only onsystemctl --failedand went GREEN while two activation snippets had failed (setupSecretsandsetupSecretsForUsers, both needing the age key that lives outside git by design). Activation snippets are not units, so they never show up there. A boot test that cannot see half of the activation is a green light over a broken system. It now parses the journal, with an ALLOWED of exactly those two. • THE WEEKLY TRIGGER is asystemd --usertimer, Sundays at 11:00, Persistent, silent on success and one ntfy push on failure. User level because the flake has a private input over git+ssh and the key is the user's, the same reason rule 13 keepsupdateas the user. And a cache HIT is the normal case: the test is one derivation over the config, so nothing changed means it returns from the store in seconds and no VM boots. That is exactly "re-verify what is new, pay nothing for what was already verified". • THE LAST 36 UNCOVERED FILES got a checker in the same movement: the 8 Lua throughlua-lsreading the repo's OWN.luarc.json(plain JSON, sofromJSONworks where.markdownlint.jsonccould not),scripts/router-sync.pythroughruff(the only loose.py, and the one that writes to the router), and the 27 QML through a parse check. • QMLLINT AS A WHOLE IS UNUSABLE HERE, measured: 2321 findings bare, 2267 with Quickshell's own QML types on the import path, and what survives is a linter that does not model Quickshell (1014unqualified, 14 callingPanelWindownot creatable, 14 rejectingcolor: "transparent"). So the check keeps ONE category,[syntax], which is a file that does not parse. THE DISCRIMINATOR IS THE CATEGORY, NOT THE SEVERITY: a parse failure prints asWarning, and qmllint exits non-zero for any warning at all. • AND THAT ONE LIED TOO, on the first attempt: piping 2267 lines into grep through a shell variable died with "Argument list too long" AND still exited 0. Twice in one day a check that could not fail, which is the failure mode to look for first when a new check goes green immediately. -
[x] The gate stopped depending on my memory in five places (23/08/2026). The question that started it was not "which linter is missing", it was whether this repo can keep being the SSOT of my infrastructure until 2032, and the answer that came out of the research is that the risk is not Nix dying: it is a rule of mine that only exists because I remember it. Five of those became executable, and every one of them was MEASURED before being turned on, so all five land as regression guards and not as cleanups. • MARKDOWNLINT, over 73
.md: 0 findings. The.markdownlint.jsonchad existed since the docs split with no executable owner, so the editor obeyed it and nothing else did. The hook's ENTRY is overridden to read that same file, because itssettings.configurationwould generate a second config and give the ruleset two owners (rule 14), andfromJSONcannot read the file back: it is JSONC and the//comments break the parse. • THE SOPS HOOK, and its default filter is WRONG here:^secretsflagssecrets/bitwarden-secrets.json, which is the index of names and is plaintext by design. Narrowed to the yaml. What it buys is the one accident rule 12 cannot survive and neither of the existing guards catches: asecrets.yamlstaged in the clear after asops -d. • THE WORKFLOW GOT LINTED for the first time, with actionlint and zizmor, and it was the only executable file in the repo with no checker. Three findings, all fixed: the two actions were pinned BY TAG (a pointer the author can move, the same class of trap as VS Code's/latest/, so now pinned by hash with the tag in a comment), the job had nopermissions:block and inherited write scope for the token, andcheckoutwas leaving the token in.git/configfor every later step. • RULE 17 BECAME EXECUTABLE, which is the one I care about most:convcofor the commit grammar ("no errors in 40 commits" before it went in) andpkgs/prose-style.nixfor the three mechanical bans, in the tree and in the message. The history is the argument: 3 commits carry aCo-Authored-Bytrailer, written before the rule existed. This stops the fourth. • THE NAIVE VERSION OF THE PROSE CHECK IS WRONG, and measuring came first: all 18 em dashes in the tree are LEGITIMATE, since rule 17's own exception is the em dash as a literal. So a code span, a fenced block, a quoted glyph of at most 3 chars and a table cell whose whole content is the dash all drop out, and in code only the comment counts, with the string literals stripped FIRST, which is also what keeps a hex color from reading as the start of a#comment. 236 files of prose, 0 findings. • THE TRAP THAT BIT ME WHILE DOING IT: a new hook does not exist locally until the devShell shellHook runs again (the installer compares before writing), so my own probe message with an em dash AND a trailer went in on the first attempt, refused only afternix develop --command true. It looks like the hook is broken when the hook is simply not installed yet. • WHAT WAS RESEARCHED AND NOT DONE is in ideas.md: the store cache with no vendor, the weekly canary that measures BREAKAGE instead of the age of the pin, whysystem.build.toplevelon a free runner is not worth it, the QML and Lua that still have no checker, the milestone tags and the disaster-recovery drill in a VM. The two follow-ups this day created (dependabot for the hashes, and the message being checked only here) are in open-items.md. -
[x] The T480 ended the day with TWO paths instead of one, and a reboot proved the unattended one (22/08/2026, closing the day). What forced the second path was a measurement, not a preference: with a stream running, closing the laptop lid froze Moonlight, video and input alike, while the machine stayed perfectly alive underneath, tunnel up and SSH answering. • THE LOG NAMED IT:
Failed to locate an output device, and on the next attempt the capture INITIALIZES, enumerating 1920x1080 at 60 Hz and creatingh264_qsv, and then delivers no frame. The tunnel counters, read on the ROUTER so the machine could not lie about itself, said 50 KiB in three minutes. Sunshine duplicates what is being DISPLAYED, and a closed lid displays nothing. It is this repo's own DPMS lesson, met again on Windows. • SO RDP IS NOT A SECOND WAY TO DO THE SAME THING. It creates a session instead of duplicating a screen, which is why it works with the panel off. Moonlight to see HER screen while she uses the machine, with her watching what I do; RDP to work on it with nobody in front. A virtual display driver or an EDID dummy plug would have made Moonlight work headless, and both were passed over for the same reason: they put a SECOND screen on the desktop of somebody who will not go hunting for a window that vanished. • THE REBOOT TEST FOUND A BUG TEN MINUTES BEFORE IT RAN, which is the best argument for running it at all.TermServicewasDEMAND_STARTand, bysc qtriggerinfo, registered for no trigger at all: enabling RDP through the registry does not touch the service, so after a reboot nothing would listen on 3389 until somebody logged in ON the machine, which is precisely the situation RDP exists to be the way back from. • THE NUMBERS, with the lid shut, on battery, nobody touching it: boot at 16:54:34, WireGuard handshake back at 16:55:03, ssh and rdp answering at 16:55:09, Sunshine listening at 16:55:29. 29 seconds from boot to reachable, 55 to everything. That is the answer to "the power went out at her place". • AND IT ENDS ON THE LOGIN SCREEN, not in a session. Sunshine bound its ports at 16:55:29 as SYSTEM in session 1 with nobody logged in, and the first interactive logon came at 16:57:54, three minutes later, by hand. No autologon exists (AutoAdminLogonabsent, and ARSO would have left the session LOCKED, withLogonUIand a 4800 event, neither of which happened). So the machine stays password-protected AND reachable, which is the pair I had assumed would have to be traded against each other. • THE COMMAND GOT A NAME AND THE ADDRESS GOT AN OWNER. 90 characters of freerdp flags becamet480andt480 mae, a wrapper rather than an alias because the account has to be an ARGUMENT, and because a second literal of 10.10.10.6 is what rule 11 forbids.my.t480owns it now andssh.nixreads it, which maderouter-ssotfail loudly the moment the literal left, exactly as its own note promises for a moved anchor. • A SMALL ONE WORTH KEEPING:t480 maeRECONNECTS to her session, windows and all, whilet480disconnects her and starts a fresh one. One active session per Windows client SKU, and the choice of account is the whole difference between "help her with her thing" and "work on the machine". -
[x] Full tunnel became the T480's DEFINITIVE mode, and that decision drags four other things with it (22/08/2026, later the same day). The reason is hers and not the network's: she is going to use public Wi-Fi, and this house is more trustworthy than a mall's. • IPv6 DOES NOT LEAK, and it was measured instead of assumed, which matters because this is where a full tunnel usually fails quietly.
::/1and8000::/1sit on the tunnel interface at metric 0, andFind-NetRoutefor2606:4700:4700::1111answersmae-t480. The tunnel carries no v6, so the effect is a fast failure and a fall back to IPv4 INSIDE the tunnel. On a v6-enabled public network, without those two halves, a good share of her traffic would leave outside the tunnel with nothing to show for it. • THE DoH POLICY WAS THE MISSING HALF, not an extra. DNS-over-HTTPS happens INSIDE the browser's own HTTPS connection, so Chrome talks straight to Google and dnsmasq never sees the query: the traffic rides the tunnel and only the DNS escapes, which makes the filtering and the visibility that justify routing her through here fiction. Applied as MACHINE policy and confirmed in the registry. • THE WATCHDOG'S NUMBERS CAME FROM THE PROTOCOL, and they corrected a number I had recommended out loud an hour earlier. 180 s sounded fine and is wrong: WireGuard rekeys every 120 s and keeps retrying for up to 90 s, so a HEALTHY tunnel coming out of a hiccup reaches ~210 s with no fresh handshake, and a limit under that tears down what was about to recover. On a public network that teardown is exactly the exposure the tunnel exists to prevent. 240 s, and the real win came from the CYCLE: the task only evaluates everyIntervaloMin, so 5 min to 2 min took the worst case from ~15 min to ~6. • WHAT THOSE MINUTES ACTUALLY ARE: a captive portal. WithDNS = 10.10.10.1and the tunnel up, the Wi-Fi login page cannot open, because there is no DNS before the portal is passed. The watchdog is what gives her the internet back so she can log in, so its timer IS the time she spends looking at a page that will not load with no idea what to do. • THE PIN GOES THE OTHER WAY from what the panel suggests. Sunshine's/pinpage waits for a number the CLIENT generates, so "I do not know what PIN to type" is the expected state of somebody looking at the host.moonlight pair <host> --pin 4271chooses it up front, which turns a two-window dance into one number handed over. Paired:moonlight list 10.10.10.6answersDesktop. • AND THE CLIENT SIDE CORROBORATED THE ENCODER FINDING from the other direction:/serverinforeportsMaxLumaPixelsHEVCas0. The host itself advertises no HEVC, which is the same verdict its log gave, arrived at without reading a log. -
[x] My mother's T480 became reachable, and the interesting half of the day was everything that was NOT the streaming (22/08/2026). The machine is a ThinkPad T480 with Windows 11 IoT Enterprise LTSC, being prepared before going to her place, and it arrives here already a WireGuard peer with sshd, a firewall and power settings done. What this repo added is the client side, the router side and the checks; the Windows side has an owner already. • THE OWNERSHIP CALL CAME FIRST, and it is why there is no guide for this machine. It carries a repo OF ITS OWN (numbered idempotent scripts, an inventory, a
winget configureDSC file), so copying the procedure intodocs/guides/would give one procedure two owners (rule 14) and the copy nobody applies is the one that drifts (rule 16). Thecesarguide exists because that machine has no repo. What lives here is the CONTRACT: the peer address, the name the router answers, and that nothing is forwarded to it. • THE DRIFT WAS EXACTLY WHAT THE MIRROR IS FOR:router-sync diffcame back withdhcpandnetwork, aconfig domainansweringt480with 10.10.10.6 and awireguard_wg0peer namedt480-mae. The peer had handshaked 2 minutes earlier with 144 MiB in and 635 MiB out, which is the shape of config that WORKS and would never be missed until it broke. • NO DHCP RESERVATION FOR IT, on purpose: the machine leaves, so its LAN lease stops being an access path and pinning .191 would declare a value nobody reads. And nopersistent_keepaliveon the router side either, since the router has the public endpoint and learns the peer's from each handshake: keepalives have to come from behind the NAT, and putting them here would look like a fix and change nothing. • THE REACH IS CUT AT THE ROUTER, and it CANNOT be cut at her end. WireGuard's crypto-routing is bidirectional, so theAllowedIPsrange that lets my packets IN is the same one that would let her OUT.firewall.wg_t480REJECTs wg to lan for 10.10.10.6 and leaves lan to wg alone. •protoIS NOT OPTIONAL IN THAT RULE. Without it fw4 renders TWO rules,meta l4proto tcpandmeta l4proto udp, and ICMP plus everything else walks past them into theaccept_to_lanbelow. Read innft, fixed withproto='all', which renders oneip saddr 10.10.10.6 jump reject_to_lan. Trap 1 of the router section in a new costume. • AND THE RULE ANSWERED "YES" WHILE WORKING, because the test was invalid: with the machine on the home Wi-Fi,192.168.1.0/24is an ON-LINK route on its own adapter and beats the tunnel's two/1halves, so ping and TCP to 192.168.1.10 never reach the router at all. Same class as pinging the router from home to measure the tunnel MTU. • THAT SAME ROUTING FACT IS A LANDMINE FOR HER HOUSE, and it is the finding of the day. If her router serves192.168.1.0/24, the most common default there is, her REPLY to my 192.168.1.10 leaves through HER LAN instead of the tunnel: perfect handshake, healthywg show, and SSH and Moonlight simply never answer. The fix is a MASQUERADE on the router for traffic toward 10.10.10.6, so she sees the client as 10.10.10.1, and it also means herAllowedIPsnever needs my LAN in it. Still to apply: open-items. •router-ssotGAINED TWO CHECKS AND FIXED A HOLE. dnsmasq answers a local name through two mechanisms and the checker read onlydhcp.@dnsmasq[0].address, missing theconfig domainsection that LuCI writes from the Hostnames tab; the range was also hard-wired to the LAN while this answer lives in the tunnel. The eighth check is the other half of that value (a host declared at a tunnel address needs a peer holding it), the ninth forbids any DNAT intovpnSubnet, and writing the ninth uncovered thatmoonlight()read only ANONYMOUS redirect sections, so aMoonlight-HTTPStyped by hand as a NAMED section would have passed every Moonlight check in silence. • SUNSHINE WENT IN BYwinget, and it is the SAME build this repo already measured (2026.516.143833), so the derived port list transferred with no adjustment.packet_size1024 for the same tunnel reason,fec_percentage30, andmax_bitrateat a deliberately conservative 10000, since the bottleneck out of there is HER upload and nobody has measured it. • THE ENCODER IS H.264 AND THE HARDWARE ARGUMENT WAS WRONG. I wrote "HEVC is the only step up, turn it on in the client", reasoning that Kaby Lake-R has an HEVC encoder. Its own log:hevc_qsvrefused with "some encoding parameters are not supported by the QSV runtime" even after Sunshine's fallback retry,av1_qsvdid not open, and the verdict wasFound H.264 encoder: h264_qsv [quicksync]. What refuses is the QSV RUNTIME on a 2023 driver, not the silicon. The encoder list is read off the log, never inferred from the GPU. • THE INSTALLER OPENS THE FIREWALL FOR THE WHOLE PROGRAM: two rules,TCP/AnyandUDP/Any, remoteAny, every profile, forsunshine.exe. Not ports, the program. That is exactly what the machine's own script anticipated, and it means "run the firewall script again" belongs after every Sunshine UPDATE, not just after the install. • TWO OWNERS ONsunshine.conf, found before it cost anything: Sunshine writes the panel'susername,passwordandsaltINTO that file, so a script that rewrites it wipes the login. It now carries those three keys over from the live file. The first version had also usedconfig\credentials\as the "panel already has a user" signal, which is wrong:cacert.pemandcakey.pemlive there and the installer always creates them. • AND THE.gitignoreOVER THERE ATE THE CONFIG IN SILENCE. That repo ignores*.confin bulk so the WireGuard private key can never escape, sogit add -Askippedsunshine/sunshine.confwithout a word and the commit went green with the file existing only on disk. The fix was NOT widening the net:!*.conf.templatewas already the exception, so the repo's copy took that name. -
[x] Codex went to the THIRD layer the same day it arrived on the second, and the tool itself made the argument (19/08/2026). Hours after the entry below chose
nixpkgs-unstableand recorded upstream-direct as "passed over", Codex opened printing its own banner:Update available! 0.147.0 -> 0.148.0. That entry stays as written, because a diary that gets edited stops being evidence, and being wrong within the day is the useful part. • WHAT THE BANNER PROVED is not that 0.148.0 mattered, it is the CADENCE. Upstream tags almost daily, so the middle layer is not "a bit behind", it is behind by default and the tool says so out loud. For an agent CLI the gap is model support, not polish. • NOT AN OVERRIDE OF NIXPKGS'src, which is the reflex and is wrong here: that derivation compiles from Rust, so every bump would want a vendor hash and a full recompile of a 251 MiB binary.pkgs/codex.nixis afetchurlon the release asset instead. • THE PACKAGING IS 50 LINES BECAUSE UPSTREAM SHIPS STATIC MUSL. Measured straight out of the tarball, before any derivation existed: no interpreter,codex-cli 0.148.0, clean doctor. Nothing to patchelf.dontStrip, since stripping a released artifact makes it stop matching what was published and buys nothing. • THE TWO THINGS THAT STILL HAVE TO BE ADDED are the two nixpkgs adds:ripgrep, or search dies withInstall ripgrep or repair the bundled Codex package, andbubblewrap, since the release bundles its ownbwrapand this package does not take it. Plus--inherit-argv0, because Codex RE-EXECUTES ITSELF as its sandbox helper. Verified underenv -i, wherergcan only come from the wrapper:ripgrep 15.1.0andrestricted fs + restricted network. •codex-bumpMAKES THREE, and a GitHub release is the easy case of both halves the other two split. The asset URL is versioned, so it is immutable like VS Code's, AND "did it change?" costs ONE HEAD, because/releases/latestREDIRECTS to the tag. The REST API would answer the same and spend one of the 60 anonymous calls per hour, and would dragjqin to read it. Tested on both paths: the no-op, and a faked 0.140.0 whose recomputed hash came out identical to the pinned one. • THE IN-APP UPDATER IS A DEAD END HERE, and the banner is the invitation to it.codex updatewants to overwrite a binary in the read-only store. What updates Codex isupdate, the alias, like everything else. • A THIRD-PARTY FLAKE WAS PASSED OVER, and it would have been ONE input instead of two files (one of them even rebuilds hourly). For a tool that holds the ChatGPT session and executes shell commands, the fetch goes to the publisher with a hash this repo pins, rather than growing the trusted set by a stranger. • THE TRUST PROMPT'S ANSWER CAME BACK AS A DIFF, unplanned and the best evidence yet that the mirror works: Codex persistedtrust_level = "trusted"for this repo intoconfig.toml, and git showed it. Committed and not reverted, since reverting only means answering again. -
[x] Codex is declared, and the home-manager option built for it is the one thing NOT used (19/08/2026).
programs.codex.settingsrenders the attrset into/nix/store, and Codex WRITES toconfig.tomlat runtime:/model,/theme,mcp add, every approval the TUI remembers. Measured on 0.147.0 against exactly the file that option would generate, those writes die withRead-only file system (os error 30). Failing loudly is the good case and still the wrong one: the option trades every runtime setting for the two lines it declares. • SO THE OPTION IS ENABLED AND ITSsettingsLEFT EMPTY, which the module's ownmkIf (mergedSettings != { })turns into "generate nothing", freeing the path for amkOutOfStoreSymlinkinto the repo. Same contract as Claude Code'ssettings.json: Nix owns the LINK, Codex owns the CONTENT, and what the TUI changes lands as a git diff (rules 14 and 16). • THIS ONE HAD TO BE MEASURED, BECAUSE IT USED TO BE BROKEN. openai/codex#6646 reports the CLI replacing a symlinkedconfig.tomlwith a regular file, and upstream first answered that the behavior was "by design" before fixing it and closing on 19/01/2026. A note that trusted the fix without checking would be describing someone else's version. • THE REAL TEST IS TWO HOPS AND NOT ONE, which the scratchpad version missed:~/.codex/config.tomlpoints at the home-manager files in the store, and only THAT points at the repo. Acodex mcp addthrough the chain kept the inode, kept the symlink, and put the new[mcp_servers]table in the repo file, so Codex resolves the realpath all the way down instead of stopping at the store. • COMMENTS SURVIVE HERE, which they do not in Claude Code's JSON, so this config can carry its own 2-line header. Codex vendorstoml_edit0.24 and edits in place instead of re-serializing the document. •forced_login_method = "chatgpt"is the only line in it: the subscription is the credential, never an API key pasted into a shell (rule 12). PROVING A CONFIG LINE IS ALIVE NEEDS A BAD VALUE, because Codex accepts unknown top-level keys in SILENCE. An inventedbogus_key_testloaded clean;forced_login_method = "bogus"failed withexpected chatgpt or api.codex loginthen reported "Logged in using ChatGPT". • unstable and not stable: upstream ships most days (0.148.0 on 18/08, 0.147.0 on unstable, 0.146.0 on 26.05), and for an agent CLI the gap is missing model support, not missing polish. Upstream-direct was passed over: a hash bump almost every day buys one day. -
[x] The
notebookWireGuard peer is gone, and "I do not know whose it is" was the argument FOR removing it (19/08/2026). It predated the mirror (it was already there on 08/08, in the commit that createdrouter/uci), it never completed one handshake in any measured interface lifetime, and nobody could say which machine held the private key. Thewgzone forwards tolan, so an unaccounted peer key is an unaccounted door into the house. • ITS OPEN ITEM HAD A PREMISE THAT DIED TWICE. The note called the missing handshake EXPECTED, because "the direct access of 10/08 replaced it", and that direct access was retired the same day this peer was. • REVERSIBLE ON PURPOSE: the public key stays in git and in this file, the symptom of being wrong is immediate and unambiguous (exactly one machine stops connecting), and the correct repair would be a FRESH keypair anyway, which is better hygiene than reusing a key whose provenance nobody can state. •10.10.10.2is free again. What is left iscelular(.3),pc-trampo(.4) andfai-workstation(.5), and the last one stays an open item for the same shape of reason: no handshake in 17 days and apersistent_keepalivethat says somebody expected otherwise. -
[x] The direct path from UFSCar is RETIRED, and the checker INVERTED with it (19/08/2026). Its stated reason for existing was that a third VPN client on the FAI machine would be a routing conflict, and that machine now runs the client without one. Three measurements finished it: it never reached the subnet that machine actually uses (
200.136.204.0/23, never declared), the/21rules had forwarded ZERO packets ever, and the tunnel reaches the host from any network, including the campus/20the direct path was serving. • WHAT DIED WITH IT, in the same commit that removed its use (rule 16): 8 generated rules insunshine.nix, 8Moonlight-*redirects on the router,scripts/router-moonlight-forward.shand themoonlight-directtest guide. • WHAT SURVIVED, RELOCATED. The four traps of changing the router BY HAND are about the router and not about that path, so they moved intonetwork.md:uciaccepts what fw4 DISCARDS (aredirect'ssrc_ipcannot be a list, and the config looks perfect with zero effect), sudo cannot prompt when the script arrives on stdin,sudo uci commitleaves/etc/configat 0600, and a watchdog for a risky change needsnohupand not a subshell. • THE RDAP TABLE IS THE MEASURED FACT WORTH KEEPING. UFSCar has at least four blocks under CNPJ 45358058000140, and only two were ever declared here:200.133.224.0/20(campus, the only one ever proven to work),200.136.192.0/21(declared, zero packets ever),200.136.204.0/23(the work PC, never declared) and200.136.208.0/20(the FAI workstation, never declared). The list was incomplete from the day it was written. • THE CHECKER DID NOT LOSE ITS JOB, it inverted. The repo declares zero sources now, sorouter-ssotasserts the device forwards NOTHING, and a redirect that comes back through LuCI is a finding instead of a surprise. It also refused to let this commit exist while the two sides disagreed, which is the first time a checker here blocked its own author. • HOW TO BRING IT BACK, if the premise ever flips again: the module block and the script are in git, and the 8 redirects are written out in this file. What it would cost is what it always cost,/serverinfowith no authentication offered to two UFSCar blocks, plus 16 rules mirrored by hand across two systems. -
[x] The router's mirror gained a CONTRACT, and the checker found its own bug first (19/08/2026). Rule 11 wants ONE owner per value, and the router is the single piece of infrastructure Nix does not reach, so a handful of values live in both places. The only thing keeping them equal was a sentence in the Sunshine notes admitting "there is a mirror to keep in sync by hand", and that sentence cost the morning three entries below.
router-ssotcompares the mirror against what the repo declares, in seven checks. • TWO LAYERS, and neither replaces the other.router-sync diffasks whether the mirror equals the DEVICE; this asks whether it equals the REPO. Green over a stale mirror proves nothing, which is why the mirror check reports ALONE when it fails: six findings about a truncated file would send the next reader to the wrong place entirely. • IT READS THE MIRROR, NEVER THE DEVICE, in this order of reasons: it runs in a pre-commit hook so it has to be fast and offline, it must never be able to lock anybody out so it opens no connection at all, and freshness is already somebody else's job. • THE FIRST RUN FOUND A BUG IN THE CHECKER, not drift. The extractor readthe FAI rangeout of a COMMENT besidemoonlightSourcesand reported a third source block that does not exist. Comments are stripped now, ignoring a#that lives inside a quoted string, because127.0.0.1#5053is a legitimate value in these same files. That is the shape of every text based checker's failure: the weak part is the parser, never the comparison. • PROVEN IN BOTH DIRECTIONS, because a checker that only passes is decoration: eight mutations on the mirror side and six on the repo side, each firing its own kind. The host address is the costliest value to get wrong, firing 9 findings across 4 checks, which is exactly the blast radius its 19 occurrences in the mirror predict. • The hook did not run on its own commit..pre-commit-config.yamlis GENERATED and gitignored, so a new hook only exists after a dev shell re-entry regenerates it. -
[x] The wildcard POISONED the anchor, and the fix was a leaf record instead of a literal (19/08/2026). The new Windows peer uses the router as its DNS, which is what keeps
fai2008.ufscar.brresolving through the forward already there, and that makesssh.<domain>unusable as the tunnel'sEndpoint: a re-resolution answers 192.168.1.10, an address INSIDE the tunnel being built. • The obvious exemption was one more entry in the pattern the notes already documented,server=/vpn.<domain>/forwarding a single name upstream. It poisonedssh.<domain>FOR THE WHOLE HOUSE: the upstream answer for a wildcard name is a CNAME chain,vpntosshto the public A, and dnsmasq caches every record in it, so the cached exact name beats the suffix rule for the full 300 s TTL. Measured with a control name in the same zone still answering 192.168.1.10. • SO THE RULE WAS NARROWER THAN WRITTEN. Longest match decides WHICH entry applies; whether the answer comes from inside or from upstream decides whether the cache can overwrite the override you are relying on. • The first fix was a pinned literal, which worked and lied: a value the DDNS already owns, duplicated into a second place. Closed the same day with a leaf record of its own in Cloudflare plus a second ddns-scripts instance, so the upstream answer is a single A with no chain to cache and nothing to go stale. • HONEST ABOUT THE SIZE OF THE WIN: hygiene, not availability. When the pin went stale the tunnel was already down, so the router's DNS was unreachable anyway and Windows fell back to the FAI resolver and got the right answer. -
[x] The work PC became a WireGuard peer, and the Windows client CARVES AllowedIPs (19/08/2026). It also closes the tunnel MTU item inherited from 10/08. Measured from the host toward the peer: path MTU exactly 1420 (
-s 1392passes,-s 1393fails), RTT 35.7 ms over 40 packets with ZERO loss, against 1.67% loss and RTT spiking from 20 to 312 ms on the direct path. • A LITERAL0.0.0.0/0TURNS ON THE APP'S KILL-SWITCH, which blocks every packet outside the tunnel including the LOCAL network, and keeping the FAI network reachable is the entire reason the peer exists on that machine. The app looks for a route of prefix length 0, so a list that covers everything WITHOUT ever writing/0gets full-tunnel routing and no kill-switch. Hence 62 blocks, generated and not hand written. • THE MTU ITEM'S PREMISE WAS WRONG, and closing it corrects it. It said the number was not actionable until the tunnel is retired.packet_size = 1024was calibrated for tailscale's 1280, which no longer exists anywhere, so with the binding path at 1420 there IS headroom: 1136 by the same proportion, 1164 by the same absolute margin. • NOT TAKEN, on purpose. The ask was stability, and the smaller packet is the robust one: raising it moves toward the ceiling whose overflow WireGuard drops SILENTLY, which is the 4 s disconnect this repo already paid for once. • The peer went into uci AND was applied withwg set, so nonetwork reloadwas needed. That is the command that could have dropped the PPPoE and the session in the same second. -
[x] "Moonlight does not connect" was OUR OWN source list, and the SYN-ACK theory died (19/08/2026). RDAP at registro.br: UFSCar does not have two blocks, it has at least four under the same CNPJ, and two in daily use are not declared here. The router matched no
src_ip, dropped the packet BEFORE the DNAT, and left no trace on this host: no conntrack entry, no refused packet, no connection attempt in Sunshine's log. • THE INSTRUMENT WAS A TEMPORARYnftCOUNTER oninput_wan, with no verdict and removed right after, because there is no tcpdump on the router and nothing gets installed for one measurement. It counted 11 packets: the SYN ARRIVES. • The DNAT counters said the same thing for free, and they are always there to read: the /20 rules show the morning's session, the /21 rules show ZERO on all four. That block has never carried a packet. • ONE LOOSE END, written down instead of buried:handle_rejectsends a TCP reset, soncshould have failed FAST with "connection refused" and it hung instead. Something eats the return packet, which is the same SHAPE as the theory this retires even though the packet is a reset and not a SYN-ACK. It was never instrumented, and it stopped mattering the same day. • THE LIST WAS NOT WIDENED. It means editing two places that have to agree and handing/serverinfowith no authentication to more of UFSCar, which is a deliberate call about exposure and not something to do while chasing a symptom. -
[x] Moonlight could not connect and the host was FINE: a ghost session, plus a watchdog that had never run (19/08/2026).
/serverinfokept answeringSUNSHINE_SERVER_BUSYwith acurrentgameset, after a client left at 08:24 with no clean teardown, and Moonlight reads that state and refuses to open a new session. Zero UDP sockets and no established TCP proved it was a ghost by the notes' own test. A restart cleared it and the pairing survived. • THE HEALTHCHECK COULD NOT SEE IT, by construction. The TLS handshake on 47984 completes the whole time, because the HTTPS handler is healthy and what is stuck is the SESSION behind it. It ran every 2 min through the entire ghost and exited 0 every time, which is correct for what it was asked to check and useless for what was actually wrong. •hypridle-guardHAD BEEN DEAD FOR 9 DAYS with exit 127. It usedawkwithout declaringgawk, and a systemd USER unit gets a FIXED PATH with no awk in it, while running the script by hand always worked because an interactive shell has one. That is the whole reason it survived: the only way to test it was the way that could not fail. • AND THE LOG FILTER HID IT.LogLevelMax = warningwas supposed to cut the noise and keep real failures visible, and systemd logs a script's stdout AND stderr at INFO, so the filter also ate the shell's own error. Seven consecutive failures logged not one reason.SyslogLevel = warningis the missing half. • The reaper that replaced the guesswork needs BUSY plus no socket HELD for over 5 min, because between an app launching and the client binding video there is a window that lasts a whole Steam Big Picture launch, and restarting inside it kills a session being born. • A LIVE SESSION THAT AFTERNOON CLOSED A CHECK OPEN SINCE 10/08: all three UDP ports bound, so "bound means a real stream" is an OBSERVATION now and not an inference, which matters because the reaper restarts Sunshine on the strength of it. The same measurement DEMOTED the other half: during a real stream there is no established TCP on 47984 or 48010 at all, so that check only ever covers the negotiation window. -
[x] Audited what the cleanup could not see, and the docs were the only thing rotting (16/08/2026). The repo itself came back clean: every QML component and Lua module is reachable,
ci/stub-duois load-bearing (without it the CI needs a deploy key for the private input), therouter/ucimirror is IN SYNC (ranrouter-sync, zero diff), and the five packages that appear nowhere else in the tree are interactive tools, not dead declarations. One dead file in 242: a.pyccommitted before.gitignorehad a rule. • THE REAL FIND WAS PROSE. Docs point at modules with a backticked path, and nothing checked those, sodocs-linksgained an eighth thing to verify. It went from 336 references to 533 and caught three stale on the first run: a guide still naming agpu.nixand ahardware.nixdirectly undersystem/, from before the reorganisation into categories, andideas.mddescribing a DDC module that was built and reverted, in a sentence that read as if it still existed. •docs/history/IS EXEMPT, and that exemption is the whole design. A diary names files that were deleted on purpose; editing it to keep paths resolving would stop it being evidence. The same reason it is append-only while notes are kept current. • A path in SOMEBODY ELSE'S repo is now writtenRepo:path/to/file. Two Foundry paths in the impermanence item looked exactly like local ones (hosts/common/…) and would have been permanent false positives. The prefix makes them say what they are, which is better writing regardless of the checker. • THE CHECK FLAGGED THE NOTE THAT DOCUMENTS IT, twice, because I quoted the dead paths while explaining that they are dead. That is the check working. The fix was to stop backticking them, and prose about a dead path reads better without the quotes anyway. • The other half isdead-config's seventh check, for tracked build artifacts. A.gitignorerule and the check are DIFFERENT guarantees: the rule stops the next one, the check finds the one already in the index, where an ignore file has no effect. -
[x] Removed the dead
jellyfin_api_key, and the removal taught the checker a sixth check (16/08/2026).dead-confighad flagged it the day it was written: in the vault, consumed by nothing, and belonging to the OLD Jellyfin server, so it answered 401. Not merely unused, unusable. • THE RUNBOOK I WROTE FOR IT WAS WRONG, in two different ways, and both only surfaced by running it. First, the documented edit command does not work at all:sops secrets/secrets.yamlfails with "Failed to get the data key", because the age key is ROOT's and lives at/var/lib/sops-nix/key.txt, which is none of the eight locations sops searches. That command was wrong in.sops.yaml's header before I moved it into the notes, so the repo had been documenting an unusable command for a while. • Second, and this is the interesting one: deleting the key fromsecrets.yamlBROKE THE BUILD.sops-install-secrets: the key 'jellyfin_api_key' cannot be found. The vault holds the VALUE; the DECLARATION issecrets/bitwarden-secrets.json, whichsystem/core/secrets.nixmaps into onesops.secrets.<name>per entry. This one was declared TWICE, since it also had a hand-writtenowner/modeoverride. Three deletions, not one. • THE FAILURE WAS LOUD, which is the good case, but it surfaces atnixos-rebuild, and that is a slow loop for a mistake this mechanical. Sodead-configgained a sixth check: every key in the index must have a value in the vault. Verified by putting the broken state back and watching it fail, then removing it again. •ALLOWEDis empty again, which was the stated goal when the list was introduced with its single entry. A checker whose exception list only grows is a checker on its way out. -
[x] Audited
open-items.mdagainst the tree, and closed six items that were already done (16/08/2026). The file's own header says a finished item migrates here, and it had stopped happening: this is rule 16's drift applied to the TODO list, where the cost is not a broken build but re-reading work that is finished. 21 items became 15. • M.2 heatsink for the KC3000: DONE on 09/08/2026. The item asked for one at 77 to 80 °C under load; after installing it the drive reads 41.8 °C idle and 47.8 °C under write. The test the item defined still stands and is worth keeping in mind: ifThermal Mgmt T1 Trans Countstays at 18 after weeks of heavy use, the throttling is gone for good. The counters are CUMULATIVE and include the era with no heatsink. • VS Code / vscode-colorize: DONE by removal. The extension was aborting in a loop, 15 coredumps in 2 days at 58 s of CPU and 2.7 GB written to the NVMe each. It is no longer inhome/apps/vscode/extensions.txt, which is exactly what that mirror file was added for: the fix happened outside this repo (Settings Sync owns the extensions) and the repo still recorded it. • HEVC on Moonlight: closed as TESTED AND DROPPED, and it migrates with its full reasoning, because the value is the negative result. H.264 is the deliberate choice for the FAI notebook. The failure mode is the worst kind to diagnose: on the HOST everything looked right (Creating encoder [hevc_vaapi], the colorimetry even IMPROVED from Rec. 601 to 709) and the delivered image was still bad. The host encodes, the CLIENT decodes, and the host cannot see that. Two wrong turns before getting it right: closed as "no action" from a REPORT, then reopened as "it works" from a HOST MEASUREMENT. Neither instrument answered "how does the image look on the client". • WoW Ascension with Bottles: done. Bottles is declared, the window rule exists inrules.lua(floating, centered on the LG, opaque, idle-inhibiting) and the addons are set up. The item was written as "once I am on the SSD", and the cutover was 01/08. • Razer Deathadder v2: obsolete, not done. The mouse on this machine is an MX Master 3S (system/hardware/mouse.nix) and there is no Razer anywhere in the tree. Closing it as "obsolete" rather than "done" is the honest label. • VSCode declarative: the DECISION was made and recorded, which is what the item was really asking for. What it wanted, "centralized in the dotfiles", is done: settings, keybindings and mcp.json are versioned and linked, and the extensions are mirrored inextensions.txt. What it did NOT get is Nix ENFORCING them, and that was refused on purpose, because Settings Sync serves the FAI Windows machine too. The reasoning is innotes/apps/vscode.md. An item asking for something the repo deliberately decided against is not open, it is answered. • TWO ITEMS WERE CORRECTED rather than closed. The Tray item was carrying 70 lines of FINISHED work (the click, the hover fix, the XEmbed bridge, the ghost icon), all of it now innotes/desktop/, so it shrank to the one thing still missing, the tooltip. And the SSOT item claimed 5 files still holding a/home/v1cferrliteral; recounting found 8. A count nobody recounts is a number that decays. -
[x] The notes got a folder structure and, more importantly, a CHECKER (16/08/2026). Right after the rule 2 sweep,
docs/notes/was 51 flat pages with no index, and nothing in the repo verified that a single pointer still resolved. Both were fixed, in that order: the checker first, because it is what made moving 51 files safe. •pkgs/docs-links.nixwalksgit ls-files, checks baredocs/paths in CODE and relative](target)links in MARKDOWN, and failsnix flake check, so also the CI. It caught a real one on its first run:docs/rules.mdstill pointed at the old pt-BR filename, which the en-US migration of 15/08 missed. I then broke a pointer ON PURPOSE to confirm the gate goes red, because a checker that cannot fail is decoration. • A BARE PATH IN MARKDOWN IS PROSE, not a pointer, and the first version got that wrong: it flaggedrules.mdfor the sentence saying the old filename "became" the current one. That sentence is history and it is correct. A file that names its own past is not a broken link, so in markdown only a real link counts. • GROUPED BY SUBJECT AND NOT BY REPO PATH, and that was measured, not taste: 16 of the 51 pages cross thesystem/andhome/boundary and 19 reference two or more modules. They cross because the ARTIFACT crosses (arch-legacy is two modules, claude-code is two, monitors is two), so a mirror of the tree would have had to split a third of the pages or file them under a half-truth. Seven folders, 5 to 10 pages each. • THE COST of the move, stated because it is the part that could rot: 128 pointers in 92 files and 113 links inside the notes, all rewritten. 34 header lines blew past 100 columns with the longer path, and trimming them mechanically left 16 with a dangling article ("... The: docs/notes/x.md"), which I rewrote by hand. Shorter is the constraint, not the goal. • TWO SMALLER THINGS came out of looking atdocs/as a whole:docs/arch-legacy.mdwas colliding with the note of the same name while being a different subject (the distro chapter versus the mount), so it becamearch-linux.md; anddocs/tests/folded intoguides/, since both files describe themselves as REUSABLE PROTOCOLS, which is whatguides/already means. One fewer top-level category. -
[x] Rule 2 stopped allowing a wall of prose, and the reasoning moved to
docs/notes/(16/08/2026). The rule permitted a header block "as long as it needs to be", and the blocks grew: 6062 comment lines out of 16634 across the tree, 36%, withhome/shell/claude-code.nixcarrying a 123-line header. A header that long is not documentation, it is a wall you scroll past to reach the code. The tree now sits at 1601 lines out of 13299, 12%, and NO comment anywhere runs longer than 2 lines. • NOTHING WAS DELETED, and that is the whole point. 51 pages indocs/notes/hold every measurement, every rejected alternative and every correction that used to live in a header, and the 2-line header points at its page. Deleting would have been faster and would have thrown away things that cost days to learn: the VPN probe's cadence, btrfs' reclaim decision, the Dolphin ViewMode enum, Spotify's 4145 restarts. • THE CAP IS PER COMMENT, not per module, and that correction came mid-task. I had capped only the headers, andhome/packages.nixwas still 101 comment lines because every package carried its own paragraph. The rule now says AT MOST 2 LINES, ANYWHERE: the header, a config, a package, a list item. •keybinds.luawas the one file that needed care instead of a sweep, because its comments are FUNCTIONAL: the SUPER+H cheatsheet is generated from them at runtime, the first line of a block becoming the group and the trailing comment the description. Verified by running the awk parser against the old and the new file: 78 binds both times, in the same order. Four labels that fall back to the group came out worse than before ("VPN (Arch parity)" says nothing) and were rewritten, because shorter is not the goal, it is the constraint. • THE GATE CAUGHT ONE REAL BREAK, which is the argument for running it per batch rather than at the end: a stray>leaked from a python heredoc intocurseforge-fix-perms, andnix flake checkfailed on the derivation. From there the gate also became the toplevel BUILD and not onlynix eval, because several of these files carrywriteShellApplicationbodies, so a comment run inside a shell string is script content and shellcheck is the only thing that sees a bad edit to it. That is also why the btrfs alert units changed derivation while doing nothing different: the comments I rewrote were inside the script, not in the Nix. • WHAT THIS BUYS beyond the reading: the reasoning was invisible fromdocs/. To find out why Caddy has a fail2ban jail you had to already know to opensystem/services/caddy.nix.notes/is indexed, cross-linked and covered by rule 16, so a page that stops being true is a bug, whereas a stale header block was just furniture. -
[x] The rules left the prompt and became
/etc/claude-code/CLAUDE.md(15/08/2026). Three rules were being retyped BY HAND at the top of every prompt I wrote for Claude Code (incremental commits, everything in en-US, never aCo-Authored-By:trailer), which is rule 3's definition of manual: it works until the day I forget, and the day I forget is the one that produces a repo in two languages with a single blob commit signed by a coauthor I did not want. Nowsystem/services/claude-code.nixgenerates them and EVERY project on this machine is born with them. It became rule 18. • WHY THE MANAGED LAYER and not$CLAUDE_CONFIG_DIR/CLAUDE.md, which is the path everybody knows: the user file is PER ACCOUNT, so withclaude-faiandclaude-pessoalit would be two copies of the same text drifting apart, and CC WRITES to it, because the#shortcut appends a memory to exactly that file. Nix owning it would break the shortcut and put two owners on one artifact (rule 14). The managed one CC only ever reads. • MEASURED IN THE BUNDLE (2.1.222) instead of trusted from the docs, because a memory file that is never read fails SILENTLY and looks exactly like an assistant ignoring the rules: the loader resolves "Managed" tojoin(fU(), "CLAUDE.md"),fU()returns "/etc/claude-code" on Linux ("/Library/Application Support/ClaudeCode" on macOS), and that layer is read UNCONDITIONALLY, unlike User and Project, which are gated by settings. Confirmed after the build: the file is in the built system's/etc. • THE NEAR MISS was managed-settings.json'sclaudeMdfield ("instructions injected as organization-managed memory. Only honored from managed/policy settings"), which does the same job with no second file and LOSES ON THE DIFF: the markdown would turn into a JSON one-liner with escaped newlines, unreadable in agit diffand out of markdownlint's reach. • IT COSTS CONTEXT IN EVERY SESSION on this machine, this repo's included, so the file holds the rules and NOTHING else, and the reasoning stays indocs/rules.md. Rule 17's em dash and emoji bans did NOT go along: they are a style choice for what I publish, not a working agreement with the agent. Promoting them later is one line in the module. -
[x] The repo is ENTIRELY in en-US, and rule 17 stopped being a promise (15/08/2026). The rule was written this morning with the
.nixtree named as known debt; by the end of the day the debt was gone. In order: the 12 densest modules, the other 86.nixfiles, the CI workflow,scripts/, the Hyprland Lua, the Quickshell QML and the tooling files (.envrc,statix.toml,.sops.yaml,.gitignore,.markdownlint.jsonc, the two VS Codesettings.json,router/README.md,ci/stub-duo/.gitkeep). • IT WAS NOT ONLY COMMENTS, and that is what made it worth doing in one stretch. Three files GENERATE what the session shows:keybinds.luais parsed at runtime by the awk incheatsheet.nix, so translating it translated the SUPER+H cheatsheet; the Quickshell QML is the bar itself; the shell scripts print to the terminal. Anything user-facing that this repo OWNS came along. • WHAT STAYS IN pt-BR IS A CLOSED LIST, and each item says why where it lives: the lockscreen (a product decision from july), the Brazilian holiday names plus the month and weekday names in the bar's calendar, and runtime identifiers whose rename would be a behavior change and not a translation (my.archAntigo, thearch-antigo-mountunit,/mnt/arch-antigo, the "Arch antigo" bookmark,/srv/media/media/Filmes). •checks.pacotesBECAMEchecks.packages, closing the one item the morning had left pending: it kept its name only because.github/workflows/nix.ymlcited it BY NAME, so translating the workflow removed the reason. • THE REDACTION MARKER ofrouter-syncchanged with it, because the old one carried an em dash, and it is written INTOrouter/uci/*.conf. The four mirrored files that hold it were rewritten in the same commit: otherwiserouter-sync diffwould report every config with a secret as diverging until the nextpull, which is a gate lying about the router's state. A translation with a runtime side effect is not a translation, it is a change, and it went in as one. • MEASURED AS IT WENT, because "it is only a comment" is exactly how a live config breaks:nix flake checkafter each batch of.nix,luac -pplus a realhyprctl reload(which answered ok, 92 binds) for the Lua,shellcheck -xpluspy_compilefor the scripts, and for the QML the Quickshell log itself, which hot-reloads on save and said "Configuration Loaded" at the end, withqs ipc call bar unhidestill answering. -
[x] Minecraft OPENED, and the "unexpected error" was the SAME lost
+x, one tree wider (15/08/2026). This closes the item opened on 14/08 ("see Minecraft OPEN"). The app answered the Play button with the generic red banner, "An unexpected error occurred. Operation failed.", whose support article lists three causes (antivirus, a full disk, folder permissions on Windows) and none of them was it. The agent log named the victim: [Radiuminator] Failed to launch Minecraft instance: d967e030-... An error occurred trying to start process '.../Documents/curseforge/minecraft/Install/minecraft-launcher' with working directory '.../Install'. Permission denied.chmod +xon what the sweep found, and the game opened with the modpack running. • THE BANNER LIES BY OMISSION, and that is worth knowing before the next one: the app knows the real error and loses it on the way out. Right after thePermission deniedthe log showsInvalid enum value: GeneralattoApiErrorReason(background.js), which is it failing to map its own internal error code and falling back to the generic message. AnyOperation failed.here deserves the agent log before it deserves the support article. • THE LESSON OF THE PREVIOUS ENTRY REPEATED ITSELF ONE LEVEL UP: yesterday I learned that "missing Java" was really a lost+x, and fixedInstall/java. The namecurseforge-fix-javadescribed the SYMPTOM I had seen, not the bug. A sweep ofInstall/by ELF magic byte found 115 files still at 644:runtime/68 (the JRE of the VANILLA launcher, which is what actually runs the game),natives/31,launcher/8,bin/6,webcache2/1 andminecraft-launcheritself.java/was the ONLY correct tree, 133 of 133, precisely because that was what the script covered. The extractor loses the bit on EVERYTHING it unpacks. • WHAT MADE IT COST A SECOND CYCLE: yesterday's fix WORKED, and working is what made it look complete.java/came back 133 of 133 correct, so every check aimed at Java was green while the game still would not open. A fix that covers the tree you looked at proves nothing about the tree you did not. • The package was renamed withgit mvtocurseforge-fix-permsand now sweeps the wholeInstall/tree. It reads the 4-byte ELF magic in bash itself, with nofileand no fork per candidate, because a list of names (*/bin/*,*.so) would have to guess the name of the next binary, and the tree grows on its own with every MC version and every modloader.assets/is pruned: 8879 content-addressed Mojang blobs, never executed, and the only subtree that grows without bound. That takes the sweep from ~1.0s to ~0.13s on every activation. •Instances/stays OUT on purpose, even with two 644.sositting in there (libEffekseerNativeForJava.so,epicfight/.../ServerCommunicationHelper.so): those are unpacked from their own jars by the MODS, at runtime, 644 on every distro, anddlopendoes not look at the exec bit. That is not a lost bit, it is how those mods ship. The.sounderInstall/do get+xfor the opposite reason: the original tarballs ship them 755, and restoring what the extractor lost is more defensible than judging one by one which ones would load anyway. • Rule 17 came along for the ride: renaming the package touchedflake.nix,home/apps/curseforge.nix,pkgs/curseforge.nix,README.mdandpendencias.md, and each of them was left in en-US in the same commit. Thechecks.pacotesattribute kept its pt-BR name for two more commits, because.github/workflows/nix.ymlcited it BY NAME and that file was not touched here; it becamechecks.packageswhen that workflow was translated, later the same day. -
[x] The FAI OneDrive mounted on Linux: BUILT, MEASURED AND REMOVED, because the tenant does not allow it (15/08/2026). The idea was
~/OneDriveas a normal folder in Dolphin, the same way as~/Driveand~/FAI-workstation: onedriver (FUSE, on-demand) through the official path, Microsoft Graph plus an Entra ID login, since Microsoft never published a OneDrive client for Linux. The module was finished and the login was actually attempted. It stopped at "Need admin approval", and the measurement showed there is nothing to be done from this side. The code went out entirely (the zero-legacy rule); it can be resurrected withgit show 4a14cd6:home/services/onedrive-mount.nix. • THE PERMISSION WAS NOT THE PROBLEM, and this is where intuition goes wrong:Files.ReadWrite.Allistype=Userin the Graph catalog (AdminConsentRequired = No), which means an ordinary user COULD consent. What blocks it is the intersection of two things in the FAI tenant: the policy isManagePermissionGrantsForSelf.microsoft-user-default-low, which only allows it when all the permissions are classified as low-impact, and the ones classified there are only the FIVE factory ones (User.Read,openid,email,profile,offline_access). onedriver asks foruser.read files.readwrite.all offline_access, so the.Allfalls outside the list and user consent dies right there. • CHANGING THEclientIdDOES NOT CURE IT, and that was the obvious way out, which I had written in the module itself as the remedy. Registering an app INSIDE the tenant (possible:allowedToCreateAppsistrue) satisfies the policy's "apps registered in your tenant", but it CLASSIFIES no permission, so the same approval screen comes back. Necessary and insufficient. Consenting on my own was not an option either, since/me/memberOfreturns only groups, no directory role. • HOW TO MEASURE THIS IN 3 COMMANDS, which is what was worth learning:az rest --url ".../v1.0/policies/authorizationPolicy"gives the policy;az rest --url ".../servicePrincipals(appId='00000003-0000-0000-c000-000000000000')/delegatedPermissionClassifications"gives what is low-impact; andonedriver -a -n <mnt>prints the auth URL with the exactscope=. Reading the scope from the URL, and not from a code comment, is what closed the case. • WHAT WOULD UNBLOCK IT, and both go through IT: admin consent on the app, or classifying the permission as low-impact.Files.ReadWrite(the user's drive only) would be a far easier ask to approve than the.All, but it would require patching onedriver, which buries the scope in the binary. With no access to IT, neither one is mine to pull. • WHY REMOVE IT INSTEAD OF LEAVING IT ON STANDBY: a module that is not imported is NEVER EVALUATED, so it does not enternix eval, norchecks, nor the rebuild. It would rot silently (a package disappearing from nixpkgs, a home-manager option changing name) and it would give the false sense of "it is ready, just turn it on". It sat like that for 4 days, orphaned since6600ad0, with nobody noticing. And there is no operational pain holding it: OneDrive through the web works. • THE MOUNT (and not sync) DECISION stays recorded here because it outlives the module: it is an INSTITUTIONAL account, and sync PROPAGATES a delete, so a local wipe would become a wipe on the FAI OneDrive, which is not mine to recover. If this ever comes back, it comes back as a mount. -
[x] CurseForge replaces PrismLauncher, and "missing Java" was PERMISSIONS (14-15/08/2026). Prism imports a modpack
.zip, but what keeps the library and UPDATES the pack is the CurseForge app, which is the real use here. It is not in nixpkgs (unfree, binary-only), so the repo repackages the official AppImage inpkgs/curseforge.nix(home/apps/curseforge.nixon the user side), the same vendored-binary pattern as claude-desktop. • THE ROOT CAUSE, and it has NOTHING to do with NixOS: the app downloads its own JRE (OpenJDK21U-jre_x64_linux_hotspot_21.0.4_7.tar.gz) and unpacks it with a .NET extractor that does NOT PRESERVE PERMISSIONS. The binaries come outrw-r--r--, the firstjava -versiondies withPermission denied(System.ComponentModel.Win32Exception, in the agent log) and the interface announces "Java Runtime Environment is missing or out of date", a message that points the wrong way. On no distro would that Java open; the bug is CurseForge's. • AND IT DOES NOT HEAL ITSELF: when trying to reinstall the JRE to fix it, the extraction fails atThe file '…/Jre_21/NOTICE' already exists., because the extractor does not overwrite either. Which means the interface's "Retry" button runs forever without moving. There were TWO bugs in a cascade, and the second is what turns the first into a dead end. • THE FIX IScurseforge-fix-java(pkgs/), which gives the+xback. It runs in the home-manager activation on every rebuild and also by hand, because the download that breaks can happen IN THE MIDDLE of a session, and then it is a matter of running the command and reopening the app. It is idempotent and silent when there is nothing to fix. In the real directory it fixed 88 files, far more than the obvious manualchmodinbin/: there were.sofiles in subdirectories, includinglib/server/libjvm.so. • THE MISTAKE THAT COST A WHOLE CYCLE, and the lesson is worth more than the fix: I read "No Java executable found" and concluded "so Java is missing". I declared three Temurin JREs (8/17/21) in the FHS, with/usr/lib/jvmand a stable path, a pretty solution for a problem that did not exist. It changed NOTHING, because the app only consults the JRE it manages: with all three installed, the agent log went on citing ITS java 18 times and ours ZERO times. The JREs went out in the same commit they came in (rule 16). A third party app's error message is a HYPOTHESIS, not a diagnosis, and the difference between "Java is missing" and "the Java that exists does not execute" is a whole afternoon. • What pushed me into the mistake was Overwolf's support doc ("the app installs Java automatically") plus theadoptium/java-runtime-*strings in the asar. Both were TRUE and they still took me to the wrong place: it does install it, just broken. Reading the AGENT log (~/.config/CurseForge/agent/logs/, JSON, separate from the Electron log) is what solved it, and it should have been the first step. • THE FINAL TALLY:curseforge+340.2 MiB againstprismlauncher-17.6 MiB andopenjdk(8, 17, 21 and 25, which the Prism wrapper bundled) -1.8 GiB, for a total of 27.2 to 25.7 GiB. The system shrank by 1.5 GiB swapping a native launcher for an Electron one, because nobody declares Java here: the provider is the app. • AppImage and NOT the.deb, and the reason is the same Java, from the other side. Both sources exist and are the same release, but what matters here is a binary the app DOWNLOADS at runtime (the JRE, the Forge installer, Minecraft), and none of that goes throughautoPatchelfHook, which only reaches what is in the store. This system'sprograms.nix-ldcovers the LOADER (the/lib64/ld-linuxhere points at it, and that is why the app's JRE ran on the host after the+x), but not each one's libraries.appimageToolswraps it inbuildFHSEnv, where the loader exists andcontainer-init.ccalso puts/run/opengl-driver/libin theld.so.conf, so Minecraft launched as a child inherits the FHS and the Arc B580 driver. It is the FHS that makes the package work, not patchelf. • THE.zipTHE TUTORIALS TELL YOU TO DOWNLOAD IS DEAD:curseforge-latest-linux.ziphas alast-modifiedof 10/08/2025 and carries 1.285.2. The.AppImageand the.debin the same folder are from 05/08/2026 and bring 1.316.0-37372, confirmed by the app's own updater on the first boot ("latest version: 1.316.0-37372"). Following the popular tutorial would install an app a year behind. • THE LOGIN DEPENDS ON AN ASSOCIATION THE APP CANNOT MAKE ON ITS OWN. It tries to register as the handler forcfauth://at runtime (ElectronsetAsDefaultProtocolClient), and that will NEVER work here:~/.config/mimeapps.listis managed by home-manager and points into the store, which is read-only (rule 14). The log says right at startupFailed subscribing app protocol.andFailed to register login scheme 'cfauth'. Sincecfauth://is the login CALLBACK (the app opens the browser and waits for the redirect), with no handler the login comes back to nothing, hence the three schemes declared inxdg.mimeAppsin the app's module. • A POINTER URL, the same hole as VS Code's/latest/: Overwolf only publishescurseforge-latest-linux.AppImage(tested-1.316.0-,~37372andlatest.yml, all 404). The pinned hash keeps the build reproducible, but it rots on their next release. The remedy ispkgs/curseforge-bump.nix, a sibling ofvscode-bump, on theupdatealias. With one aggravating factor: there it was enough to change the number, here the HASH has to be RECOMPUTED. To avoid downloading 139 MiB on everyupdate, what answers "did it change?" is a 256 KiB range request on the.deb(thecontrolsits in the first few KiB); the AppImage is only downloaded when the answer is yes. • That is whycurseforgeis the ONLY exception inchecks.packages(flake.nix): leaving it there would paint the CI red on every Overwolf release, for something that is not in this repo.curseforge-bumpDOES enter the check, because its shellcheck is stable. • TWO THINGS I HAD WRITTEN WRONG AND MEASUREMENT KNOCKED DOWN: (1) that--no-sandboxwas necessary, when runningbin/curseforge, which passes no flags at all, the app opens and loads the library normally, because thebuildFHSEnvbwrap already gives Chromium the namespace it wants; the flag stayed only because it came from the upstream.desktop. (2) that GNU tar would autodetect the compression in a pipe, when it only autodetects if it can seek, soar p … | tar -xOdies with "Archive is compressed. Use -J option". The bump goes through a file, which as a bonus makes it survive the day.xzbecomes.zst. -
[x] Hovering the VPN pill shows the tunnel's quality (14/08/2026). The pill only answered "is there a tunnel?"; what was missing was "and is it any good?", which is the question of whoever has SSH or a call depending on it. Now HOVER opens
bar/VpnStatsPopover.qml(latency, jitter, loss, a graph of the series, session traffic, uptime) and CLICK still opens the usualVpnPopover, with connect/disconnect. Information on hover, ACTION on click. • DIVISION OF LABOR:vpn stats-json(new) delivers the STATE (iface, IP, MTU, uptime, session bytes and WHICH host serves as the probe target) and the bar MEASURES the latency with a continuous ping. It did not become a field ofstatus-jsonbecause that one is the 5s polling that paints the pill and has to be cheap; this one only runs with a tunnel up. Discovering a target (sweeping routes, testing candidates, memorizing) is shell work; observing all the time is the work of whoever stays open. • THE OBVIOUS PROBE TARGET DOES NOT WORK: the FAI ppp peer is192.0.2.1, which is TEST-NET-1, a facade address of the SonicWall, and it ignores ICMP (MEASURED: 100% loss). What answers is200.136.209.236(fai.ufscar.br): a public IP, but the200.136.209.128/25route goes out through ppp0, so the ping measures the TUNNEL. A baseline measured at 1 packet/s: ~34ms average, 0.8ms mdev, 0% loss, and that is where the verdict's cutoffs come from (stable / slow / unstable / bad). • Theping -I <iface>is not a style detail: it pins the packet to the tunnel (SO_BINDTODEVICE). Without it, a target that stopped being routed through the VPN would go out over the home internet and the panel would display a GREAT latency that is not the tunnel's. A wrong number is worse than a missing number, and with the bind that case becomes "no probe". MEASURED PROOF: the same target pinned toenp7s0gives 100% loss, so the displayed number cannot come from outside the tunnel. • THE 1st VERSION MEASURED IN BURSTS AND THE NUMBERS WERE PRETTY AND FALSE. The panel was already finished and approved ("it is all correct") when the question "are these numbers reliable?" knocked down half of it. It pinged 3 packets every 20s. MEASURED side by side at the same hour: the burst gave an mdev of 0.4ms; a 20s window gave an mdev of 3.3ms with a peak of 54.7ms. Which means it observed 0.6s out of every 20s (3% of the time), so a 2s hiccup was invisible in 97% of cases, and the loss, with 3 packets, had a RESOLUTION OF 33%: 1 to 3% of real loss showed up as "0%". Only the average survived the audit (33.6 against the 34.4 of the 40 packet reference). The lesson: a short sample does not answer a question about STABILITY, and a panel displaying half a second of jitter with a monitoring look lies by omitting the scale. • THE FIX was turning it into a CONTINUOUS probe at 1 packet/s (the VpnProbe component, inBar.qml) with statistics over a window of the last 60 packets: real jitter, loss with 1.7% resolution and one bar per second in the graph. Measured cost: 84 B/s, and 30 packets at 1/s gave 0% loss, so the target does not rate-limit at that cadence. The ping's-Ois MANDATORY: without it, a lost packet is SILENCE and the series would contain only the ones that came back, an eternal 0% loss, the same kind of lie again. •pingis line-buffered even when writing to a pipe (verified: one line per second, with nostdbuf), which is what makes reading the stream viable. • A WATCHDOG, because a dead probe is the worst possible failure HERE: with-Othe ping speaks every second even when the target disappears, so SILENCE is not packet loss, it is the process broken. Without a watchdog the panel would freeze displaying the last good window, looking "stable", which is exactly the lie it exists not to tell. 5s without a line means marking the hole in the series and resurrecting the ping. TESTED by killing the probe: it came back in 10s. • THE TUNNEL DROPPED AND CAME BACK in the middle of the work, and it taught two things. (1) Theping -Idoes NOT die on the reconnect: ppp0 is called ppp0 again and the bind re-resolves, verified through the process'swchar, which kept growing at ~1 line/s. (2) Precisely because of that, the series would splice two different tunnels as if they were one; the IP entered the probe's key (it changed from 192.168.50.2 to .3) to force a new series. • To VALIDATE a QML change here, useqs-restart: the hot-reload kept a Timer from the old tree ALIVE (a debuggingconsole.logkept coming out after the file was already clean and the log said "Configuration Loaded"). quickshell.nix already warns that the hot-reload does not reapply everything, and that holds for an object with a Timer/Process, not only for a Repeater delegate. • THE GRAPH exists because "is it stable?" is a question about TIME: a lone "34 ms" does not distinguish a smooth tunnel from one that swung 30 to 900ms in the last minute. The scale starts at ZERO (the ceiling is 60ms, or 15% above the peak), because auto-scaling from the minimum would turn 0.5ms of variation into a dramatic sawtooth, the opposite of an honest reading. • Two QML traps, both with a silent symptom: areadonly property real topin the delegate dies with "Cannot override FINAL property" (the name collides with the Item inheritance) and becamescaleTop; and an INLINE COMPONENT DOES NOT SEE THEidOF THE DOCUMENT that declares it, so theroot.vpnStats[...]inside VpnProbe blew up with a ReferenceError, the instance was never born, and what complained was the POPOVER, anundefinedthree files away from the cause. The data started ARRIVING through a property. • The target is memorized in$XDG_RUNTIME_DIR/vpn-probe-<id>, keyed by iface+IP: a new tunnel means a new probe, and "not found" is reevaluated every 5 min (otherwise a target that was down at the instant of connection would condemn the panel to "no probe" until disconnecting). • The panel was born at 300px and the footer cut off the probe's IP ("200.136.209…"). It went to 360 with a two-line footer: a diagnostic panel that elides data is self-contradictory, because whoever opens it is precisely after the detail. -
[x] Azure MCP Server, and ONLY on the FAI account (14/08/2026). The request was to work on portal.azure.com by command instead of clicking through the interface. It became
pkgs/azure-mcp.nix(theazmcpbinary) plus anmcpfield in theprofilesofhome/shell/claude-code.nix. • WHY PACKAGE IT instead of following Microsoft's recipe: the docs saynpx -y @azure/mcp@latest server start, which rule 13 forbids twice, an implicit "latest" AND a fetch with no hash ON EVERY SERVER START, which means the MCP could change version in the middle of a session. The npm@azure/mcpis only a JS shim that picks, in the postinstall, one of the six@azure/mcp-<os>-<arch>; what has the server is the platform package, and inside it comes ONE self-contained .NET binary of 150 MB. So we fetch the platform tarball directly andnodejsleaves the closure entirely. • THE FINDING THAT COST THE FIRST TWO ATTEMPTS:runtimeDependencies(RPATH) does NOT resolve this binary's libs, and the error does not say why. With nothing:Couldn't find a valid ICU package. With icu+openssl in the RPATH: ICU passes and then comesNo usable version of libssl was foundplus a core dump. The reason is that it is single-file and it UNPACKS the native .NET libs into~/.net/azmcp/<hash>/on the first start (libpal_azure_c_shared_openssl3.sois there), and it is that extracted .so, outside the store and with none of our RUNPATH, that does the dlopen. LD_LIBRARY_PATH throughmakeBinaryWrapperis inherited by the extracted libs and resolves it; andmakeBinaryWrapper(execv in C) and not a shell wrapper, because .NET findsInstrumentation/Resourcesthrough /proc/self/exe, which is only right AFTER the exec. •azure-cliIS NOT NECESSARY FOR THE LOGIN, and that is worth recording even though it came in later for another reason (the item that follows): Microsoft's docs and azmcp's own error message tell you to install it, but the DefaultAzureCredential chain ends in DeviceCodeCredential, the "open login.microsoft.com/device and type ABC123" one, which works on its own. It was only failing withPersistence check failed, which is NOT a credential issue: it is the MSAL token cache trying to talk to the Secret Service.libsecretanddbuswere missing, and this machine already has both through the keyring. With those two in LD_LIBRARY_PATH the device code came out immediately (verified: the code was emitted, with no login). Themsalruntime/libX11 in the error log are the WAM broker, which only exists on Windows, so ignore them. • AND THEN CAME THE CORRECTION THAT KNOCKED DOWN THE PREMISE: the Azure MCP Server does NOT COVER APP REGISTRATION, which was the REAL reason for all of this ("I do not want to touch that interface anymore"). I had delivered the MCP without checking whether it did the one thing that was asked. MEASURED intools/list: among the 68 tools there is no App Registration, no service principal and no Graph;roleis Azure RESOURCE RBAC, not an Entra app; andextension_cli_generateonly GENERATES the text of theazcommand, it never executes. Microsoft does not publish an Entra MCP either, sincemicrosoft/mcponly has Azure, Fabric and a template. Theentra-app-registrationfrommicrosoft/azure-skills, which looked like the missing piece, is a SKILL (guidance markdown) and not a tool: what it teaches the agent to do is runaz ad app create/list/show/permission add/…. Which means what does the work isazure-cli. It went in (home/packages.nix), and the cost is 0.95 GiB MARGINAL and not the 1.19 of the closure, since 0.24 was already on the system. THE LESSON, and it is about method, not about Azure: "the official tool for service X" does not imply "it covers the part of X you want". The tool list is cheap to read (a JSON-RPC handshake over stdio) and it should have come BEFORE the package, not after. • WHY THE MCP IS PER ACCOUNT and not global: the cloud is the work one, and there are 68 tools in--mode namespace(counted through a JSON-RPC handshake straight at the binary) that have nothing to do on the personal account. Soprofiles.fai.mcp = [ azureMcp ]andprofiles.pessoal.mcp = [ ], and the account that does not declare it does not get the flag. • THE--mcp-configTRAP, measured on CC 2.1.222, and the rule is the OPPOSITE of the intuition: the flag is VARIADIC, so it swallows everything until it finds a token starting with "-". With no terminator,claude-fai mcp listdies withMCP config file not found: …/mcp(it readmcpandlistas two more files). But the--fixes ONLY the subcommand and BREAKS the flag: with it,claude-fai --versionopens a session with "--version" as the prompt. Hence thecasein the wrapper: starting with "-" (or empty) goes without--; a bare word goes with it. • THREE PATHS REFUSED: (1) the root.mcp.json, which is where the two Cloudflare MCP servers live, because it is PROJECT scope, so Azure would only exist when runningclaudeinside the dotfiles, which is precisely where we will never touch Azure; (2) user scope in.claude.json, which is app state, since CC rewrites the whole file (rule 14); (3)/etc/claude-code/managed-mcp.json, which LOOKS like the right place for being the sibling of the hooks' managed-settings.json, and is a trap: whoever deploys that file gains EXCLUSIVE control and CC stops loading everything else, including the MCP servers from thegithubandatlassianplugins, which are in use. It would gain one and lose two. • AS A BONUS, THREE ROUGH EDGES IN THE MODULE: plainclaudebecame a wrapper (it was the raw binary, and without this the MCP would not reach the VS Code extension, which calls the binary from the PATH); the.claude-faithat was written twice becamedefaultProfile(rule 11); andclaude-pickstopped repeating the CLAUDE_CONFIG_DIR logic, since the menu now carries the WRAPPER PATH and it only does anexec, so it inherits the MCP and the variable for free. • THE LOGIN HAS TWO TRAPS, and both LIE about what happened. The first: a plainaz login, on an account whose tenant has no subscription, ends inNo subscriptions foundand PERSISTS NOTHING. The authentication passed; it wasazthat aborted afterwards, and the followingaz account listsays "Please run az login", which reads as "your password failed". The right form isaz login --allow-no-subscriptions --tenant <id>, and it is not a corner case: an App Registration is a DIRECTORY object, so the work tenant here legitimately has no subscription at all. The second:az ad app liston its own lists nothing, it requires a selector (--show-minefor the ones you own,--allfor the whole tenant, which then asks for an admin role in Entra). • TWO IDENTITIES, with OPPOSITE roles, and that decides which tool serves what:FAIUFSCar(80241bb1-cb3b-4da2-98ae-3029430fdbcd) is directory only, which is where the App Registrations live and is the target ofaz ad;BHS(92247c24-8a8c-47f3-a7f1-85df939ad4b6) is the one with a subscription, and it requires MFA (AADSTS50076, tenant configuration, not a defect here). Which means: App Registration =az adon FAIUFSCar; the MCP's 68 tools, which operate over a subscription, only have work to do on BHS. • VALIDATED END TO END:nixos-rebuild buildOK; the server comes up (initializeplustools/listthrough JSON-RPC over stdio); thesystem/initof a headless session lists{"name":"azure","status":"connected"}next to the plugins;az ad app list --show-minereturned the tenant's 3 apps; andazmcp subscription liststarted answeringstatus 200withsubscriptions: []instead of 401, which proves that AzureCliCredential closes the chain and the device code became plan B, not the path. -
[x] The hyprsunset curve comes down again after 18:00 (13/08/2026). The question that opened this was a different one: "is hyprsunset starting along with the PC? because it does not look like it". There was no defect at all in the autostart, and the reason it "does not look like it" is the config itself. • THE MEASUREMENT THAT DISPROVED THE SYMPTOM: boot at 11:58:58, the service active at 11:59:05, 7 s later, and the FIRST thing it did was
Applying profile from: 8:0thenResetting the matrix (--identity passed). Which means it came up and applied the filter TURNED OFF, because from 08:00 to 17:30 the profile isidentity. Nothing visible at boot was the CORRECT behavior. The service isenabled, 8 h up, 234 ms of CPU, zero restarts. • A GENERAL TRAP, which holds beyond this service: an automation whose correct state is "no visible effect" canNOT be audited by eye. I came close to treating perception as evidence of failure; what closed the question was the journal (Switched to new profilehour by hour: 17:30 to 5500K … 20:00 to 3200K) plushyprctl hyprsunset temperaturematching the profile for the clock time. • THE CTM REACHES BOTH SCREENS, and that was VERIFIED, not presumed: an A/B through IPC (identity, then 3000K, then back to the profile) with me watching. It changed on DP-2 and on the LG TV over HDMI. It mattered to confirm that before touching the curve, because if one output did not receive the CTM, no Kelvin adjustment would solve anything, and the 08/08 history already records that hyprsunset applies to ALL outputs or to none (it cannot target an output). • THE CHANGE, then, is one of PREFERENCE and not a fix: every step after 18:00 came down ~200 to 400K (18:00 4200 to 3800, 19:00 3500 to 3200, 20:00 3200 to 3000, 22:00 2800 to 2600, 23:00 2500 to 2400). There are still 13 profiles and the biggest step is still at 18:00 (now 5000 to 3800). • THE COLOR AXIS WAS CHOSEN AGAINST WHAT ideas.md RECOMMENDS. It is recorded there that reducing BRIGHTNESS comes before color temperature, and that "night mode does not replace adequate brightness"; the post-18:00 curve still touches only Kelvin, with gamma entering only at 22:00. It was a conscious choice of mine after seeing both proposals side by side, not carelessness: the automatic gamma dim existed and was REVERTED on 08/08 along with the DDC, and reintroducing it is a bigger change than lowering Kelvin. • WHAT STAYS OPEN and is noted in the module header: if this curve is not enough, the next step is progressive gamma from 18:00 on, and not continuing to lower Kelvin, which from here down makes the color worse with no proportional relief. • THE ACCEPTED PRICE: the curve now crosses ~3200K on purpose, and thehyprsunset.nixheader warned that below that the color ruins a film, a game or a photo. From 19:00 on that becomes the NORM, so SUPER+SHIFT+F9 (identity) stops being a rare escape hatch and becomes a routine gesture for opening media at night. The next profile on the clock resumes the curve by itself. -
[x]
/mnt/arch-antigomounted ALWAYS, andarch-browsedied (11/08/2026). The symptom was opening the Dolphin bookmark and seeing an empty folder. There was no defect: the secrets were readable, the repo answered, and the mount came up in ~20 s when asked. The defect was the DESIGN, an automation with no declared owner (rule 15), alive only while the alias's terminal stayed open. It became a service:home/services/arch-legacy-mount.nix(the unit that mounts) plussystem/services/arch-legacy.nix(the mountpoint and the path's SSOT, which the bookmark started reading, rule 11). • WHY TWO FILES, and it is not rule 4 fussiness: whoever MOUNTS has to be the user (a FUSE mount is private to whoever mounted it;sudo restic mountproduces a folder Dolphin cannot open), but whoever CREATES the directory has to be root, because /mnt is root's. And the option is born on the system side because a system module cannot read a home-manager option, while the reverse does not exist. • The directory leftrestic.nix, where it was created along with /mnt/backup. There it was tied to therestictoggle: turning the backup off would start taking down a mount that is now PERMANENT, and the failure would show up far from the cause. •--no-lock, and this was MEASURED, not economized: everyrestic mountcreates a non-exclusive lock and renews it every ~5 min, and a mount that does not exit cleanly leaves the lock STUCK. The repo had 3 locks: one from the live mount and leftovers fromarch-browseon 05/08 and 08/08. Permanently, that would only get worse, and it would also write to the offsite repo every 5 min forever. The lock protects concurrent reads against PRUNING, and this repo is static: nothing has written to it since 01/08 and no routine prunes it (forget --pruneonly looks at the HOME repo). Checked afterwards: the mount is up, 0 locks. • TheType = "notify"from ~/Drive canNOT be copied:restic mountdoes not speak sd_notify (there is no "notify" in the 0.18.1--help). With Type=simple, systemd would declare the unit ready before the mountpoint existed, reproducing the very symptom the change came to kill. Hence the ExecStartPost that waits formountpoint -q(writeShellApplication, rule 7), with TimeoutStartSec=180 so the wait fits. • The rclone binary goes PINNED in-o rclone.program=: therclone:backend EXECUTES rclone and a systemd unit does not inherit the PATH, the same trap that already killed the backup service ("executable file not found in $PATH"), solved without depending on the PATH. And the rclone.conf is a WRITABLE COPY in%t, in a file of its own (not ~/Drive's): without that, the renewed OAuth token producesFailed to save configin the journal, and in a 24/7 service a recurring ERROR hides a real one. • THE PRICE, measured: ~195 MiB of resident RSS (115 from restic with the index of the 44.6 GiB snapshot plus 79 fromrclone serve restic). On the network, idle, it is ZERO, because restic does not poll. The bookmark comment said a permanent mount would be "an open connection and a lock on the repo for nothing": the connection is real and became a conscious choice, and the lock stopped existing. And the Dolphin thumbnail warning got MORE important, not less, because previews read content, every read downloads packs, and now the folder is always one click away. • An empty folder there stopped being a normal state and became a SYMPTOM: the diagnosis starts atsystemctl --user status arch-antigo-mount. -
[x] The
switchthat activated but did not become the boot, because of an UNRELATED unit (11/08/2026): therebuildfor the mount above ended withActivation (test) failed(exit 4) and the culprit wasvpn-fai, which had nothing to do with the change. It was already flapping before (a restart counter at 43) because the FAI server answers"Password change needed", an expired institutional password.nhruns the activation intestBEFORE pinning the generation, and it aborted thebootphase. • THE DAMAGE IS SILENT and does not show up at the time:/run/current-systemwas already the new generation (everything working in the session), but/nix/var/nix/profiles/systemstayed on the OLD one, which means the next boot would go back to the system without the change. Whoever does not compare the two does not see it. A one-command diagnosis:readlink -f /run/current-system /nix/var/nix/profiles/system, and equal means it closed. • THE LESSON, which is bigger than this case: a unit failing for an EXTERNAL reason (an expired password, a remote service down) hijacks the entire switch. Asystemctl stopon it plus rebuilding again solved it, sincevpn-faiislinkedwith noWantedBy, so stopping it loses nothing, because it does not come up at boot anyway. • Pending on the FAI side: change the password in the portal and updatefai_vpn_passwordin sops (it feeds thenxbender-fai.conftemplate,system/net/vpn.nix) plus a rebuild. Until then,~/FAI-workstationandssh workstationstay out. -
[x]
claude-fai/claude-pessoal: TWO Claude Code accounts, and~/.claudestopped being one of them (11/08/2026). On Arch that was two aliases and a shell function in~/.zshrc(_claude_share_projects), running on every terminal open. It becamehome/shell/claude-code.nix: aprofilesattrset that is the accounts' SSOT and generates everything, the wrappers, theclaude-pickmenu, thesettings.jsonsymlink and theprojects/symlink. A new account is one entry in it plus asettings-<name>.json. • I GOT THE FIRST VERSION WRONG, and the mistake is instructive: I created an EMPTY~/.claude-fainext to~/.claude, which ALREADY WAS the FAI account (oauthAccount.emailAddress= victor.ferreira@…, a nonprofit premium seat). That would be two logins for the same subscription, and the "third account" would exist purely by accident of naming. I copied the Arch topology (default + 2) without checking WHO each folder was HERE. The lesson: in a migration, the question is not "which folders existed there", it is "what each folder IS here", and the answer was onejq .oauthAccount ~/.claude.jsonaway. • Plainclaudebecame FAI, throughhome.sessionVariables.CLAUDE_CONFIG_DIR. It catches everything that calls the binary without going through the wrappers: the VS Code extension, a script, cron. It only applies in a NEW shell (hm-session-vars.sh is read at the start of the session), so the terminal that ran therebuildstays on~/.claudeuntil it is closed. The same trap as NH_FLAKE on 03/08, with the difference that here a new terminal already solves it. •~/.claudeSTILL EXISTS, now as the ARCHIVE and not as an account:projects/(200 MB, 13 projects, 39 memories from this repo) belongs to the MACHINE, not to a subscription. Staying on the canonical path lets third party tooling find it by itself and prevents retiring an account from one day orphaning the archive. Considered and refused: moving it inside.claude-fai(asymmetric) and moving it to a neutral path (200 MB moved with a live session writing there). • WHAT MIGRATED from~/.claudeto~/.claude-fai, because it is the SAME account: the installed plugins (8.1 MB,github/atlassian/frontend-design, which the repo'ssettings-fai.jsonstarted declaring instead of the Arch ones, otherwise the migration would silently turn them off),settings.local.json(permissions already approved), the.claude.jsonWITHOUToauthAccount/claudeCodeFirstTokenDate(17 trusted folders preserved, the account fields left for/loginto rewrite) andhistory.jsonlconcatenated with the Arch one, 128 + 1705 lines, and the concatenation is chronological for free because the windows do not overlap (Arch ends in june, this machine starts in july). • A WRAPPER INSTEAD OF AN ALIAS, and the difference is not cosmetic: an alias only exists in an INTERACTIVE zsh, so on Archclaude-faidid not work over non-interactive SSH, inside a script, in a VS Code task or in a Hyprland keybind. Now they are binaries generated bywriteShellApplication(rule 7: the logic in the build), which for free PINS the version of theclaudebeing called, and that matters here, because this machine has an orphan native install in~/.local/binthatclaude doctorcomplains about and that the PATH could resolve first. • WHAT DECIDED THEsettings.jsonDESIGN, and it was MEASURED instead of assumed: the file is linked into the repo throughmkOutOfStoreSymlink, the same contract as VS Code (home/apps/vscode.nix), and that is only safe if CC does not replace the symlink with a regular file on save. It writes ATOMICALLY (tmp + rename), which would KILL the link, but it resolves the realpath FIRST: runningclaude auto-mode resetin a test profile, the symlink stayed intact and what changed inode was the TARGET (593793 to 593844). In other words: the TUI's/configkeeps working and every adjustment lands as agit diffinstead of invisible drift (rule 16). If CC ever loses that guard, the symptom is~/.claude-fai/settings.jsonno longer being a symlink and the repo no longer receiving anything. • Do NOT pointCLAUDE_CONFIG_DIRat~/.claudeto "reuse" the default account: the.claude.json(project/MCP config, distinct fromsettings.json) lives at the ROOT ofCLAUDE_CONFIG_DIR, which in the default case is the home's~/.claude.json, and with the variable pointed at~/.claudeit would become~/.claude/.claude.json, a SECOND divergent file. Verified on 2.1.222, along with the rest:claude mcp addwith a symlinked.claude.jsonwrote THROUGH the link. • TWO CONFIGS DIED IN THE CROSSING (rule 16) and that is why thesettings-*.jsonare not a faithful copy of the Arch ones: thepermissions.allowwithmcp__penciland the two user MCP servers that were in both accounts'.claude.json,pencil(/opt/pencil-dev-bin/…, an AUR package that does not exist on NixOS) andatlassian(throughnpx mcp-remote, today done by theatlassian@claude-plugins-officialPLUGIN, which the default account already uses). Migrating a permission for an MCP server that does not come up is declaring the nonexistent. •projects/IS STILL SHARED, now through a declared symlink: it is where the transcripts AND the per-project memory live (…/projects/<slug>/memory/), so any account resumes the same conversations and reads the same memories. A known price:ccusagedoes not separate cost per account, because it reads the common archive, so the number belongs to the machine, not to the subscription. • THE STATE CAME FROM RESTIC, NOT FROM THE REPO (rule 6): thehistory.jsonlof both accounts (128 and 179 prompts) came out of the Arch backup throughrestic dump, with nothing mounted, which is the better path when you want a specific file and not to browse. The.credentials.jsonwas NOT restored on purpose: a 7 week old token from a decommissioned machine is worth less than a clean/login, and a credential is neither declared nor copied by a script (rules 6 and 12). • ThewriteShellApplicationshellcheck failed the first build with SC2155 (export X="$(cmd)"masks the command's exit code). Rule 7 paying for itself in the build instead of in a runtime bug. -
[x] VT-x turned on in the BIOS, and the next step this history told me to take is NOT necessary (11/08/2026). The Cowork entry (08/08) closed with "turn VT-x on and ONLY THEN add
users.users.v1cferr.extraGroups = [ "kvm" ], which did not go in because it is not validatable without the device". The device exists now, so it could be validated, and the validation DISPROVED the plan. • VT-x confirmed:vmxin the/proc/cpuinfoflags,kvm_intelloaded,/dev/kvmcreated at 07:11. TheVMX (outside TXT) disabled by BIOSlines are gone from the log. • ThekvmGROUP DOES NOT GO IN:/dev/kvmis born in mode 666 (a udev rule NixOS already ships), groupkvm. It was measured thatv1cferrhas read AND write on it WITHOUT being in the group. Adding theextraGroupswould declare a permission the system already gives everyone, which is dead config by rule 16, on the same day the rule was born. • THE LESSON is about the shape of the note, not about kvm: "do X and THEN do Y" records a HYPOTHESIS about Y as if it were a plan. When X finally happens, Y gets executed with nobody rechecking whether it still makes sense, and here it did not. A note about a future step should carry the TEST that decides whether it is needed (test -w /dev/kvm), not only the action. • Secure Boot is stillDisabledand that did NOT regress:Setup Mode: Disabledsays the keys are still enrolled, so only Phase 4 of the guide remains, flipping the switch. • As a bonus, the colorize diagnosis confirmed itself: 19 h without a single abort (the last one on 10/08 18:30) against one every 30 to 60 min before. The coredumps that still show up incoredumpctlall predate the removal, so they are a record, not activity. -
[x] GNU coreutils across the whole
cesarmachine, and nothing had to be installed (11/08/2026). Git for Windows already bundled the complete userland (coreutils 8.32, grep 3.0, sed 4.9, awk, less, vim) inC:\Program Files\Git\usr\bin; it just was not exposed, because the Git installer puts onlyGit\cmdon the PATH. The change was ONE entry in the machine PATH. • The whole decision is in the ORDER: append at the END, never at the front. That directory bringsfind.exe,sort.exe,tar.exe,link.exeandecho.exe, all with a Windows namesake of different semantics. Prepending, which is what the Git installer offers as an option, with a warning, breaks.batscripts and MSVC builds, because the MSYSlink.exeis not Microsoft's linker. At the end, you gain everything that does not conflict and lose nothing. MEASURED withwhere:find/sort/tarstill resolve to System32, andlsresolves to the GNU one because only one exists. • IN POWERSHELL THIS PAYS OFF LESS THAN IT LOOKS, and it is not a PATH problem:ls,cat,cp,rm,sort,curlandechoare native ALIASES, and an alias always beats the PATH. There it isls.exeby hand. Incmdthere are no aliases and it works directly; inside bash the question does not even come up. • Applied throughpowershell -EncodedCommand(base64 UTF-16LE) instead of nested quotes: the command crosses zsh, ssh, cmd and powershell, and each layer eats a level of quoting. Adir /b "C:\...\usr\bin" | find /ceven reported "path not found" on a directory that EXISTED, purely from cmd's mangling in the pipe, and it almost became "this Git installation is minimal and does not have the utilities". • The script is idempotent (-split ";" -notcontains), because a machine PATH is exactly the kind of thing you apply twice without noticing. • The guide was born:docs/guides/cesar-windows-manual-steps.md, covering the authorized key, the PATH, Scoop and Claude Code. Those are the steps Nix does NOT reach (the machine is not NixOS and is not mine), and without them written down a Windows reinstall would become rediscovery from scratch. The same nature as the OpenWrt router'sauthorized_keys. • VERIFIED as a bonus thatSetEnv TERMis still unnecessary: inside Git Bash,TERMalready comes asxterm-256color, set by the shell itself. The 10/08 decision not to send the variable holds now that the shell has changed. -
[x]
ssh cesar: my brother's PC became a declarative host (10/08/2026). Access already worked by hand (ssh v1cferr@192.168.1.40); what went into home/shell/ssh.nix was the alias and, above all, the record of WHY this host looks like none of the other three: it is the only Windows one in the file. • InheritingfaiResiliencewould be cargo cult. That block exists to tolerate the SonicWall tunnel's routing hole within VS Code Remote-SSH's 17 s budget. Here it is a LAN hop, <1ms, the same decision already taken forrouter, and the file's pattern becomes "resilience is opt-in, not default". • NoSetEnv TERM, unlike the other three hosts. The default shell of the Windows sshd is cmd.exe, which does not read TERM, and their sshd does not carryAcceptEnv, so the variable would be discarded at the server. Copying it for symmetry would give a line that does nothing and that the next reading would try to "fix". • The post-quantum warning on every connection is NOT our error. The server isOpenSSH_for_Windows_9.5(measured with-v), andmlkem768x25519only exists from OpenSSH 9.9 on; Windows 11 build 26200 still ships 9.5. It only goes away when MS updates Win32-OpenSSH. REFUSED to silence it withWarnWeakCrypto = "no"(it exists in our 10.4): silencing it per host hides the server's real lag, and the day it gets fixed would go unnoticed. The warning is honest noise. •ssh-copy-iddoes NOT work against Windows, because it assumes a POSIX shell on the other side, and on the other side there is cmd.exe. The manual step is run ON my brother's machine, and WHICH file depends on whether the user is an administrator: if they are, the Windows sshd ignores their~/.ssh/authorized_keysand only readsC:\ProgramData\ssh\administrators_authorized_keys, which still requiresicaclsrestricting inheritance, otherwise sshd refuses the file and falls back to the password silently on the client side. DONE the same day, and validated withssh -o BatchMode=yes ... "echo OK":BatchModeforbids the password prompt, so theOKproves it was the KEY that authenticated; without it the test is ambiguous, because you type the password and conclude the key worked. •Add-Content -Encoding asciiis not fussiness: PowerShell's default for a file is UTF-16, and sshd does not readauthorized_keysin UTF-16, a SILENT failure that falls back to the password without saying why. The same class of trap as theicaclsone: both ways of getting this wrong are silent and indistinguishable from each other. • The account PASSWORD was changed along with it, and the decision was MEASURED first: Windows distinguishes CHANGING a password (with the old one in hand, which re-wraps the DPAPI master key) from RESETTING it (net user v1cferr *, which makes it unrecoverable and takes Credential Manager, browser passwords and EFS along with it, all silently). There is no "change" path from the command line without P/Invoke, and over SSH there is no Ctrl+Alt+Del. The reset was safe becausecmdkey /listcame back* NONE *(nothing to lose) andGet-LocalUser | Select PrincipalSourcecame backLocal; had it come backMicrosoftAccount, neithernet usernorSet-LocalUserwould change anything, because the password would belong to the MS account. • A literal IP (192.168.1.40) and not amy.*option: it is cited in a single place, the same justification asrouter, since a lone literal does not trigger rule 11. But it is DHCP: if the router hands out another address, the alias breaks, and the fix is a DHCP reservation on OpenWrt, not one more option here. • The shell became GIT BASH, and bash had been there the whole time.RequestTTYplusRemoteCommandpointing atC:\Program Files\Git\bin\bash.exe, tested, landing inv1cferr@Cesar MINGW64 ~$.where bashLIES on that machine: the onlybashon the PATH isC:\Windows\System32\bash.exe, which is not bash, it is the legacy WSL stub, and there is no distro installed. The real bash does not show up inwherebecause onlyGit\cmdis on the PATH and the binary lives inGit\bin. It almost became "this machine has no bash", when it did. • And the coreutils came with it, with nothing installed. Git for Windows bundles the entire GNU userland (ls,grep,sed,awk,find,less,tar,curland so on), so the complaint about "missing coreutils" was really the complaint about landing in cmd.exe. • REFUSED to change the shell through the registry (HKLM:\SOFTWARE\OpenSSH\DefaultShell): it is GLOBAL, it would change the shell of every SSH session on the machine, including its owner's. On the client side the choice is only ours and it disappears along with this repo. • REFUSED to install WSL, even withv1cferrbeing an admin: my brother's project is Windows-native Gradle/Java (gradlew.bat), and running it from WSL against/mnt/ccrosses the I/O boundary that is exactly where WSL is slow, on top of planting GB of VM on somebody else's machine. THE ACCEPTED COST: Claude Code's sandboxing only exists on WSL2, so on native Windows the permission is the only barrier. •RemoteCommandand a command line are MUTUALLY EXCLUSIVE in ssh ("Cannot execute command-line and remote command"), so the block above on its own would BREAKscp,rsyncandssh cesar <cmd>. Hence the twincesar-cmd, the same host with noRemoteCommand:cesarto sit down and work,cesar-cmdto copy a file. The alternative was memorizing-o RemoteCommand=noneon every invocation. -
[x] A disk growth audit: Docker was the only one with no ceiling, and btrfs/GC needed nothing (10/08/2026). The question was "is btrfs fine? is there swap? does the GC clean up properly?". All three answers were "yes, do not touch it", and the real problem was in a fourth place nobody had looked at. • Docker: 11.35 GB of build cache, 8.5 GB recoverable, ZERO policy. Every neighbor already had a ceiling: journald at
SystemMaxUse=2G, coredumps vacuumed by systemd, btrbk withsnapshot_preserve, nix with a weeklygc. Docker was the only one with nothing, and thegrad-radar.nixthat came in the day before made it WORSE: it runsdocker compose buildin ExecStartPre, on every boot. Solved in system/services/docker.nix; the manual prune recovered 8.501 GB (the cache went from 11.35 to 2.85). • TWO OPTIONS REFUSED, and both destroy data in the SAME window, with the stack STOPPED at the time of the weekly prune. Neither raises an error; they successfully delete what they should not.allVolumes.enableprunes NAMED volumes, which is whereduo_duo-db-dataandgrad-radar_db_datalive: with the compose down, the prune would delete both Postgres instances.flags = ["--all"]removes a TAGGED image with no running container, and the local images do not come from a registry, so gone means a rebuild, which in grad-radar includes apnpm installinside the container (the unit hasTimeoutStartSec = 1800for that reason). The--allis what most public configs use; here it does not pay off. • systemd'sweeklyis Mon 00:00, which is exactly thenix-gcminute. It becameMon 04:30, after restic (03:00 plus up to 30 min) and nix-optimise (03:45). Two heavy I/O cleanups on the same NVMe in the same minute gain nothing from coinciding. • btrfs: nothing to do, and one popular piece of advice NOT to follow. The mount options arenoatime,compress=zstd:1,ssd,discard=async,space_cache=v2, which is the correct set. Do NOT addfstrim.timer:discard=asyncis already continuous TRIM, and the timer is not enabled precisely for that reason. Allocation is healthy with ~398 GiB unallocated (Data 548 GiB allocated / 530.7 used; Metadata DUP 7 GiB / 3.99), so there is no pendingbalanceeither, which is what bites btrfs users in the long run. • Swap exists, in two layers: zram 7.7 G at priority 5 (hot, compressing 2.7 G into 981 MB as measured) and a 16 G swapfile at priority -1 (cold), the latter with 0 B used, so the fast layer handles it alone. The proof that the swapfile is correctly NOCOW is notlsattr, it is the kernel having accepted theswapon: btrfs REFUSES to activate a swapfile that is CoW or compressed, so being active is the validation. • The GC was already right: weekly plus--delete-older-than 30d, plus a dailynix-optimise. Recommended NOT to tighten it, since the whole/nix/storeis 53 GB out of 953 GB, which means it is not what grows; shortening the window would cost rebuild cache and rollbacks to recover space that is not missing. • A MISTAKE ALONG THE WAY, and the mechanics are worth more than the mistake: acaddy.nixand aservices.nixfrom other work were already in the INDEX; agit addof the two files for this change plus a commit took the WHOLE index along, so unrelated work landed in this commit. Undone withreset --soft HEAD~1plusgit commit -- <paths>, which commits only the given paths and leaves the rest of the index alone. THE RULE: in a tree with other work staged, ALWAYS commit by pathspec. • The--dry-runthat had been written in the module's comment DOES NOT EXIST in Docker 29.6.2 (checked in the--helpbefore committing). The real preview isdocker system pruneWITHOUT the-f, which lists the categories and asks y/N. -
[x]
wg-status: WireGuard visibility with no password, and a sysupgrade bug it revealed (10/08/2026). There was no way to answer "is the phone connected?" without the root password, and a ping only proves reachability, not a handshake. • A WRAPPER, NOT THE BINARY:/usr/bin/wg-status=exec /usr/bin/wg show, with a fixed subcommand and WITHOUT forwarding"$@". Putting the whole/usr/bin/wgin NOPASSWD would givewg set, which REPLACES A PEER KEY, so whoever had only the SSH key could enroll themselves into the VPN with no password at all. With no argument forwarding there is nowg-status set. • root:root 0755 and OUTSIDE /home. If the user could write to the file, rewriting it would give arbitrary root with no password and the wrapper would become the hole it exists to avoid. That is why it did NOT go into /home/v1cferr/bin/, which would be the convenient place for already being preserved. The form is copied from /usr/bin/wake-desktop, which already lived here the same way. • A PRE-EXISTING BUG FOUND ALONG THE WAY:/usr/bin/wake-desktopwas NEVER in/etc/sysupgrade.conf. sysupgrade preserves/etc/sudoers.d/but NOT/usr/bin/, so the next upgrade would delete the binary and LEAVE the NOPASSWD rule pointing at nothing, with Wake-on-LAN through the router dying silently and the config looking right. The rule and its target have to be preserved TOGETHER. Both were listed. • WHAT THE FIRSTwg showSHOWED, and it is worth more than the wrapper: 1. The phone is connected (a 1m57s handshake, 3.14 GiB sent), but the endpoint is186.219.82.216, which is UFSCar (AS52888), block186.219.80.0/20. Which means it is on the campus WiFi, NOT on mobile data. That corrects the reading I had made of the RTT: the 80 ms with 20 ms of jitter are not 4G, they are the same path as the notebook (35 ms) plus the WiFi hop. 2. THERE IS A THIRD UFSCAR BLOCK, and it is NOT in themoonlightSourcesof system/services/sunshine.nix. The practical consequence: DIRECT Moonlight from the phone would be refused. Today it works only because WireGuard's 51820 has nosrc_ip. DECISION: do not widen the allowlist now, because the phone already has a path that works, and opening another range for a device that does not need it is a bad trade. It is recorded here for when somebody asks "why does the phone not connect directly?". 3. In 17 DAYS of uptime, only ONE peer did a handshake.notebook(.2) never did, which is consistent, since that is exactly what today's direct access replaced. Butfai-workstation(.5) haspersistent_keepalive = 25, meaning it was configured to keep the connection alive, and it did not connect once. Either it is off, or the peer is legacy. See the open items. -
[x] Router:
/etc/init.d/firewallenters NOPASSWD (10/08/2026). The scripts/router-moonlight-forward.sh needed a password only for thereloadat the end, and that made it interactive for nothing. • THE INCREMENTAL RISK IS ZERO, and it is MEASURED, not assumed, and that is what unblocked the decision:/usr/sbin/nftALREADY was NOPASSWD (confirmed withsudo -n nft list tables), andnft flush rulesettakes the whole firewall down. The power to turn the firewall off with no password already existed, through a WORSE path. The change only makes the legitimate path usable. Without that measurement this would have been refused as "privilege escalation", and the refusal would have been wrong. • METHOD, which holds for any sudoers change: validate withvisudo -c -fon the CANDIDATE file in /tmp, BEFORE touching what works, because a malformed sudoers makes sudo refuse everything, and the fix would require precisely the root that sudo would give. A second check of the whole set (visudo -c) after installing, with a rollback. • THIS IS NOT MIRRORED.router-synccovers/etc/config/and nothing else, so this entry is the ONLY record of the change in the repo.sysupgradepreserves/etc/sudoers.d/(it is in the 38 keep.d entries), so it survives an upgrade, but not a clean reinstall. • The final state:v1cferr ALL=(ALL) NOPASSWD: /sbin/reboot, /usr/sbin/nft, /sbin/uci, /etc/init.d/dnsmasq, /etc/init.d/firewall. An arbitrary command still asks for the password ((ALL) ALL), which is correct. -
[x] Moonlight with no VPN: a direct port forward, restricted to UFSCar (10/08/2026). The request was "connect to Sunshine from outside without joining the VPN, and directly, with no intermediate servers". The second half of the request was already true and nobody knew. • THE PREMISE WAS WRONG, and that is worth more than the implementation: WireGuard is not an intermediate server. Its endpoint is the home router ITSELF, so both the tunnel and the port forward travel exactly
UFSCar → internet → 177.52.84.188. There was no alternative route to eliminate. That WAS a risk with Tailscale (it could fall into DERP) and it left along with it on 08/08, so the fear outlived the cause. Do not rewrite this as a routing gain. • WHAT IS ACTUALLY GAINED is MTU: 1492 from PPPoE against ~1420 through the tunnel. Latency is noise. • WHAT MOTIVATES IT ANYWAY, and it is a good reason: the FAI notebook already runs nxBender + openconnect. Adding a third VPN client there is a routing conflict waiting to happen, and its owner refused to install WireGuard for that reason, and they are right. • THE PORTS WERE CHECKED IN THE BINARY, not in a blog post: the offsets were read from the web UI of the build in use (2026.516.143833,assets/web/assets/config-*.js): tcpport-5/port/port+1/port+21, udpport+9throughport+11. Almost every list on the internet includes a UDP 48002 ("mic") that DOES NOT EXIST in this version. There are three UDP ports, not four. • 47990 (the admin panel) was left OUT of both lists on purpose. Theorigin_web_ui_allowed = "wan"is only safe because it is not forwarded, and the comment that justified that value ("only the WireGuard range reaches this port") stopped being true today and was rewritten. • TWO LOCKS, and the host's-sis not redundant with the router'ssrc_ip: it is the one that survives somebody touching LuCI without reading the repo. The lists HAVE to match, because diverging gives "the router forwards, the host drops", a symptom indistinguishable from any other failure. • A NEW CONSTRAINT the change created:packet_sizeis GLOBAL and it now serves two paths with different MTU. The useful ceiling becomes the SMALLER one (the tunnel), so 1024 stopped being conservatism and became a lock. It only unlocks if the tunnel is retired. • ENCRYPTION does not regress, and that was not obvious: Sunshine classifies a client by IP. Through the tunnel it arrives as 10.10.10.x = LAN, solan_encryption_mode = 0(the tunnel encrypts). Direct it arrives public = WAN, sowan_encryption_mode = 1, on by default. • VALIDATED THE SAME DAY, with a real session of 21m58s plus 9min. The full measurements and method are indocs/guides/moonlight-direct.md(deleted 19/08/2026 with the path itself); the essentials: 0% loss across 100 packets of 1 KB, RTT 35.5 ms, jitter 0.54 ms. The proof that the session works end to end was Claude itself starting to run WITHOUTSSH_CLIENTand withDISPLAY=:0, inside the graphical session being streamed. • UDP GETS THROUGH, and the method of proof is worth keeping: the router's DNAT counter markedMoonlight-Stream-Campus → packets 3. That counter only counts the FIRST packet of each new flow (after that conntrack bypasses dstnat), so 3 is exactly video 47998 + audio 47999 + control 48000. After the reconnect it became 6, which is how it was confirmed that there was ONE reconnect and not several. •ping_timeout = 20000absorbed an 18.9 s hole at 11:47 without dropping the session. The proof is indirect and it holds as a method: the hypridle guard did NOT cycle (aStoppedat 11:25, noStartedat 11:47), which means the prep-cmdundonever ran, so Sunshine did not tear the session down. CORRECTION (same day): the 1st version of this line concluded "the hole was on the UFSCar side", because nothing had blinked on the home side. I confirmed afterwards that it was ME reconnecting, on both drops that day (18.9 s and 104 s). "It was not here" does not imply "it was the network": the third hypothesis, the user, was missing, and it was the right one. Theping_timeoutmechanism is still measured and true; what was invention was the attributed cause. There is no sign of instability on this route, with 0% loss in every test. • THE ROUTE, measured and with the owners through RDAP: router → Alcans (AS52783, the ISP) → Algar (AS16735, Alcans's TRANSIT) → IX.br/NIC.br (AS26162) → RNP (AS1916) → UFSCar (AS52888). Five autonomous systems, and NONE is an intermediate server, since they all forward packets and none terminates the connection. IX.br and RNP cannot be removed: UFSCar's internet comes from RNP. It is the empirical answer to the "no intermediate servers" request, and it confirms that the tunnel did not have any either. CORRECTION (same day): the first version of this line said Algar was the ISP, because it was the 1st PUBLIC hop in the traceroute. Wrong: the home IP (177.52.84.188) is in177.52.80.0/21, which is ALCANS TELECOM (AS52783). Algar is its transit. Reading "first public hop" as "my provider" is the same class of error as the traceroute read as proof of CGNAT: the hop says where the packet went through, not whose the subscription is. What answers that is RDAP on the home IP, not the traceroute. • LATENCY: noise, as predicted. The 35 ms belong to the physical path, which the tunnel travelled all the same. Recorded explicitly so nobody attributes a gain to this later. -
[x] ~~CGNAT~~ the dead item that outlived its own correction (10/08/2026): the 07/08 entry in
docs/open-items.md("THERE IS NO INBOUND … NO port forward rule can work") stayed there for three days AFTER the 08/08 entry in this file had already disproved it entirely. Deleted. • THE COST WAS NOT THEORETICAL: it is the first thing you read when asking "can I expose a port?", and the answer it gives is "no, give up". Today's work started by having to prove that the repo's own docs were wrong. • DISPROVED TODAY, with a method that does not lie (an independent external vantage point, not the FAI network):check-host.netfrom Austria, Canada and Iran connects to 2222 on177.52.84.188. A control in the same tool: 47984 gaveConnection refusedbefore the redirects, which is what makes the positive interpretable instead of merely optimistic. • AND THE TRACEROUTE STILL LOOKS LIKE CGNAT: hops 2 to 4 in172.31.xand hop 5 in100.127.255.225, which is the100.64/10range, the range DEFINED for CGNAT. It still is not: it is the ISP's internal transport. This is exactly the instrument that misled on 07/08, it has not improved, and whoever repeats the reading will get it wrong again. • THE PROCESS LESSON: correcting the history does not correct the open item. A reverted item has to die in BOTH places in the same commit, otherwise the repo ends up with two answers and the wrong one is in the file people read first. -
[x] Tailscale auth keys revoked: the 05/08 exposure is closed (09/08/2026). The orphan
.envat the repo root heldTAILSCALE=tskey-auth-kLXAR6…in plain text, mode 644, and the key was reusable, the worst case, because whoever had the string could join the tailnet as many times as they wanted. Deleting the file (05/08) reduced the local exposure and invalidated NOTHING; only revoking it in the admin console does that. I deleted every key on 09/08. • Tailscale fell out of use on this machine along the way:tailscaledisinactiveand thetailscalebinary does not even exist on the PATH, which made the revocation an act with zero blast radius. Recorded because it is the argument that unblocks this kind of item: when nothing depends on the secret, revoking has no downside and the only reason to postpone disappears. • The external access route is still the CGNAT open item, not this one. -
[x] A general audit of the setup, and what it found: the restic prune had been stopped for 4 days (09/08/2026). A complete hardware and software sweep asked for in the open ("is my system healthy?"). The verdict was healthy, but two findings only existed because nobody had looked: both were hiding behind noise that had already become routine. • restic:
forget --prunehad not run since 05/08 15:46.error: lstat /home/v1cferr/Drive: permission denied, so restic exits 3, so theunlockand theforget --prune, which are the 2nd and 3rd ExecStart, never get to run. It is the SAME trap as~/FAI-workstationon 05/08 (a user FUSE mount, with the backup running as root), with a mountpoint nobody remembered to exclude. Fixed in system/services/restic.nix. • THE FIX RAN and the result DISPROVED the estimate of whoever wrote this: I warned that a delayed prune could take a while and need more than one run, because of repacking on a remote repo. It took 14 s: it removed 1 snapshot, repacked 1 pack and freed 4.9 MiB. 6 snapshots / 26.1 GiB were left. • WHY IT WAS SO CHEAP, which is what keeps the next case from being overestimated: with--keep-daily 7and only 5 distinct days in the repo, NOTHING had aged out of the window in 4 days, and the only excess was the same-day duplicate. The cost of dead retention does not grow linearly with the days stopped: it is ZERO until the repo passes 7 distinct days, and only then does each new day start pushing one out. • AND THAT REORDERS WHAT THE REAL RISK WAS. It was not space on the Drive, it was theunlock, the other ExecStart that also was not running. A stuck lock from an interrupted run blocks the ENTIRE backup, not the prune, and that damage does not depend on how much time passed, it happens the first time a run dies mid-way. • WHY IT WENT UNNOTICED, and this is the part worth keeping: FAI-workstation only mounts with the VPN up, so it failed INTERMITTENTLY, and it was precisely the intermittency that made somebody investigate.~/Drivemounts on EVERY boot, so the failure became constant, daily and silent. A failure that happens ALWAYS is easier to ignore than one that happens sometimes: it becomes the service's normal state. The data was never at risk (a snapshot saved every day, 41.8 GiB, 297,740 files); what died was the retention. • VS Code: 15 coredumps in 2 days, and it is not the editor. What aborts with SIGABRT is the language server of thekamikillerto.vscode-colorize0.17.1 extension (coredumpctl infohands over the whole command line, with the path toserver/out/server.js). The price measured PER abort: 58 s of CPU, 2.6 GB of peak RAM and 2.7 GB written to the NVMe just to record the dump, on a 15 GB machine and on a disk whose wear we track. The 09/08 cadence: 10:38, 15:01, 15:43, 15:57, 16:49, 16:55, 17:07. • DO NOT CONFUSE this with the 90 s "stop job" in the entry below, even though both say "VS Code": there it is theapp-code-*.scopeignoring SIGTERM at SHUTDOWN; here it is a child process aborting IN THE MIDDLE OF THE SESSION. Independent causes, independent fixes, and the real risk was the first finding "explaining" the second and the investigation stopping there. • DISPROVED BY MEASUREMENT, which is what prevents useless work: (a) "15 GB of RAM is not much", when memory PSI is ~0, the on-disk swapfile has 0 B used and zram compresses 2.7 G into 981 M; what freezes is colorize, not the RAM. (b) "the NVMe is hot", when Composite is 52.9 °C under load (a game + 7 containers + ollama), against the 77 to 80 °C that generated the heatsink open item; theSensor 2at 79.8 °C is still the known false alarm (an unimplemented sensor, pinned). • The rest is green and checked: 0 failed units,is-system-running: running, btrfsno errors found, 58% disk, fwupd with no pending update,nix flake checkpassing. Secure Boot and VT-x are still off, the same BIOS trip, already tracked. -
[x] Shutdown from 90 s to ~5 s: the "stop job" was ONE app, not the system (09/08/2026). The complaint was "it takes almost 5 min to shut down". The journal disproved the number and handed over the culprit: 90.3 / 90.4 / 90.5 / 90.6 s across the last 10 boots. A round number like that is not work, it is a TIMEOUT, systemd's default
DefaultTimeoutStopSecof 90 s, hitting in full, every boot. • A SINGLE culprit and always the same one:app-code-*.scope: Stopping timed out. Killing.. VS Code runs in a USER SESSION scope (GLib createsapp-<name>-<pid>.scopewhen launching the.desktop) and it does not answer SIGTERM. Searching the pattern across the whole journal: 8 occurrences of VS Code and 6 of Chromium before it, so it is Electron behavior, not this machine's. Everything ELSE (docker, jellyfin, network, unmounts, swap) stops in under 2 s, which closes the arithmetic: 90 s of shutdown = 88 s of waiting. • WHERE THIS CHANGES THE DIAGNOSIS: the instinct was to touch the SYSTEM services. There was nothing to gain there. The adjustment that solves it is on the USER side, 5 s insystemd.user, where the tenant is a desktop app and whoever was going to save on SIGTERM already saved in under 1 s. • THE SYSTEM stayed at 30 s and did not go to 5 s along with it:duoandgrad-radarhavedocker compose downin their ExecStop, anddowngives EACH container 10 s of grace. Tightening too much here would SIGKILL those stacks' Postgres in the middle of the down, which does not corrupt anything but comes back doing WAL recovery, and the price shows up far from the cause. • WHAT THE CHANGE DOES NOT DO, because the easy reading is "now it kills the apps": the SIGKILL already happened, 90 s later. Nothing that died a natural death started being killed; we only stopped paying the wait for someone who was never going to answer. • THE TRAP RULE 8 CAUGHT, and it fails SILENTLY:systemd.extraConfigWAS REMOVED (26.05 wantssystemd.settings.Manager). Writing to the removed option, thenix evalof the generatedsystem.confPASSES and comes out without the line, with zero errors and zero warnings, and the shutdown would still take 90 s with the config "applied". It only showed up because the validation was READING the generated file, not asking whether it built. And the asymmetry is the non-obvious part: on the user sidesystemd.user.extraConfigis still the ONLY form, sincesystemd.user.settingsdoes not exist (checked in theoptions, not deduced from the system side). • NEXT TIME the shutdown drags, the global ceiling will not be the answer: a unit with its ownTimeoutStopSecignores the default (today qbittorrent has 30 min, jellyfin 15 s, caddy 5 s,user@.service2 min from upstream). Look there first withsystemctl show <unit> -p TimeoutStopUSec. -
[x] The bar clock shows the date AND the time, and the calendar rolls the year over by itself, measured (08/08/2026). The clock used to be a TOGGLE: a click alternated between
HH:mm:ssand dd/MM/yyyy, never both. Seeing the date cost two clicks (there and back), which is expensive for the most consulted piece of data on the bar. Now they come out together in the same pill. • THE FORM: the time first in mauve, the date after it inTheme.colDim. That is HIERARCHY, not separation: the time sits on the left edge, which is where the eye enters the pill, and the date follows without competing. It was born as a newsubinwidgets/Pill.qml(secondary text in the same pill), reusable for any pair that travels together. • TWO DECISIONS: no YEAR (redundant, since the calendar popover has it, one hover away), and the weekday from the file's owndowAbbrinstead of Qt's"ddd". Qt's format depends on the PROCESS locale, so if the bar comes up withoutLC_TIME, autologin for instance, "sáb" silently becomes "Sat". The local table does not have that risk. • RULE 8 CANNOT BE SATISFIED HERE, and that is worth knowing: the Quickshell tree ismkOutOfStoreSymlink, so it does NOT go through/nix/storeandnixos-rebuild builddoes not exercise these files, which means there is no build to validate. The substitute I used:qmllint(syntax) + headlessqml(behavior) + a screenshot of the real result. A QML error here only shows up with the bar already running. • THE QUESTION THAT WAS WORTH THE NIGHT: "and when it is 2027, does the calendar update on its own?". Yes:SystemClockticks every second,updateClock()comparesyyyy-MM-ddagainstcalDayKeyand callsrefreshCalendar()on the first tick after midnight. I did not take that on faith: I simulated the 31/12/2026 to 01/01/2027 rollover with the REAL functions extracted fromBar.qml(not a rewrite), and the header becomes 2027, "today" jumps to 01/01, and Carnival is painted on 08-09/02. Easter was checked through 2032, including the Carnival of 29/02/2028, a leap day. The algorithm has a built-in self test: if it slipped in some year, the WEEKDAY would give it away (Easter is always a Sunday, Corpus Christi always a Thursday). • THE MOST VALUABLE FINDING, and it was the link I had only REASONED about: the popover readscalMapthrough a binding (Repeater { model: bar.monthCells(...) }), and a QML binding only reevaluates when the PROPERTY is reassigned. Measured in headlessqml(6/6): reassigning propagates, MUTATING the object from the inside (calMap[k] = v) emits no signal at all. Which means the calendar only rolls the year over becauserefreshCalendardoesroot.calMap = root.buildCalMap(...). Whoever "optimizes" that into writing into the existing object FREEZES the calendar silently: nothing breaks, nothing logs, it just stops rolling the year. Noted in the code, next to the function. • HOLIDAYS REVERIFIED (national + SP + São Carlos) and the result was: NOTHING CHANGED. The list was already correct and complete for 2027, with no new holiday since Law 14.759/2023 (Consciência Negra, national since 2024 and no longer only state level). An independent confirmation: the non-optional ones on the list add up to 14, which is the number the city hall and the local press publish for São Carlos. • WHAT CHANGED ABOUT THE HOLIDAYS was the DOCUMENTATION, which was worse than the code: the header said "see the workflow notes", a pointer outside the repo, which is to say to nowhere. The laws went into the file (662/1949, 6.802/1980, 9.093/1995, 14.759/2023, the state 9.497/1997 and the MUNICIPAL 7.502/1974 for Corpus Christi). • THE TWO TRAPS that calendar sites fall into and this list does not, and the first search I ran got both wrong: (1) CARNIVAL and Ash Wednesday are neither a national nor a municipal holiday in São Carlos, they are optional days off; (2) CORPUS CHRISTI is FEDERALLY optional but a MUNICIPAL holiday here, through the law above, and in another city it would befac. • WHAT DOES NOT UPDATE ITSELF, and it is the only part of the calendar like that: theholidayDefslist. The MOVABLE ones derive from Easter and scale forever; the FIXED ones are LAW written by hand. A new law, or the municipality changing a holiday, leaves the grid wrong silently. Review it when news of a new holiday appears, not at the turn of the year, because nothing there depends on the year. -
[x] DDC/CI backlight REVERTED: worse dimming on both screens beats great dimming on one (08/08/2026).
ddcutilworked, the curve worked, and it went out anyway. The reason is not technical, it is ERGONOMIC, and it is worth more recorded than the code. • WHAT WORKED:hardware.i2c.enableplus ddcutil gave REAL backlight control on DP-2 (LG ULTRAGEAR, MCCS VCP 2.1). The measurement that motivated everything is still valid: the monitor was at 100% at 20:00 in a dark room, and THAT was the cause of the burning eyes, not the blue filter. The hourly curve applied 40% and the monitor obeyed. • WHAT KILLED IT: the LG TV on HDMI has no way to follow along. It does not speak DDC/CI (x37 unresponsive), it is NOT ON THE NETWORK (checked in the router's DHCP: the only unnamed device is Amazon, not LG, so not even webOS), and HDMI-CEC does not cover brightness, which is a spec matter. Three paths, three closed. • THE REASONING BEHIND THE REVERSION, which is the point: one screen at 32% next to another at 100% forces the pupil to readapt every time the gaze switches, and that tires the eyes MORE than the gain on the good screen. Worse dimming on both beats great dimming on one, when both are in the field of view. The hyprsunset gamma is technically worse (it darkens the SIGNAL, with the backlight at full) but it reaches BOTH, and uniformity won. • THE ALTERNATIVE that still holds, if the discomfort ever comes back: adjust the TV's backlight ONCE with its own remote (it is device config, it persists, and it falls into the same category as docs/guides/bios-*.md) and resume DDC on the monitor. It was REFUSED for not being automatic, not for not working. • WHAT STAYED from the experiment:wayland-utils(wayland-info), which came in along with it and is useful on its own; and the correction to the hyprsunset.nix header about the shader and about the 13 profiles. • THE MONITOR WENT BACK TO 100% ON ITS OWN, and I had written the opposite: I actually warned that "it stayed at 40% and persists in hardware". FALSE, becausesetvcpwithout--savedoes not write to the EEPROM, so the value is lost on the first screen power-off. The reversion ended up complete, with no orphan state. It counts as an inverted trap: whoever automates DDC needs to know that the change is VOLATILE by default, which is actually good (a service that reapplies at login is enough) but surprises anyone expectingsetvcpto stick. -
[x] ~~Gamma leaves the hyprsunset curve~~ REVERTED along with the DDC (08/08/2026): a side effect of DDC/CI coming in was that
hyprsunsetdoes NOT know how to target a specific output. I searched foroutput/monitor/displayin the source: ZERO occurrences. It applies the CTM to ALL outputs at once. • THE PROBLEM THAT CREATED: with DP-2's real backlight coming from DDC/CI, keeping the gamma auto-dim would give DOUBLE dimming on the good monitor (backlight 32% times gamma 0.9 at 22:00) in order to deliver weak relief on the TV. It was a regression I introduced by adding brightness.nix, and it only showed up when asking "and the TV, how would that work?". • THE SPLIT THAT REMAINED: DP-2 (LG ULTRAGEAR) gets real backlight through DDC/CI; HDMI-A-3 (LG TV) gets its backlight adjusted with ITS OWN remote, since it does not speak DDC/CI. A TV is an appliance, not a computer: picture config persists there and does not need the repo. •max-gamma = 150STAYED: the SHIFT+VolUp/Down keybinds still adjust gamma through IPC, now as MANUAL fine tuning instead of an automatic curve. • HDMI-CEC was discarded without testing, and the reason is a spec matter: the protocol covers power, volume and input switching, and brightness is not in the standard. What is left is network control (webOS, a 2017 TV), which stays open: I do not know its IP, and the192.168.1.20from theTV-Samsung-Salalease does not answer (and it is not even certain that is this TV). -
[x] ~~REAL monitor brightness: DDC/CI + an hourly curve~~ REVERTED (08/08/2026):
ddcutil getvcp 10returned 100 at 20:00, in a dark room. The monitor spent the whole day at full, and NO Kelvin curve solves that. It was the cause of the burning eyes. • THE INVERSION THAT MOTIVATED IT: the ergonomics literature puts REDUCING BRIGHTNESS above color temperature, and "night mode does not replace adequate brightness". I expected the opposite, and the config expected it too:hyprsunsetcovered color with 13 carefully tuned profiles and luminance with nothing. • The hyprsunsetgammawas NOT BRIGHTNESS: it darkens the SIGNAL sent to the panel while the backlight stays at full. The light reaching the eye does not change. The july history itself already recorded "no real backlight", so the gap was written down and nobody had connected the dots. •hardware.i2c.enable(system/hardware/ddc.nix) loadsi2c-dev, which was not loaded because NOTHING ASKED FOR IT. Without/dev/i2c-*ddcutil has nothing to speak through. • ONLY DP-2 ANSWERS: the LG ULTRAGEAR speaks MCCS (VCP 2.1); the LG TV on HDMI returns "does not support DDC/CI, I2C slave address x37 is unresponsive". And I WAS WRONG in predicting that: I said HDMI "exposed ZERO i2c buses", but ddcutil found its/dev/i2c-7. My test looked at per-connector symlinks in sysfs, which is a different thing. The conclusion was right for the wrong reason: the TV HAS a bus, it is the one that does not speak the protocol. •home/desktop/brightness.nix: an hourly curve mirroring the hyprsunset structure (90% during the day, 55% at 18:00, 40% at 20:00, 28% overnight). A 5 min --user timer, and it writes ONLY when the target changes, so a manual adjustment holds until the next step, the same contract as hyprsunset, and it does not keep writing DDC for nothing (it is slow and it makes the monitor blink).--modeland not--display N: the number changes if another DDC monitor comes in. • REFUSED: theddcci-driver(it exists in nixpkgs, exposes the monitor as a standard backlight and would letbrightnessctlwork). It is an out-of-tree kernel module and it breaks on every bump. For infrastructure that has to last, calling ddcutil from a timer is less elegant and far less fragile. • The curve values are a STARTING POINT, not truth. The criterion from the literature is comparing against a sheet of white paper next to the screen. -
[x] LocalSend declarative, open ONLY to the LAN (08/08/2026): an open source "AirDrop" (MIT, 1.17.0) for moving files between the phone and the PC with no cloud and no account. Three pieces:
programs.localsendin system/net/localsend.nix, port 53317 allowed by SOURCE, and an entry in themy.autostartpanel. • WHY INsystem/and not inhome/, against rule 4: what unites the PACKAGE and the PORT is the nixpkgs module, and a firewall is system level. The same case asprograms.steam. The package is NOT repeated in home/packages.nix: the autostart readsosConfig.programs.localsend.package, and the store path ofsw/bin/localsend_appand of the unit'sExecStartMATCH (the same5vzlv6k…), which is the proof that nothing was duplicated. As a bonus, that kills the spotify trap by construction: switching tounstablein system/ takes the autostart along, instead of requiring me to remember to keep two files in sync. •openFirewall = falseAGAINST the module's default, and the threat is not the internet: the router only forwards 80/443/2222, so 53317 was never exposed to the world. Who would reach it is the VPN, becauseopenFirewallopens the port on EVERY interface, and with the FAI tunnel up (ppp0) the whole corporate network would start seeing the service and reading/api/localsend/v2/info(device name, model, fingerprint) with no authentication at all. It becomes trust by SOURCE, the same as Sunshine's: onlymy.net.lanSubnet. • The WireGuard peers get in FOR FREE, with no rule of their own: they arrive with source 10.10.10.x and the net/network.nix rule already accepts that range before any decision. The fifth consumer of the SSOT, validated by the sentinel ritual (rule 11): swapping the range for 172.31.99.0/24 changed both new rules along with fail2ban'signoreip, and reverting gave back an IDENTICAL store path (i0kvjns…). • UDP is mandatory alongside TCP, and it is not a detail: TCP is the transfer and the/info; UDP 53317 is the multicast announcement on 224.0.0.167, which is what makes the devices DISCOVER each other. Without it the app opens and works, but only through "add by IP" by hand, a failure that looks like "my phone cannot find me", not "the port is closed". • The port is REPEATED in my module because the nixpkgs one does not expose it as an option (it is afirewallPort = 53317internal to their file). Changing the port INSIDE the app (Settings, Network) makes reception die SILENTLY: no build error, no log. • AUTOSTART with--hidden(a flag confirmed in thelibapp.sostrings, not guessed): it comes up with no window, only the SNI icon in the quickshell tray. Without it the app receives nothing, because LocalSend only listens while it is open, and sending from the phone would require walking to the PC to open it. An explicit price: the tray becomes the ONLY way to bring the window back. Do NOT turn on "Autostart after login" IN THE APP'S SETTINGS: that writes a.desktopinto~/.config/autostartand would become a SECOND owner of the same automation (rule 15), with two instances fighting over 53317. • A CORRECTION TO A WRONG COMMENT that this work uncovered, in net/network.nix: I had written that thenixos-fwchain "ends in a refuse, so-Ais never reached". FALSE forextraCommands: read in the GENERATEDfirewall-start, it is injected BEFORE-A nixos-fw -j nixos-fw-log-refuse(nixpkgs 26.05, line 235 vs 238), so-Awould work. The sentence only holds for a rule typed BY HAND into a firewall that is already up. The-I 1is still right, but for ANOTHER reason: it is what reproduces the semantics oftrustedInterfaces. The same class as "~/.profileis not read on OpenWrt", an old note I was about to cite as if it were a measured fact. • The nickname, the destination folder and "save without confirming" stay in the app (rules 6 and 14: it rewrites its ownshared_preferencesat runtime, so Nix does not own it). -
[x]
my.ingress: exposure becomes a TOGGLE (08/08/2026). The Caddyfile stopped being written by hand. Each service declares itself inmy.ingress(the schema in system/net/ingress.nix, the panel in hosts/nixos-kingston/services.nix) and the vhosts are GENERATED. Switching reach means changing one word:expose = "lan"becomes"public". • WHAT THIS FIXES: before, the reach was implicit and asymmetric.duo/aihad a hand-writtenrespond @externo 403,jellyfin/torrentdid NOT, and the decision to expose only existed as an ABSENCE in the middle of 60 lines. Encoding security by omission is the worst case: forgetting to write it became "exposed", silently. Now theexposedefault islan, so forgetting CLOSES. • The TAILNET in the home matcher (100.64.0.0/10): it is what gives REAL remote access today, since CGNAT prevents direct inbound. A tailnet peer is as much "home" as the LAN. It went along into fail2ban'signoreip, because getting the password wrong on the phone cannot ban yourself. That range is the same one carrier CGNAT uses; today it is harmless because nothing from the internet reaches the process, but on the day of the tunnel it requires distinguishing the path, not trusting the IP. •remote_ipbecameclient_ipBEFORE any tunnel exists: today they are identical (with no trusted proxy, the client is the connection). It cost nothing and it disarms the trap of cloudflared delivering over loopback and all the tunnel traffic becoming "home", bypassing basic_auth silently. Do NOT addtrusted_proxieswhile there is no tunnel: without it the X-Forwarded-For is ignored (which is correct); with it, any local process forges the source IP. • The fail2ban failregex became DERIVED from whoever hasauth, instead of the literalpos\.: a new service with basic_auth enters the jail by itself. • A NIX TRAP that bit on the first generated file: the target is the literal{$VAR}(a Caddy env var), and"{$${v}}"in a Nix string is a SYNTAX ERROR, not an escape, so the generated file came out with a raw{$${v}}, which would become an EMPTY hash at runtime. The unambiguous form is concatenating:"{$" + v + "}". It was caught by eye, in the generated Caddyfile; there would have been no build error. • VALIDATED:caddy validatewith the custom binary and real bcrypt hashes returnedValid configuration; the generated file is semantically identical to what was there by hand; and the sentinel ritual (rule 11) held, since flippingjellyfintolanmade the 403 appear and reverting gave the store path back byte for byte. -
[x]
router-sync: the router config comes out of the blind spot (08/08/2026). The ~750 lines of UCI from the Cudy WR3000 now live inrouter/uci/*.conf, versioned, with the secrets redacted. It is NOT a declarative router: it is a VISIBLE one with DETECTABLE drift, which was the real complaint.router-sync pullmirrors,router-sync diffcompares and exits 1 if they diverge (tested in all three states: in sync 0, diverged 1, reverted 0). • WHY IT DOES NOT PUSH CONFIG, and why this half came first: writing UCI over SSH requires commit-confirm (apply, schedule a rollback, confirm if there is still access). Without that, one wrong network or firewall line locks you out and the way back is failsafe mode with PHYSICAL access. The read half delivers nearly all the value with none of the risk, and the export becomes input for ANY push tool later. • FAIL-SAFE REDACTION, and the direction is the point: it redacts by DEFAULT anything whose name suggests a credential, and only lets through what it recognizes,public_key(public by definition) and a value starting with/(that is a PATH, not a secret). A blocklist would do the opposite and leak silently the day a new package brought a new option. Validated: the 7 secrets came out redacted, and the 2 predicted false positives (luci.flash_keep.passwd='/etc/passwd',uhttpd.main.key='/etc/uhttpd.key') stayed intact through the/rule. An extra sweep for high-entropy strings only found the peers' 3public_keyvalues, a DHCPv6 DUID and a path. •uci showand notuci export: one line per option makes the git diff point at the LINE that changed instead of the whole block. •__file__does NOT find the repo root: the script is copied into /nix/store, so a path relative to it points inside the store (read-only). It bit on the first run. The right idiom is the one insync-secrets.sh:git rev-parse --show-toplevel. • WHATsysupgradeALREADY PRESERVES: 38 entries in keep.d, and I got this wrong TWICE by reading the list truncated at 12 lines and concluding from what I did not see. It preserves ALL of/etc/config/,/etc/profile.d/,/etc/dropbear/, passwd/shadow/group AND ALSO/etc/sudoers.d/, which I had been saying was lost. The real gap is only/home/. • A TRAP that only showed up when reading/sbin/sysupgrade:/etc/sysupgrade.confitself is NOT in keep.d.list_static_conffilesreads the paths LISTED INSIDE it but does not include it, so the 1st upgrade preserves what you asked for and the 2nd loses everything, because the file doing the asking disappeared in the first one. The fix: list/etc/sysupgrade.confinside itself. • BEST PRACTICES researched, for when the push decision comes: an image (nix-openwrt-imagebuilder + /etc/uci-defaults) and a push (nuci/Dewclaw/your own) are COMPLEMENTARY, not alternatives, because the image is the disaster artifact and the push is the daily cycle.nuci(github.com/lonerOrz/openwrt-nix) is the best designed one (it validates first, an anti-brick watchdog with a boot hook, sops,nuci diff) and it is ALIVE (a push on 30/07/2026), but it has 1 contributor and recent commits still refactoring the CLI, which is API churn in the tool that controls the network. Dewclaw is older and its author declared that they do not support it. -
[x]
my.net.{lan,vpn}Subnet: the home ranges become an SSOT (08/08/2026), the last loose end of the Tailscale removal. "From home" is a SECURITY decision (it separates who gets in directly from who needs a password) and it was spelled out in FOUR places: Caddy's@externomatcher, the caddy-pos jail'signoreip, sshd'signoreIPand the firewall rule that replacedtrustedInterfaces. Diverging there raises no build error, it just starts treating as a stranger somebody who should get in, or the other way around. • TWO options and not a single list: the firewall that keeps Sunshine reachable trusts ONLY the WireGuard one. Sunshine is closed on the LAN by decision, and merging the ranges would open it to the whole home network without anybody noticing. • Validated by the sentinel ritual: swappingvpnSubnetfor 172.31.99.0/24 changed all FOUR consumers together; reverting gave the store path back. • A BONUS FINDING, two dead configs the sentinel exposed: fail2ban's[DEFAULT]came out with127.0.0.1/8 ::1DUPLICATED (the nixpkgs module already prepends both, so declaring them again repeated them), and the caddy-pos jail'signoreipbecame identical to the DEFAULT once both started reading the SSOT. A jail with noignoreipinherits the default: one fewer copy to diverge. • These values MIRROR the router, which is what really defines them (it serves the LAN DHCP and it is the WireGuard server). Nix does not reach in there, so changing the range on OpenWrt and forgetting it here leaves the repo lying silently. -
[x] Tailscale REMOVED, WireGuard only (08/08/2026): remote access becomes the WireGuard the ROUTER already served, and the third party mesh leaves the repo entirely. • WHY, and the premise I had wrong: the Tailscale client ALREADY is FOSS (BSD-3); what is proprietary is the CONTROL plane. So the swap does not remove closed software from the machine, it removes the dependency on a third party coordinator that knows which devices exist and can turn the network off. Headscale would solve that too, but it costs a critical service to maintain; plain WireGuard costs zero, because the server was already on the router (
wg0, 10.10.10.0/24) and Caddy already trusted that range. • WHAT KNOCKED DOWN THE ONLY ARGUMENT AGAINST IT: the fear was a corporate network blocking UDP and forcing the DERP relay, which is exactly the use case (Moonlight from work). MEASURED: 3 UDP 51820 packets fired from FAI, and the nftablesAllow-WireGuardcounter on the router went from 0 to 3. It gets through. And with no relay the video always goes direct, which for streaming is pure gain, since the path is even shorter, given that FAI and home are both in São Carlos. • THE TRAP THAT ALMOST SLIPPED THROUGH:tailscale.nixcarriedtrustedInterfaces = [ "tailscale0" ], and Sunshine runs withopenFirewall = false. Deleting the module without replacing that would have left Sunshine UNREACHABLE from everywhere, silently. The replacement is in net/network.nix and it is by SOURCE, not by interface: the WireGuard server is the ROUTER, so there is no localwg0to trust, and the peer arrives over the LAN with source 10.10.10.x. The rule is inserted with-I nixos-fw 1: the chain ends in a refuse, so-Awould never be reached. • WHAT DIED ALONG WITH IT, and this is the point of "zero legacy": thesunshine-path-probesubsystem (~82 lines plus 2 systemd units) and themoonlight-statssections that cross-referenced the tailscaled journal. All of that existed to answer "was this session direct or did it fall into DERP?", and with WireGuard there is no relay, so the question LOST ITS OBJECT. It was not code that broke; it was code that stopped making sense. The report kept what is still true: the session durations and the short/long split. • Also gone: theafter = tailscaled.serviceof cloudflare-dyndns. That DNS race existed because resolv.conf pointed at the 100.100.100.100 served by tailscaled itself. The RETRY stayed, since the other cause (DHCP taking ~6.5s after network-online) is independent and still holds. • FUNCTIONAL ADJUSTMENTS in sunshine.nix:csrf_allowed_originspointed at the tailnet IP and the MagicDNS name, both dead, so it becamehttps://192.168.1.10:47990, which is where the peer arrives. It is still a SNAPSHOT (an IP cannot be derived at build time), so it is worth guaranteeing a static lease on the router.packet_size = 1024STAYED: it was calibrated for tailscale0's MTU 1280 and there is room to spare in WireGuard's ~1420 MTU, but it is a proven value and raising it would be optimizing without measuring, risking reintroducing the SILENT drop that cost the 29/07 debug. • WHAT IS LOST, and it is real: Tailscale re-resolved the endpoint by itself when the home IP changed. WireGuard stores the resolved endpoint and does NOT re-resolve, so if the IP changes while you are away, the session dies and the client does not come back on its own. There is a fix (a timer that re-resolves and reapplies), but it is work, not third party magic. •tailscale_authkeyleft the Bitwarden index. The ENCRYPTED value is still in secrets.yaml until somebody removes it by hand, which is harmless but is residue. -
[x] OpenWrt router: access, cleanup and
owfetch(08/08/2026). The Cudy WR3000 became administrable over SSH with no password, and it gained a system summary. NONE of this is declarative, and the record exists for that reason: OpenWrt is not NixOS, so everything below is a MANUAL step that disappears in a clean reflash (it survives asysupgradewith keep settings). • The CLIENT is declarative, the rest is not:home/shell/ssh.nixgained therouterhost. WithoutfaiResilience, because that dimensions keepalive and multiplexing for the SonicWall tunnel; on a <1ms LAN hop it would be cargo cult. • THE FIVE MANUAL STEPS, in order: (1)ssh-copy-idfor the key; (2)@includedir /etc/sudoers.din/etc/sudoers, which was NOT there, so a drop-in in there was ignored SILENTLY; (3) the restricted NOPASSWD rule; (4) the script in~/bin/owfetch; (5) the call in/etc/profile.d/99-owfetch.sh. Adding/etc/sudoers.d/to/etc/sysupgrade.confmakes step 3 persist. • There is NOsuin BusyBox andsudo cmd > filedoes NOT work (the>belongs to the unprivileged shell, before sudo). The pattern is| sudo tee. And editing sudoers means copy, edit the COPY,visudo -c -f, and only then install: withsudobroken and nosu, the way out would be failsafe with physical access. • SUDO SCOPE: it started asALLand was NARROWED to/sbin/reboot, /usr/sbin/nft, /sbin/uci, /etc/init.d/dnsmasq. The reason is thatNOPASSWD: ALLplus an SSH key makes the KEY equivalent to root, and root login over SSH was already disabled in dropbear (the-w -gflags), so the broad version undid the protection that already existed. • LOCAL DNS: 13 entries became 1. Only/v1cferr.dev/192.168.1.10did any work, since in dnsmasq that covers the domain AND every subdomain. The 12 specific ones were redundant, several pointing at services dead since Arch (bazarr/prowlarr/radarr/sonarr/jellyseerr/spendflow/chat/dash/files), and none coveredposorduo. A backup is in/etc/config/dhcp.bak-limpeza. An accepted effect: the wildcard also catches the APEX, sohttps://v1cferr.devfrom inside the house gives a TLS error (the wildcard cert does not cover the bare domain). From outside it goes to Vercel normally. •scripts/owfetch.sh: a fetch in pure ash, ~4 KB, with ZERO dependencies. It is not fastfetch because /overlay has 1.4 MB free out of 6.1 MB, and fastfetch weighs 1 to 2 MB while neofetch would drag bash in; either one fills the flash, and a router with full flash does not even save config. The field order mirrorshome/shell/fastfetch.nixon purpose. • AUTOSTART in/etc/profile.d/99-owfetch.sh(/etc/profilescans that directory). Mandatory guards:[ -t 1 ](otherwise it pollutesscpand commands over SSH) and-x(otherwise it errors if the script disappears in a reflash).$HOMEand not a fixed path: the directory is read by EVERY user who logs in, so whoever does not have the script simply sees nothing. • METHOD, and the lesson is worth more than the result: I actually WROTE DOWN HERE that "~/.profileis not read on OpenWrt". FALSE, it is read, and the real login showed the fetch running TWICE with just that. What fooled me was the test:echo exit | ssh -ttis NOT an interactive session (ash looks atisatty(0), and stdin came from a pipe), so it returns nothing both for "not configured" and for "configured and working". A method that does not distinguish the two hypotheses is not a test, and it was the SAME class of error as the CGNAT one: a blind instrument read as evidence. • printf pads by BYTE: the "Memória" label misaligned becauseóis 2 bytes in UTF-8. It became "RAM". And ARM does not exposemodel namein /proc/cpuinfo, so the CPU name comes fromDISTRIB_ARCH. • Theshellcheckhook enteredflake.nixbecause of this script:scripts/sync-secrets.shalready got verification for free throughwriteShellApplication, butowfetch.shruns in ash ON THE ROUTER and no derivation wraps it, so it would be the only.shin the repo executing on somebody else's machine WITHOUT verification. Turning the hook on required# shellcheck shell=bashin.envrc, which has no shebang (SC2148). -
[x] ~~CGNAT~~ FALSE ALARM: external access ALWAYS worked (08/08/2026). This entry was born WRONG on 07/08 and it stays rewritten instead of deleted, because the value is in the method that failed. Yesterday's conclusion ("there is no inbound route, it is CGNAT") was false on all THREE legs: • There is NO CGNAT. The router has the public IP directly on
pppoe-wan. The172.31.43.240I read as "the ISP's NAT" in the traceroute is the PEER of the PPPoE link. RFC 1918 hops in a traceroute do not prove CGNAT, because an ISP uses private space in transport, and that looks the same in both cases. It was inference, sold as proof. • The ISP does NOT block. Proven by the nftables counter (nft list chain inet fw4 dstnat_wan): 3 hits from outside gave counter +3. The packets always arrived. • External access WORKS. Proven by Cloudflare's edge: a temporaryproxiedrecord, and CF connected to Caddy and got the404 Subdomínio não configuradoin 0.39s, with the router counter going up along with it. Case closed. • WHAT BLOCKS IS THE FAI NETWORK. It was my only external vantage point, and it is not neutral: the SYN leaves there and arrives here, the router's conntrack showsSYN_RECV(which means Caddy DOES answer the SYN-ACK), and the final ACK never comes back. The FAI firewall drops the SYN-ACK. • METHOD, three ways for the test to LIE, all of them committed here: 1. FROM INSIDE THE LAN: the router does hairpin and the port "opens". A false positive. 2. THROUGH THE FAI VPN with the tunnel ACTIVE on the target:ip route get 200.136.209.229goes out throughppp0with source192.168.50.1, so the answer comes back by another path and the client discards it. A false NEGATIVE, and the most treacherous one, because it looks like a legitimate external test. 3. A SINGLE EXTERNAL VANTAGE POINT: if it is the one blocking, everything looks broken. A corporate network cannot judge its own case, which demands a SECOND independent point. • THE TOOL THAT SOLVED IT, keep it for next time: create aproxiedrecord on Cloudflare and hit it. CF becomes a NEUTRAL external vantage point that is already yours, with no third party and no VPN. The catch-all404is the perfect signal, since you do not even need a service up. Delete it afterwards. • CONSEQUENCES:expose = "public"WORKS today. There is no need for cloudflared, no need to call the ISP, and thetrusted_proxiestrap becomes theoretical again (but theclient_ipstays, because it is still correct). • AND THE REAL RISK INVERTS:jellyfinandtorrentare exposed to the internet NOW, with the app's own login as the only barrier. Yesterday that was theoretical ("nothing reaches it"); today it is a fact. The wildcard resolves here, 443 is forwarded and Caddy serves both with no gate. Decide consciously: keep it, or switch toexpose = "lan"in the panel. -
[x] Cloudflare in Claude Code: CLI + MCP (07/08/2026), to close Caddy's "pending discovery" (the wildcard answering with a private IP) without me reading the zone in the dashboard by hand. Two pieces:
.mcp.jsonat the root and.claude/settings.json. •wranglerCAME IN AND WENT OUT the same day, and the reason is worth recording because I was about to repeat it: I assumed the "official Cloudflare CLI" would serve for DNS. IT DOES NOT. The entire help is Workers/Pages/KV/R2/AI/Queues, and there is nowrangler dnsand nothing about zones. And the price was 2.2 GiB of closure, FOUR copies of nodejs-24 (slim, -npm, -corepack and the full one): on its own, +1.91 GiB of the +2.10 GiB of that switch. It contradicted the very criterion I used two lines below to refuse the Workers skills. What does DNS is the MCP. • THE SCOPE IS PROJECT, and NOT global, against what I had asked for, because global and declarative are MUTUALLY EXCLUSIVE here, and the why is worth recording. The three MCP scopes in Claude Code:localanduser(=global) live in~/.claude.json; onlyprojectlives in.mcp.json, versioned. And~/.claude.jsonis the file that holdsnumStartups,tipsHistory,projectsand a feature cache, which the app REWRITES at runtime, so by rule 14 Nix cannot own it. Global = imperative. End of story. • THE ESCAPE HATCH EXISTS AND WAS REFUSED:/etc/claude-code/managed-mcp.jsonwould be declarative (environment.etc) AND global, the only way to have both. But it takes EXCLUSIVE control: "Claude Code loads only the servers that file defines. Users cannot add, modify, or use any other MCP servers, including plugin-provided servers." That would KILL github, atlassian and the claude.ai connectors. Trading 4 working servers for 1 is a terrible deal, and project scope solves 100% of the use case, since the DNS/Caddy work is IN THIS repo. • The official plugin (cloudflare/skills, which the docs tell you to install) was OPENED and REFUSED: it brings 5 MCP servers and 13 skills, and 11 of them are Workers/Durable Objects/Pages/Turnstile, a dev platform I do not use. What was left is what serves:cloudflare-api(DNS, zones, tokens) andcloudflare-docs. The 3 discarded ones werebindings,buildsandobservability. The marketplace was removed after being inspected, it was not left orphaned. •enabledMcpjsonServersin.claude/settings.jsonPRE-APPROVES both: without it Claude asks on every session. It only holds in a trusted workspace, since a cloned repo does not self-approve. • Auth is OAuth in the browser, on the FIRST tool call, so there is no token in the repo (rule 12). It is a credential SEPARATE from thecloudflare-dyndnssops token; do not confuse the two. •.mcp.jsonis strict JSON, with no comments, which is why the "why" is here and not there. -
[x] Caddy is back, declarative (07/08/2026): "Phase 4, Homelab" started. The Arch ingress (
caddy/etc/caddy/Caddyfileon themain/archbranch) had been left behind in the migration.services.caddynow serves*.v1cferr.devwith a WILDCARD LE cert through Cloudflare DNS-01, andmy.net.domain(system/net/domain.nix) became the domain's SSOT: before, the literal existed in a single place (the DDNS), which did not justify an option; with the proxy the consumers became four and triggered rule 11. • The reason for the biggest hack of the old setup DIED: building with xcaddy and hiding the binary in/usr/local/binexisted because "apacman -Syuonce overwrote the custom binary and took the whole proxy down". On Nix it iscaddy.withPluginswith the vendor pinned by hash, so the package IS the declaration. •propagation_timeout -1was PRESERVED literally. It is not a preference: certmagic's local propagation check fails on this host, and without turning it off the issuance hangs. • The repo's FIRSTnetworking.firewall.allowedTCPPorts(80/443). Everything else uses the upstream module'sopenFirewall, butservices.caddydoes not have one. The router had been forwarding 80/443/2222 since the Arch days; what blocked was the NixOS firewall. • AUTO-GATE on the 4 secrets (the duo.nix pattern), because an empty{$VAR}becomes an empty basic_auth hash and Caddy would refuse the ENTIRE config, and inert is better than taking the proxy down on the switch. • Validated WITHOUT a switch: the rule 11 sentinel ritual (the DDNS + the vhost + 5 matchers + the failregex all changed together) andcaddy validateon the generated Caddyfile, with the custom binary, returningValid configuration. • ~~Pending discovery: the wildcard answers with a private IP~~: SOLVED on 07/08/2026, and the diagnosis was WRONG. There was no wildcard at all on Cloudflare: the zone had 10 records and no*. What answered192.168.1.10was the ROUTER (192.168.1.1), which has a local wildcard*.v1cferr.devpointing to 192.168.1.10 configured on it, outside Nix, as split-horizon so the LAN does not hairpin through the WAN. The mistake was MEASURING FROM INSIDE THE NETWORK:dig @1.1.1.1also returned the private address because the router hijacks port 53. What broke the tie was DNS-over-HTTPS (port 443, unhijackable): from outside,sshalways resolved correctly to the public IP, and a random name gave NXDOMAIN.cloudflare-dyndnswas never broken. LESSON: to validate public DNS from inside your own LAN, use DoH, becausediglies when there is interception. • WILDCARD ADDED (07/08/2026):*CNAME tossh.v1cferr.dev, DNS-only. A CNAME and not an A on purpose:cloudflare-dyndnsonly managesssh.${domain}(network.nix:67), so the wildcard INHERITS the dynamic IP for free, while an A would be frozen at the first IP. That matches the design the Caddyfile already had: a wildcard cert plus the catch-allrespond "Subdomínio não configurado" 404. Before that, NONE of the five vhosts (pos/duo/ai/jellyfin/torrent) had a record, so from outside the whole proxy was unreachable. • The wildcard plus DNS-01 risk was TESTED, not assumed: if the wildcard ran over_acme-challenge.*, the cert renewal would break in ~60 days, silently. Queried through DoH, an explicit TXT under the wildcard is still returned as a TXT (RFC 4592: an explicit record takes precedence). Renewal is safe. • ZONE CLEANED the same day: 10 records became 3. Out went the two orphan_acme-challengeTXTs (prowlarr/torrent, leftovers from Arch, since certmagic did not clean up), thetvA pointing at 179.135.127.74 (a DEAD IP: the DDNS only managesssh, and being explicit,tvbeat the wildcard and stayed broken, so it was removed and now inherits the live IP) and theap/dash/filesCNAMEs tossh, which the wildcard made redundant. What was left isA ssh(the target of both the DDNS and the wildcard),A v1cferr.devproxied (the site on Vercel) and theCNAME *. • Thens1/ns2.dns-parking.comNS records also went out. They were leftovers from the HOSTINGER import, and the check that authorized removing them: the REAL delegation lives at the REGISTRAR, not in the zone, anddig NSalready returned onlybruce/zoe.ns.cloudflare.com, proving Cloudflare never served those two. Nothing from Hostinger needs to exist in the zone; the link is their dashboard pointing at Cloudflare. (If email ever comes to the domain, then yes: MX + SPF.) • A split-horizon TRAP: the ROUTER's wildcard also covers the APEX, so from inside the LANhttps://v1cferr.devhits Caddy and gives a TLS ERROR, because the*.v1cferr.devcert does NOT cover barev1cferr.dev(a wildcard does not match the domain itself). It is not broken: through Cloudflare's edge the apex answers a normal 307. It just cannot be tested for the apex from home. -
[x]
arch-browseopens again (07/08/2026): the alias was RIGHT, what broke it was the backup.restic-backups-home-gdriveruns as ROOT and thercloneConfigFileoption only setsRCLONE_CONFIG=/run/secrets/rclone_gdrive_conf. rclone renews the OAuth token and PERSISTS it over the file it was pointed at, which as root works, and the recreated file is bornroot:users, erasing the sopsowner = "v1cferr". Then the alias, which runs as the user (a FUSE mount is private to whoever mounts it), could not read rclone.conf anymore. • A treacherous symptom: it FIXES itself on reboot (sops reapplies it) and breaks on the first backup. The day's timeline closed exactly: boot 07:29:47,~/Driveread OK 07:30:10, the delayed 03:00 run (Persistent = true) ran 07:54:39, the secret became root's 07:54:40. • How it was found: comparing/run/secrets/*against the sopsmanifest.jsonfrom/run/current-system/activate. Onlyrclone_gdrive_confdiverged. It is worth keeping as a recipe, since secret drift does not show up innixos-rebuild, only at runtime. • Fix: a writable copy in/run/restic-backups-home-gdrive/rclone.conf(theRuntimeDirectorythe module already declares), the same pattern as~/Drive. restic never touches the secret again.mkBeforeonpreStart, NOTmkAfter: theinitialize = trueinjectsrestic cat config || restic initat the beginning of the SAME script and that already talks to the Drive, so copying after it would kill the service right at the start. • No token was lost:/runis tmpfs, so root's write was already discarded on every reboot. sops was always the source of truth. -
[x] Dolphin closer to Windows Explorer (07/08/2026): 6 keys, and only the ones where the Dolphin default DIVERGES from Explorer. Each default was read in the package's
config.kcfg, not guessed;HighlightEntireRowandSortFoldersFirstalready came right and stayed out. •[DetailsMode] ExpandableFolders=false: the▶markers and the tree lines were the most jarring thing, since Explorer has no expander in that view. •[General] ShowSelectionToggle=false(on Win11 "checkboxes" comes off),AlwaysShowTabBar=true,ShowFullPath=true,ShowStatusBar=FullWidth. •[KDE] SingleClick=false(double click) came in and went OUT the same day. The request is INTERFACE similarity, and clicking is BEHAVIOR, so it does not change a pixel. Worse: it lives in kdeglobals, so it would change EVERY KDE app because of the file manager. It works as a ruler for the rest: touch what you SEE, not what you USE. • The "pixel-perfect" guide that circulates (vrunox-9714/dolphin-win11-theme) was REFUSED, and not out of laziness: it depends on a KWin rule to remove the title bar (here it is Hyprland, there is no KWin) and on a QSS through--stylesheet, which would fight the Kvantum that already draws all of Qt. Before copying a theme recipe, check whether it presumes Plasma. • An Explorer-style TOOLBAR: TRIED AND REFUSED the same day (07/08/2026). Not committed, and~/.local/share/kxmlgui5/dolphin/dolphinui.rcwas deleted. The primary reason: I did NOT LIKE the result. Explorer has TWO strips (the address on top, the commands below) and Dolphin has only ONE toolbar, so everything piles into a single line and it ends up worse than the clean default. It is not a config limitation, it is Dolphin's. There was also a cost that on its own already called for caution: the.rccarriesversion="48"and when Dolphin goes to 49, KXMLGUI DISCARDS the file silently, so the toolbar would go back to the default with no error at all. The same class of drift as the ViewMode. If somebody insists: it is an idempotent activation and neverhome.file(rule 14, since Dolphin rewrites the file in "Configure toolbars"). But the verdict is NO. • Where the look STOPPED: icons plus the 5 keys above. What would be left requires fighting Kvantum (QSS) or a KWin that does not exist here, which means nothing cheap is left. -
[x] Dolphin icons: Fluent to Win11 (07/08/2026). The request was "as similar to Windows 11 as possible", and the starting point ALREADY WAS a Windows 11 theme (
fluent-icon-themeis Fluent Design). So it was not a fix, it was fidelity:Win11-icon-themeredraws Microsoft's icons, while Fluent is an authorial interpretation of Fluent Design. • Decided by LOOKING, not by reading: I put together a comparison with the real icons of the 3 candidates (Fluent / Win11 / We10X), 19 names each, at the same size and background.We10Xis out for being Windows 10X, the previous generation; it shows up in lists for being popular, not faithful. • The argument that decided it:Win11-icon-themeis by the SAME author (yeyushengfan258) as theWin11OS-kdewe already vendored for Kvantum, so widget and icon match out of the box. • The price: it leaves nixpkgs (vendored, pinned by commita5b460a), so bumping became MANUAL. • Three traps paid for in the build, all invisible in the upstream docs: 1.nativeBuildInputs = [ gtk3 ]is MANDATORY. install.sh callsgtk-update-icon-cacheat the end of EACH variant and theset -eo pipefailkills it right there, after installing the 1st. Without that,Win11-dark, which is the one we use, did not even exist. And it is not about the cache: noicon-theme.cachesurvives in the output. 2.noBrokenSymlinksfails the build with 147 dead links per variant. Silencing it is NOT a workaround: they are color variant names (folder-green.svg,folder_color_yellow_wine.svg) whose target does not exist in ANY installation, not even on Arch, becausecolors/color-<X>/usesfolder-*.svgnames to override and never creates the prefixed ones. A cosmetic upstream bug. Prune it withfind "$out" -xtype l -delete. 3.-t <color>stays OUT: it copiescolors/color-<X>/OVERplaces/scalableand would recolor the folders. The empty variant is precisely the one that was approved. • Comments citing "Fluent-dark" in launcher.nix/clipboard.nix now point atmy.theme.iconTheme. A hardcoded theme name in a comment is drift waiting its turn. • AND THE THEME ALONE WAS NOT ENOUGH: in the 1st screenshot after the rebuild the folders came out as monochrome LINE ART. The theme has colored art inplaces/16andplaces/scalable, butplaces/22isfill="currentColor", and 22 is the default. It was not a Win11 regression: Fluent had the SAME monochrome 22, it just did not show because the view was Compact (a big icon, so it fell into scalable). Which means what revealed it was the switch to Details, which landed the same day, two changes together disguising the cause. The key is[DetailsMode] PreviewSize, NOTIconSize. I got it wrong twice before readingdolphinitemlistview.cpp:172:previewsShown() ? previewSize() : iconSize(), so with previews on,IconSizeis IGNORED. Both went to 32 so the size does not jump when the preview is turned off to dig through the archive. It lives inhome/apps/dolphin.nix. • A repeated lesson:grep currentColoron the SVG made me conclude that the 16 was monochrome when it is COLORED (a 28 KB file, with the color outside the fill). Only rendering the three sizes side by side made the picture appear. For an icon, RENDER it, do not read the XML. -
[x] "Always Details" was never Details (07/08/2026): it had been Compact since 18/07. The
dolphin.nixpin always worked, it pointed at the wrong mode.DolphinView::Mode(src/views/dolphinview.h) is0 = Icons, 1 = Details, 2 = Compact, and NOT the menu order (Icons/Compact/Details = Ctrl+1/2/3). The kcfgwhatsthisstill calls 2 "column" (Compact's old name) and reinforces the mistake. • The diagnosis came from a SCREENSHOT, not from reading config: I opened Dolphin and looked. A "correct" config with the wrong effect cannot be seen by reading the.directory. If you are going to check a view mode, look at the screen: the name to the right of the icon plus a second column means Compact; Details has column headers and one line per item. • The raw number became a namedviewModeDetails, so it does not mislead again. • Migrating 2 to 1 required a new branch in the activation: over an ALREADY immutable key kwriteconfig6 exits 2 andset -ewould take the rest of home-manager down, so it rewrites with sed directly. • This is rule 14 proving itself for the SECOND time in the same file: nothing failed, it was just wrong, for 3 weeks. A KConfig pin with no verification is drift waiting its turn. -
[x] A THUMBNAIL on the restic mount costs a DOWNLOAD from the Drive: measured on 07/08/2026, this is not theory.
PreviewsShownhas<default>true</default>indolphin_directoryviewpropertysettings.kcfg(26.04.3) and it is not set here, so the preview is ON. I opened Dolphin in the snapshot'sPictures/Screenshots(30 files, 3.9 MiB): +30 thumbnails in~/.cache/thumbnailsand +3.68 MiB read from the network byrclone serve restic, which means it downloaded the whole folder just to draw icons. • KDE'sMaximumRemoteSize(default 0 = do not preview remote) does NOT protect, and the test PROVES it: it is unset and the preview happened anyway. A FUSE mount in/mntlooks to KIO like a LOCAL path, so the "remote" guard is not even consulted. • And the local limit ([PreviewSettings] MaximumSize) does not work as a guard: it is global and the images were ~130 KiB each, far below any sensible ceiling. • There is no PER-PATH guard in Dolphin/KIO, checked in the kcfg and in the binary's symbols. WithGlobalViewProps=truethere is no way to turn the preview off only in/mnteither. • DECIDED: the preview stays ON everywhere. A globalPreviewsShown[$i]=falsewas refused, because a thumbnail is worth more day to day than protection against a rare lookup. The mitigation is manual: turn previews off before digging through the archive. Recorded with a warning in the "Old Arch Linux configs" section, which is where you land when opening the repo. -
[x] VS Code always on the latest stable, with no manual edit (06/08/2026): this closes the loose end yesterday's URL change left. The request was "I would rather always keep it on latest", and the answer is NOT going back to
/latest/: a pointer plus a pinned narHash is exactly the per-release breakage we fixed yesterday. An input that updates itself does not exist with a pinned hash; what exists is an AUTOMATED BUMP. •pkgs/vscode-bump.nix(writeShellApplication, rule 7, since the build IS the shellcheck): it queriesupdate.code.visualstudio.com/api/update/linux-x64/stable/latest, reads theproductVersion(and NOT theversion, which is the commit hash), rewrites the number inflake.nixand runsnix flake update vscode-tarball. It is a NO-OP when already on the latest, because it runs on everyupgrade. • The repo path comes as an ARGUMENT (rule 11): the SSOT isprograms.nh.flake, read by zsh.nix throughosConfig, so the package holds no literal. • A scheduled Action was discarded: the repo would stay current on its own, but it would require agit pullbefore theupgradeto be worth anything, and each bump would trigger the 1.43 GiB CI. The moment the version matters is the REBUILD, so the right trigger is the alias. • Along for the ride, the aliases stopped repeating themselves:rebuildCmd/updateCmdare composed in theletandupgrade=${updateCmd} && ${rebuildCmd}. Before,upgradespelled both out, which is the same rule in two places, and on the day only one copy changes,upgradestops being what its name says (rule 11). • VALIDATED with a round trip, and that is the proof that matters: I downgradedflake.nixby hand to 1.131.0, ran the bump andflake.lockcame back BYTE-IDENTICAL to the committed one (thesha256-PLpT3k…narHash of 1.132.0).nix flake check: all checks passed. • What this does NOT solve: the extensions and settings still come from Settings Sync (a Microsoft account), not from Nix, so only the PACKAGE is declarative. -
[x] earlyoom was NOT protecting the compositor (05/08/2026): the most serious finding of the cleanup, and it turned up by accident.
waybarandmakowere in the--avoidlist and are ghosts (they left in the migration to Quickshell). When taking them out, I measured the regex against the LIVE processes and it matched 5 out of 10, with Hyprland left out. • THE CAUSE: earlyoom matchescomm, the KERNEL field truncated at 15 chars. The nixpkgswrapProgramleaves the script with the original name and the real ELF as.X-wrapped, and what RUNS is the ELF, so the comm is.Hyprland-wrappand.quickshell-wra.^(Hyprland|…)$never matched. The comment promised "the compositor never dies" and the effect was the opposite of what was written. • Now it is^[.]?(…)with no$, matching 16 live processes.quickshellcame in (today it is the bar, the OSD AND the notification daemon), along withhyprpaper. • A TRAP INSIDE THE TRAP: I wrote"^\\.?"first, and the backslash DOES NOT ARRIVE, because the module delivers the args throughEnvironment=EARLYOOM_ARGS=…and systemd discards\.as an invalid escape. The daemon logged'^.?(…)'. A character class[.]has no backslash to lose. LESSON: check what the DAEMON parsed, never the .nix:journalctl -u earlyoom | grep 'avoid killing'. -
[x] The backup migrated from the HDD to Google Drive, verified (05/08/2026): the Seagate held the ONLY copy of the live home, on a ~2009 Momentus 7200.4 with 840 thousand load cycles and 348 CRC errors, INSIDE the machine. It was not about space: the Drive has 4.95 TiB free out of 5 TiB. An offsite copy wins on the failure modes that actually happen (the disk dies, theft, fire); it loses on restoring over the network and it starts depending on the Google account. • Measured: the 1st snapshot read 40.6 GiB, becoming 23.6 GiB on the wire, in 15 min, across 255 thousand files. The next incremental: 33 s and 170 MiB.
check --read-datarereading 189 packs: "no errors were found". • Three things a REMOTE repo requires and a local one does not:--pack-size=128(on the Drive the cost is per API CALL, not per byte),checkOpts = [](the local--read-data-subset=10%REREADS, and rereading remotely is DOWNLOADING, which would be GB/day forever) and--max-repack-size=2G(remote pruning repacks). • A BUG this uncovered: the backup FAILED INTERMITTENTLY withlstat /home/v1cferr/FAI-workstation: permission denied, so restic exits 3. It is a USER FUSE mount and the backup runs as ROOT, which does not enter somebody else's FUSE; it only happened with the FAI VPN up. And sincebackupis the 1st of THREE ExecStart entries,forget --prunedid NOT run, so retention silently did not apply on those days.--one-file-systemdoes not save you: it prevents DESCENDING, but the lstat of the mount point still happens. • The Seagate repo was NOT deleted: the Drive has 1 snapshot and it has 13, with a 6 month window. Deleting it today would lose every version older than today. • New aliases:backup-browse(it mounts the repo as a folder, one dir per snapshot) andbackup-verify. rclone does NOT decrypt restic; what decrypts is restic. -
[x] ~/Drive = the Google Drive root MOUNTED, not synced (05/08/2026): I started with
rclone bisyncand switched torclone mountafter LISTING the remote, which is the step I should have taken FIRST. The root has ~19.6 GiB of real archive (Documentos, César, Mãe, SENAC and so on), and the dedicated folder I had invented would have been born EMPTY, which did not solve "I need a file that is on the Drive". • Mount wins here: zero download (bisync would pull the 19.6 GiB onto the NVMe for the SAME access) and sync PROPAGATES, so deleting locally would delete on the Drive, including family folders. •Type=notify(it is inrclone mount --help): the unit only becomes "started" after the mountpoint is ready, otherwise Dolphin opens first and caches "empty". •--exclude BACKUPS_EX-B560M-V5/**: it hides ~48 GiB of restic blobs from the file manager. It is not cosmetic, because an accidental Delete in there CORRUPTS the backup. • Two traps paid for: (1) bisync does not create the destination folder and the remedy it suggests fails too; (2) rclone RENEWS the OAuth token and tries to persist it into the config file, and against the sops secret (0400) that becomesFailed to save config, so the unit copies it into%t(tmpfs, 0600) and passes--configon the command line.--configon the command and notRCLONE_CONFIGin the environment, because exporting it would make the FAI mount look for thefaiwsremote in the wrong file. • And the mount would not come up because~/Drivehad an orphan 0-byteRCLONE_TESTthat the bisync version had created: rclone refuses a non-empty mountpoint, and--allow-non-emptystays out on purpose (mounting over it HIDES the file). If it does not come up:ls -a ~/Drivebefore suspecting the network. -
[x] An option is DECLARED in system/ and DEFINED in hosts/ (05/08/2026): this became convention 6 in the README. The monitor connectors (
DP-2/HDMI-A-3) had adefaultin system/desktop/monitors.nix and themy.servicespanel lived in system/services/toggles.nix. With a single host that is invisible; on host number 2, system/ starts LYING (a laptop would inherit connectors it does not have and would bring Jellyfin/Sunshine up by default). Now system declares the options andhosts/nixos-kingston/services.nixanswers them. The monitor defaults were removed on purpose: a host that forgets them BREAKS at eval, loudly and early. Themy.servicesdeclaration is still central and was NOT distributed per module, becauseosConfigonly sees the NixOS namespace, so the keys read by home/ (dropbox, discord-rpc, cs2-backup) need a SYSTEM module either way. -
[x] The gate started BUILDING, not only evaluating (05/08/2026):
nix flake checkbuilds what is inchecksand ofnixosConfigurationsit only requires that the toplevel BE a derivation. Measured: it printed "running 1 flake checks" and only pre-commit was built. What is fragile here is not evaluation, it is PACKAGING: nxbender's 3 patches, vscode'ssourceRoot = "source"and the wrapProgram over claude-desktop's .deb are assumptions about SOMEONE ELSE'S tree, so they break at build time, after anupdate, and sinceupgradeisupdate && nh os switch, the breakage landed in the middle of the switch. Nowpackages.x86_64-linuxexposes all four (nix build .#nxbender) andchecks.packagesbuilds them. Deliberately NOT system.build.toplevel: that would drag quickshell (Qt/C++) onto the runner. And it caught its first victim the same day: the VS Code/latest/URL is a POINTER. 1.132.0 shipped, the pointer moved and the pinned narHash (1.131.0's) stopped matching. Here it passed because the old tarball was in the store; on a CLEAN machine the flake did not evaluate anymore. It was not a 2032 risk, it broke on every release. Swapped for a versioned URL (/1.132.0/), which is immutable, and the price is thatnix flake updatedoes not bump the version on its own, since bumping means editing the number. -
[x] Hunting down dead code and dead docs (05/08/2026): deleted
pkgs/README.md(it said "empty for now" with 2 derivations inside, and pointed at a "phase 5 of the README" that does not exist),home/desktop/quickshell/bar-preview.qml(the file itself defined when to die: "once the bar reaches parity, it is wired into shell.qml and Waybar goes away", and both things happened),scripts/healthcheck.sh(zero references in any .nix and a header calling itself "gitignored" while being VERSIONED, the loose.shthat rule 7 forbids) and the orphan.env. Plus ~6 comments describing a world that does not exist (the "PHASES" in Bar.qml,nvidia-smion an Intel machine,waybarin two category lists). AUDITED and clean: zero orphan.nix, zero flake input with no consumer (12 checked), everymy.*option with a consumer. LESSON: the worst legacy is not a leftover file, it is a LIST ENUMERATING A REMOVED PROGRAM. The deadwaybar/makoin earlyoom's--avoidhid the fact that the compositor was never protected. Dead docs there were not clutter, they were a disguised bug. -
[x] The
nixos-sandisknode REMOVED from the tailnet (05/08/2026): it had been sitting offline for 4 days, belonging to a machine that no longer exists (the SanDisk became Windows 11), and a dead node is an ACL and a route nobody audits. Done in the admin console; 2 were left (nixos-kingston, faidell6035). • WHY THESE TWO DID NOT BECOME DECLARATIVE (researched on 05/08/2026, rule 1): the OFFICIAL CLI does neither.tailscale --helpon 1.98.10 (the installed one) has 30 subcommands and none of them iskeyordevice. The closest islogout, which expires THIS machine's node key, not removes somebody else's node. The official "Remove a device" doc says console or API, with no CLI, and the upstream FR (tailscale/tailscale#8844) is still open. Both actions belong to the v2 API:DELETE /api/v2/device/{id}andDELETE /api/v2/tailnet/{tailnet}/keys/{keyId}, athttps://api.tailscale.com. DECISION: do it in the admin console and do NOT store an API token. Automating it would require a token or OAuth client with write access to the whole tailnet, kept in sops, a PERMANENT secret more powerful than the auth key you want to revoke, created for a ONE-TIME task. It only pays off if a recurring routine ever exists (pruning idle nodes, say), and then the right thing is an OAuth client with minimal scope, not an admin API key. (A doc detail that matters: revoking the key does NOT deauthorize whoever already joined with it, since they are independent actions, and that is why both are on this list.) The CLI, by the way, ALREADY is declarative: it comes fromservices.tailscale.package(system/net/tailscale.nix), not from a package list. Puttingtailscaleinsystem/packages.nixwould be the same package in two places, which breaks rule 4. -
[x] The Arch archive VERIFIED and the module DELETED (05/08/2026): the end of the lifecycle that
system/services/restic-arch-kingston.nixitself had written down: "once the check --read-data passes and the Kingston is formatted, delete this file". •sudo restic-arch-kingston check --read-datareturnedno errors were found, reading the 189 packs (1 snapshot, 7 indexes) of the repo on the Drive. That is what separates "it uploaded" from "it can be restored": it rereads the data, not just the index. • Out went the module, the import insystem/services/default.nix, thearch-kingston-archivekey intoggles.nixand thetruein the host panel. • KEPT on purpose: the repos, therestic_password_arch_kingstonsecret (the Bitwarden index) andrclone_gdrive_conf. They are the KEY to a live archive, and the module was what WROTE to it, not what gives access. • A side effect that became a fix: deleting the module took therestic-arch-kingston*wrappers with it, andresticwas NOT on the PATH, because the nixpkgsservices.resticonly generates a wrapper per repo. The archive would have been unreachable without anix shell.resticwent intosystem/packages.nix(the "rescue" criterion), and the wrapper-less commands are in the "Old Arch Linux configs" section above. • The Seagate leg was NOT read-verified and will not be: on 05/08 it was decided to keep ONLY the Drive copy. Verifying a repo that is going to be deleted is wasted work. -
[x] A second age recipient in the sops vault (04/08/2026):
.sops.yamlhad ONE key, and sops has no recovery, so losing that key means losing EVERY secret in the repo, forever. Its only backup was Bitwarden, so the design had a point of failure capable of permanent damage (the repo's other risks cost time, not data). Now there are two recipients:host_nixos_kingston(the usual one, in /var/lib/sops-nix/key.txt) andbackup_offline. •creation_rulesonly applies to a NEW file, so adding the key to.sops.yamldoes NOT re-encrypt what already exists. What does that issops updatekeys -y secrets/secrets.yaml, run as root (the current key is root's) and withchown v1cferr:userspluschmod 644afterwards, otherwise the repo file ends up owned by root. • VERIFIED by decrypting the vault with the backup key in ISOLATION (SOPS_AGE_KEY_FILEpointing only at it). Without that test the backup would be imaginary, andupdatekeysprints "already up to date" even when it does the job, so its message is not proof. The proof is decrypting, or the tworecipient:entries inside secrets.yaml. • A good side effect:restic_passwordlives INSIDE the vault, so recovering the vault recovers the restic password, and losing Bitwarden no longer turns the repos into bricks. • The anchor was renamed fromnixos_seagatetohost_nixos_kingston: the key was born on that host and was carried over in the cutover (01/08), so it is the same key on a new host, and the old name was confusing. MISSING is the OFFLINE copy (USB or paper in another physical place): both copies of the private key today are on the same machine and in the same cloud account (~/ and ~/Dropbox, in plain text, a conscious choice). While that is the case, the second recipient protects against losing the host key, not against losing the account or the machine. -
[x] The CI started running
nix flake checkFOR REAL (04/08/2026): the workflow ran statix/deadnix/nixfmt straight from nixpkgs because the privateduo-streak-daemoninput would makeflake checkrequire a deploy key (Nix fetches ALL the lock's inputs when evaluating, not only the ones the output uses). That left two holes: the CI did not verify thatnixosConfigurationsEVALUATES (a module error stayed green until the rebuild), and the threenix runcalls were a THIRD definition of the same rule that flake.nix and pre-commit already defined, rule 14 drift waiting to happen. • The way out was--override-input duo-streak-daemon path:./ci/stub-duo: it swaps the input BEFORE the fetch, so no credential enters the CI. An EMPTY directory is enough because the only consumer (system/services/duo.nix) uses the input purely as a Docker build context, which is path interpolation with noreadFileat eval time. If some day a module READS a file from the private repo, the stub needs that file (or plan B, the deploy key, comes back; it is recorded at the end of the workflow). • Theenv NIXPKGSthat existed only to pin the linters' version is gone: they now come from flake.lock, identical to the local ones by construction. Touching the flake.nix hooks changes the CI by itself, with no workflow edit. • ACCEPTED COST: the check fetches the ~1.43 GiB of inputs and evaluates the whole config, so the CI went from seconds to minutes. Machine time in exchange for coverage and for a single definition. -
[x] An offline archive of the flake inputs (04/08/2026):
nix flake archive --to file:///home/v1cferr/flake-archive, which enters the home restic (restic.nixcovers /home/v1cferr and does not exclude that path), so it ends up versioned and verified by the machinery that already exists, instead of a raw copy. Measured: 18 inputs, 1.43 GiB in the store, becoming 319 MiB in the archive (thefile://cache compresses with xz, which is why it takes a few minutes). • VERIFIED for real, not by "the files are there": each input was QUERIED back withnix path-info --store file:///home/v1cferr/flake-archive <path>, with 0 missing. • A TRAP when verifying:--dry-runalso lists the ROOT flake path, which with a DIRTY tree changes on every edit, so it shows up as "missing" from the archive with nothing actually wrong. The archive is of the INPUTS; the repo itself has git as its backup. • WHY:flake.lockpins IDENTITY, not AVAILABILITY, and flakes have no mirrors (there is no?mirrors=like in fetchurl). Half the inputs come from a single maintainer or are self-hosted, andquickshellonly exists at git.outfoxxed.me. If that server goes down, the input is unfetchable and the pinned rev helps with nothing. • What this does NOT buy: a complete offline rebuild. The sources of each nixpkgs package still come from the cache/upstream. To boot without building anything, what you archive is the system closure (anix copyof system.build.toplevel), which is tens of GB, a different decision. PENDING becoming declarative (rule 3): today it is a manual command and it ages on the nextnix flake update. The natural owner is a systemd timer re-archiving, and restic deduplicates, so re-archiving only adds the inputs that changed. -
[x] BTRFS properly configured (02/08/2026): the FS was audited after the cutover. What existed (noatime, space_cache=v2, subvolumes, a monthly scrub) was right; what was missing became system/hardware/btrfs.nix (POLICY, machine-agnostic behind "is the root btrfs?") plus system/services/btrbk.nix (snapshots). The LAYOUT stays in disko.nix. • SNAPSHOTS (the biggest gap): btrfs with no snapshots is ext4 with checksums. An hourly btrbk of @home, retention 48h/7d/4w,
snapshot_create=onchange(otherwise an idle machine generates 24 identical snapshots a day and pushes the useful ones out). It does NOT replace restic, since it lives on the SAME disk; it covers "I overwrote it 20 min ago", restic covers "the disk died". @home only: the root already has per-generation rollback in GRUB, and a snapshot of/would not even catch /nix (a separate subvolume, since a snapshot does not descend into a nested subvolume). • zstd:3 to zstd:1: on a ~7 GB/s Gen4 drive the bottleneck becomes the COMPRESSOR. Decompression has the same speed at both levels, so reading loses nothing and every rebuild gains. It only applies to NEW writes; rewriting would requiredefragment -czstd, which BREAKS reflinks. • fstrim.timer TURNED OFF:discard=async(a kernel default since 6.2, now explicit in disko) is already the same operation, queued and rate limited. Both together means duplicate TRIM. If discard=async is removed from disko, turn fstrim back on in the SAME commit. • Automatic block group reclaim turned on (dynamic_reclaim + periodic_reclaim, kernel 6.11+, both came as 0). It is the IN-KERNEL replacement for btrfsmaintenance'sbtrfs balance -dusage=Ncron, and better, because it knows when relocating is NOT worth it. bg_reclaim_threshold stays untouched: it is mutually exclusive with the dynamic one (EINVAL). • Alarm: the scrub failed SILENTLY. Now OnFailure sends a critical notification to every live session plus the journal. On top of that, a DAILY check ofbtrfs device stats -c: the scrub is monthly, so an NVMe that starts dying on day 2 would go 28 days with no warning. The counter does not reset itself, so acknowledge it withdevice stats -zAFTER investigating. •+C(nodatacow) on the database directories (Docker volumes, the Jellyfin SQLite): CoW plus random 8 KiB writes fragments endlessly. It only takes effect on NEW files, and it turns the checksum off for those files, a conscious trade-off, since both are rebuildable. A SINGLE MANUAL STEP (a subvolume is not born in a rebuild, since disko only runs at install time): sudo mount -o subvolid=5 /dev/nvme0n1p2 /mnt && sudo btrfs subvolume create /mnt/@snapshots && sudo umount /mnt Thenofailon /.snapshots exists so that forgetting that step costs "btrbk does not run" (RequiresMountsFor) instead of "the boot drops into an emergency shell". Do NOT turn qgroups/quota on: it kills btrfs performance and it is the reason for half the "btrfs is slow" reports. Nothing here needs them. -
[x] DUALBOOT with the minegrub theme + SECURE BOOT (02/08/2026): systemd-boot became GRUB, with the Minecraft "world selection" theme, Windows 11 in the menu and Secure Boot on in both OSes. system/core/boot.nix (the dormant boot-grub.nix was absorbed) plus system/core/secureboot.nix. WHAT DECIDED THE ARCHITECTURE, and it was not taste: the two ESPs are on different disks (NixOS on nvme0n1p1, Windows on sdb1). systemd-boot only loads an EFI binary from its OWN ESP, so it is incapable of listing Windows, and switching OS would become F8 at POST every time. That takes LANZABOOTE down with it, since it is systemd-boot-only and it is the official Secure Boot path on NixOS. What is left is GRUB (it reads both ESPs, and it is what the theme requires) plus signing by hand through sbctl. There is no NixOS module that signs GRUB. HONESTY ABOUT WHAT THIS PROTECTS: the firmware verifies GRUB and Microsoft's bootmgfw; GRUB loads the kernel and initrd WITHOUT verifying them (it has no shim). It satisfies the firmware and Windows and it blocks a bootloader swapped from outside; it does not block someone who already has root. The whole chain only with lanzaboote, and then with no menu and no theme.
enroll-keys -m(with the Microsoft certificates) IS NOT OPTIONAL: without it, erasing the factory keys takes down Windows AND the Arc B580's option ROM. And the timing matters: Microsoft's 2011 CA EXPIRED in june/2026. Checked on this machine on 02/08: BIOS 2803 already carries both generations in thedb(2011 plus the three 2023 CAs) and sbctl 0.18 embeds all six, so the-malso covers post-rollover Windows.--firmware-builtinwould NOT work: this firmware'sdbDefaultis empty. A THEME TRAP: the icons match by--class, NOT by title, and it fails silently (a generic icon with no text).nixoscomes from the defaultentryOptions;windowsis derived by 30_os-prober from the FIRST word of the "Windows Boot Manager" label (so "windows", never "windows11");submenuis the one for old generations. The 2-line text is RENDERED INSIDE the PNG in the Minecraft font, and the GRUB title is pushed off screen by the theme (item_icon_space = 2000), which is why every generation shows the same description: they share thenixosclass. A theme limitation. THE THEME CHOICE: the original link was minegrub-theme (the Minecraft main menu), passed over for minegrub-world-sel-theme (the same author), because the world selection screen gives an icon plus a description PER OS, which is what a dualboot wants; in the main menu an entry is just a button. MEASURED BEFOREHAND: the 1 GiB ESP holds all 10 generations. install-grub.pl turns copyKernels on by itself (/boot is on a different filesystem from /nix/store, which also avoids depending on GRUB reading btrfs+zstd) and names files by store hash, so generations sharing a kernel occupy space once: 13 MiB plus 47 MiB per kernel version. The runbook of the MANUAL steps (Setup Mode can only be entered through the BIOS) is in the secureboot.nix header, along with the why of each one. -
[x] Remove every other host and keep only the current one: today there is only hosts/nixos-kingston/. nixos-sandisk left on 02/08/2026, because its disk became Windows 11, so the host was no longer a rollback and no longer a target. A template for a new host can be taken from the git history.
-
[x] Add Duolingo running automatically with Nix: the duo-streak-daemon stack (a Playwright daemon + api + web + Postgres) through docker compose managed by systemd (system/services/duo.nix). The code is a private flake input (git+ssh, pinned in flake.lock); the secrets come through sops (the duo.env template); the login uses a saved SESSION (duo-login once, because headless falls into Duolingo's anti-bot). The streak is kept alive on its own once a day (catch-up). Helpers: duo-login, duo-run-once.
- [x] Install Ollama or another recommended way to run AI models locally: NATIVE Ollama (system/services/ollama.nix), on the GPU (Arc B580) through Vulkan since 06/08/2026. qwen3:4b (the text solver) plus bge-m3 (embeddings) through loadModels. It is the local solver for duo-streak-daemon (localhost:11434), with no quota and no cloud.
- [x] Ollama on the Arc B580 GPU (06/08/2026): this was the "explore later" left pending
from the card swap.
services.ollama.accelerationdoes NOT exist anymore (mkRemovedOptionModule): acceleration became a PACKAGE choice, and plainpkgs.ollamais the same as-cpuwhen there is no rocmSupport/cudaSupport, which means the old "CPU-only" was not a nixpkgs limitation, it was the default. A 1-line solution:package = pkgs.ollama-vulkan(0.32.3, already in 26.05). VULKAN and not SYCL/ipex-llm because Vulkan uses the Mesa ANV that is already on the system, so there is nothing new to package (ipex-llm is not in nixpkgs). Measured at startup:library=Vulkan description="Intel(R) Arc(tm) B580 Graphics (BMG G21)" type=discrete total=11.9 GiB available=9.7 GiB.llvmpipe(Vulkan on the CPU, showing up as GPU1 in vulkaninfo) is discarded by ollama itself, soGGML_VK_VISIBLE_DEVICESwas not needed. The module's hardening already allows the card:DeviceAllowhaschar-drm(major 226 = /dev/dri/*) andSupplementaryGroups = [ "render" ]. A known risk: the Vulkan backend crashing on Arc under high-frequency decode (ollama#14207). The fallback ispkgs.ollama-cpu, 1 line. An ARCHITECTURAL CONSEQUENCE: Mesa is now a critical path for AI, not only for games, which reinforces the driver/unstable item below. -
[x] Claude Desktop (GUI: Chat/Cowork/Code), 02/08/2026. The research changed its answer halfway through: on 30/06/2026 Anthropic started publishing an OFFICIAL Claude Desktop for Linux (beta, a
.debin an APT of its own, with only Debian/Ubuntu supported). That RETIRES the projects that reverse engineered the macOS/Windows binary, which was the entire state of the art until then. It is NOT in nixpkgs: issue #366213 (a package request) was CLOSED and the channel only has claude-code/claude-monitor. CHOSEN:aaddrick/claude-desktop-debian(5.3k stars, automatic releases following the upstream version), which REPACKAGES the official .deb since v3.0.0, usingdpkg-debplusautoPatchelfHook, the nixpkgs vendored-binary pattern (discord/vscode). PASSED OVER:k3d3/claude-desktop-linux-flake(the pioneer and the most cited in searches, but it reverse engineered the native module and has been IDLE since nov/2025, which predates the official release) andheytcass/claude-for-linux(it extracts from the macOS DMG; 6 stars and 77 open issues). A criterion beyond popularity: aaddrick does NOT use the nixpkgs electron (it keeps the tree co-located so/proc/self/exe/resourcesPathresolve) and does NOT turn the sandbox off.chrome-sandboxships SUID, the store carries no SUID, and instead of the--no-sandboxmost forks use, it relies on the userns sandbox. The FHS variant and not the plain one: the MCP servers need to find node/uv, and Cowork brings up a real QEMU VM looking for/usr/share/OVMF/*.fdand/usr/bin/virtiofsdat HARDCODED FHS paths, so outside the FHS it only answersvirtualization_tools_missing. MEASURED closure: 2.9 GiB (qemu_kvm is the biggest slice). Integrated throughoverlays.defaultand not throughpackages.<system>(which is the zen-browser/browser-previews pattern here): I checked FIRST that the 13 attributes the package uses (libgbm, addDriverRunpath, qemu_kvm, OVMF and so on) exist in 26.05, so it can be built against the stable base instead of dragging a 3rd nixpkgs into the lock. Its input isnixpkgs-unstable, andfollowsalone would NOT solve it (the overlay uses the consumer'sfinal, ignoring its own input). COWORK DOES NOT WORK on this machine until a MANUAL BIOS step: the kernel saysx86/cpu: VMX (outside TXT) disabled by BIOSandkvm_intel: VMX not enabled, so/dev/kvmdoes not exist. Turn "Intel Virtualization Technology (VT-x)" on (the same visit as Secure Boot) and ONLY THEN addusers.users.v1cferr.extraGroups = [ "kvm" ], which did not go in here because it is not validatable without the device./dev/vhost-vsockALREADY exists. Chat and Code work with none of this. Runtime findings:--doctoris NOT a recognized flag in this version (it opens the GUI); the app comes up in NATIVE Wayland by itself, so theCLAUDE_USE_WAYLAND=1the official docs tell you to use is unnecessary here. Missing from the Linux beta: Computer Use and dictation. STATE (rule 6, so restic): the session/login and~/.config/Claude/claude_desktop_config.json, and the app REWRITES that JSON at runtime, so by rule 14 Nix does not own it. KEYRING: on the 1st login the app warns "your sign-in won't be saved" and asks for a login EVERY time. It is NOT the keyring (checked:org.freedesktop.secretson the bus andcollection/loginpresent, so it is not the keyring-after-restore case). It is Electron autodetecting the secret backend from XDG_CURRENT_DESKTOP: "Hyprland" matches no case in Chromium's os_crypt, it falls back to "basic text" and safeStorage declares itself unavailable. The SAME bug and the SAME remedy as VS Code, but withoutcommandLineArgs(this is not the nixpkgs electron), so it comes in through a wrapper (overlayClaudeKeyringin flake.nix). Onlyclaude-desktopis wrapped: the upstream overlay builds the-fhson top offinal.claude-desktop, which is the FIXPOINT one, so the FHS variant inherits the wrap by itself and their fhs.nix did not have to be touched. The general rule: EVERY new Electron app here will need--password-store=gnome-libsecret. -
[x] Swap the RTX 3050 for an Intel Arc B580 (Battlemage): DONE. The Arc is validated (
xeloaded, fastfetch/vainfo OK) and NVIDIA is REMOVED for good: system/hardware/gpu.nix is now pure Intel (xe + Mesa, VA-API iHD), with nomy.gpu, no specialisation and no CUDA. Battlemage is fine on kernel 6.18/Mesa 25.x. Ollama stayed on the CPU through the swap and WENT BACK to the GPU on 06/08/2026 throughpkgs.ollama-vulkan(see the Ollama item above). To resurrect NVIDIA: the git history of system/hardware/gpu.nix. -
[x] The Intel driver from the UNSTABLE channel: TRIED, TESTED and REJECTED (06/08/2026). The idea was "the driver always on the latest version, because Intel updates it every week". It dies on the fact that a graphics driver on NixOS is not a normal lib: it is a PLUGIN loaded impurely from
/run/opengl-driver/lib, and the LOADER comes from the base channel. A loader accepts a driver equal to or OLDER than itself, never newer, becauselibvasweeps__vaDriverInit_1_<minor>from its own minor down to1_0and does not try above. Measured: the unstableintel-media-driverexports1_24, the stablelibva2.23.0 stops at1_23, sovaInitialize failed with error code -1, and ALL decode/encode falls back to the CPU silently (rule 14: nothing fails, it just ends up wrong). A known community problem (nixpkgs #263940, #216361). MESA IS THE EXCEPTION, measured AFTERWARDS, and it is the opposite of what I had concluded:libgbmbecame a SEPARATE package (a stub that links the host's at runtime) and 25.05 introducedhardware.graphics.packageprecisely to "manage the global Mesa version without a mass rebuild". Tested: theunstable.mesaICD plus the system vulkan-loader givesdeviceName = Arc B580anddriverInfo = Mesa 26.1.6; EGL the same, no error. In other words: Mesa CAN cross channels (the Vulkan/GL loader negotiates the version),libvaCANNOT (it only goes down in minor). It is not the same class of problem, even though it looks like it. And the gain did not exist: nixpkgs BACKPORTS point releases to the release branch. Mesa 26.1.5 vs 26.1.6, and kernel 6.18.42 + linux-firmware 20260622 are IDENTICAL on both channels. The only divergence is the Intel userspace (media-driver 26.1.6 to 26.2.4, compute-runtime 26.18 to 26.27, vpl-gpu-rt 26.1.6 to 26.3.0), and that is exactly the one that cannot cross channels. THE RULE THAT STAYS, per lever: (a) kernel goes topkgs.linuxPackages_latest, from the OWN stable channel, with no crossing (done, see the kernel item); (b) Mesa goes tohardware.graphics.package = pkgs.unstable.mesa(pluspackage32 = pkgs.unstable.pkgsi686Linux.mesa, in that order, sincepkgs.pkgsi686Linux.unstableis wrong, see flake.nix), a proven mechanism but ONLY worth it when the delta is a real minor: review ~sep/2026, when unstable goes to 26.2+ and 26.05 pins to 26.1.x; (c) VA-API/oneVPL/compute stays on stable and only moves with the base (26.11, ~nov/2026); (d) do NOT adopt mesa_git or a third party cache. The warning is recorded in theextraPackagesheader in system/hardware/gpu.nix. Extra weight since 06/08: Mesa became a critical path for AI too, because Ollama started running through Vulkan/ANV, so it is not only game performance anymore. -
[x] Mainline kernel (
linuxPackages_latest, 7.1.x): 06/08/2026, in system/core/boot.nix. It is lever (a) of the item above and the ONLY driver one that does not cross channels, becauselinuxPackages_latestcomes from 26.05 itself, and the Arc'sxedriver lives in the kernel, so a new kernel means a new driver with no loader ABI risk. It is safe on this machine because there is NO out-of-tree module (no zfs or virtualbox to version match, audited) and Secure Boot here signs GRUB, not the kernel (core/secureboot.nix), so it asks for no key re-enroll. It goes from 6.18.42 (the release default) to 7.1.6. Rollback is the previous generation in the GRUB menu.bootis PREFERABLE toswitchon a kernel change, butswitchdoes not break it: I had written "NEVER switch" and I was wrong, because NixOS keeps/run/booted-system/kernel-moduleswith the tree of the RUNNING kernel, and that is what happened in practice (switch 6.18.42 to 7.1.6,systemctl --failedempty, modprobe resolving under .../6.18.42). The advantage ofbootis only not restarting a service in a generation whose kernel has not come up yet.
Add all of them as the default
- [x] Centralized UI FONT (rule 10):
my.fonts.uiin system/hardware/fonts.nix is the SSOT, so switching = 1 line plus the package. It lives in system/ (not in my.theme) because the PACKAGE is system level and fontconfig needs the name, since a system module cannot read an HM option while the reverse works. 7 consumers, all throughosConfig.my.fonts.ui: fontconfig (defaultFonts mono/sans/serif), GTK (dconf + gtk.font) and Qt in theme.nix, kitty, hyprlock, the rofi launcher + clipboard, and Quickshell through the SAME palette JSON (the .qml is a hot-reload symlink, Nix does not write inside it). The SIZE stays with each consumer (11pt GTK, 12pt kitty/rofi, per widget on the lock), because that is context. Validated with a sentinel: I changed the value, all 7 changed, and the revert came back to the same store path. JetBrainsMono Nerd Font is confirmed as the #1 recommendation for dev work in 2026 (Fira Code is 2nd, with ligatures; Iosevka is narrower, ~20% more code per line). A rofi TRAP: inside the .rasi, '#' opens a COLOR literal, not a comment, so commenting in there kills the parse of the WHOLE theme and rofi only warns on stderr, falling back to the defaults silently. -
[x] FALLBACK CHAIN (02/08/2026): emoji, CJK, math and dingbats turned into a PIXELATED LITTLE SQUARE (a stream title on Twitch, a spreadsheet in Chrome). The diagnosis knocked down the obvious hypothesis: the emoji, CJK and color fonts were ALREADY installed, coming for free through
fonts.enableDefaultPackages = true. The defect was the chain having ONE LINK ONLY:sansSerif/serif/monospace= the SSOT alone, a MONOSPACED font covering Latin/Greek/Cyrillic plus the patched symbols and nothing else. Everything outside that was resolved by fontconfig's own ordering, which is to say by ACCIDENT, and at the end of that queue sitsunifont, a 16px bitmap that is the only one covering ranges like U+0870 and U+2FFC (measured withfc-list ":charset=<cp>"). The little square was it. FIX:noto-fonts(which brings NotoSansMath/Symbols/Symbols2, where the mathematical letters 𝗥 and the dingbats ⁎ come from),noto-fonts-color-emojiandnoto-fonts-cjk-sans, all DECLARED even the ones already coming from enableDefaultPackages, because rendering cannot depend on a NixOS default nobody asked for. And each generic became a list: the SSOT first (appearance untouched), Noto in the middle,Noto Color Emojiat the END (at the end it never wins over a text font, but it is reached directly instead of by luck in the queue). A MEASUREMENT TRAP that almost made me conclude wrongly TWICE:fc-matchLIES. With an explicit family (fc-match "Noto Sans:charset=1F534") it returns the requested family even if it does not have the glyph, since charset only weighs on the ordering. And with no valid charset it answers anything (it answeredunifontfor everything when my loop broke the parsing). What filters BY REAL COVERAGE isfc-list ":charset=<cp>". NOT A BUG:❤(U+2764) stays monochrome on purpose, because it is a TEXT PRESENTATION emoji and it only becomes colored with the VS16 selector (❤️). Forcing color would require a rule of its own. -
[x] Download a MEGA link through a proxy/Tor (03/08/2026):
mega-tor <link> [destination](home/net/mega.nix) plus a client-only Tor daemon with SOCKS on 127.0.0.1:9050 (system/net/tor.nix, themy.services.tortoggle). THE TOOL: megatools (megadl), 139 KiB of closure, maintained (1.11.5, jul/2025). It is the only maintained one that opens a PUBLIC LINK from the CLI and has a NATIVE--proxy socks5h://, since its own man page usessocks5h://localhost:9050(Tor) as the example, so it needs no torsocks or LD_PRELOAD. Discarded: rclone (themegabackend talks to an ACCOUNT; a link with the key in the fragment is not a remote path, rclone#7088 open), MEGAcmd (official, a big closure andproxyonly over HTTP(S): SOCKS is issue #204, open since 2019, and with no SOCKS there is no Tor) and megabasterd (a Java GUI; its proxy is a LIST of proxies for beating the quota, a different goal). THREE THINGS FROM THE NIXOS WIKI THAT DO NOT HOLD HERE (checked in the nixpkgs module, not assumed): (a)services.tor.enablewithoutclient.enablebrings the daemon up with NO outbound port, so it staysactiveand nothing can use it; (b) theopenFirewall = truefrom the example is for a RELAY, since the listener is 127.0.0.1, there is nothing to open, and opening it would become an open proxy on the LAN; (c) the "second fast port 9063" DOES NOT EXIST, because the module generates ONE SOCKSPort fromclient.socksListenAddress, and 9063 is only the default of thetorsocks-fasterwrapper (services.tor.torsocks), which without a hand-declared SOCKSPort points at a port where nobody listens. That is why torsocks was left out.SafeSocks 1: it refuses SOCKS4 and SOCKS5-with-IP, so whoever resolves DNS locally gets an ERROR instead of leaking the query, and that is why the consumer uses socks5h. A PATIENT LOOP (there is a single wrapper,mega-dl, with the transport as a flag; themega-torof the 1st version became--tor): it tries, and on failure it RESUMES until the file completes or until the 48h ceiling. Resuming is what makes this worth it: the partial lives in.megatmp.<id>at the destination, resume is the DEFAULT (--disable-resumeis what turns it off) and it is keyed by the FILE ID, not by the transport. MEASURED: I started over Tor and continued directly from the same partial. The--torproves the circuit first (the exit IP through check.torproject.org) so it fails with the right cause when the daemon is down. THE QUOTA IS THE REAL LIMIT, and no transport changes it: an anonymous download gets ~5 GB per IP in a ~6 h SLIDING window, counted per IP and not per account (logging out does not reset it). The real test was a 17.4 GiB file = ~4 windows. That is why the loop distinguishes "over quota" (a megatools string) and WAITS 30 min instead of switching IP: a sliding window frees up gradually, so knocking every 30 min yields more than waiting 6h idle, and slicing the file across different IPs is exactly what the quota exists to prevent (it is what megabasterd does with a proxy list). Urgency is solved with a Pro account (megadl -u/-p, the password through sops), not with rotation. QUOTA DETECTION through acaseon a variable and NEVER| grep -q: with the writeShellApplication pipefail, grep exits on the 1st match, tail dies of SIGPIPE and the pipeline returns an error DESPITE the match (the same trap as the Sunshine healthcheck). Anddu -shcon a glob that matches nothing ALREADY prints "0 total" and exits with an error, so the fallback's|| echo 0came out added to it, printing "0" twice on the line. MEASURED RESULT (04/08, the 17.4 GiB test file): it completed THROUGH TOR in 3h19m, at ~1.5 MB/s on average, well above the 709 KiB/s of the initial moment. And the quota NEVER hit, contrary to my prediction: Tor rotates circuits over hours (MaxCircuitDirtiness = 10 min for a new stream) and megadl opens a connection per chunk, so the traffic went out through several exit IPs with nobody asking. A side effect of Tor's design, not configuration from here, and that is why the prediction "17 GB anonymously will not happen" was wrong ON THIS PATH; on a fixed IP (direct or a single VPN) it holds. CHECKING THE FILE, and the order matters: (1) megadl already verifies MEGA's MAC and aborts with "MAC mismatch", so finishing with no error is CRYPTOGRAPHIC proof that the bytes are the server's, which is worth more than any file test afterwards; (2)fileplus the signature at offset 0; (3) the final 8 bytes, which in a complete RAR5 end in03 05 04 00(the HEAD_ENDARC header, type 5 = end of archive), which is what separates "truncated download" from "whole file". A p7zip TRAP:7z lsaidType = gzipand "There are data after the end of archive" on a PERFECT RAR v5. The nixpkgs p7zip comes withenableUnfree ? falseand the postFetch RIPS OUT the unRAR code, so without the codec it does not recognize the signature, scans the file and matches the first blob that looks like gzip. It almost became "the download got corrupted". To really test a RAR CRC:nix shell nixpkgs#unrar -c unrar t(unfree, and this repo's allowUnfree is already true). MEASURED THROUGHPUT (04/08): what was slow was TOR, not MEGA and not the line: Hetzner (USA): 1 stream 17.2 MB/s | 8 streams 42.2 | 16 streams 33.9 (worse) MEGA (gfs206n184): 1 stream 27.7 MB/s | 4 parallel ranges 53.5 (449 Mbps) Tor (the real download): ~1.5 MB/s The NIC is gigabit, so the ceiling is the plan (~450 Mbps). The 17.4 GiB that took 3h19m through Tor would come down in ~11 min on a direct stream, ~5.5 min with 4 ranges. AND THIS IS WHERE SPEED AND QUOTA OPPOSE EACH OTHER: direct is 18x faster and stops at the window's ~5 GB; Tor is slow and in practice unlimited (circuit rotation). There is no free "fast AND 17 GB": whoever wants both uses a Pro account, and only then do the parallel ranges start to pay off (11 min to 5.5). THAT IS WHY I DID NOT BUILD A PARALLEL CLIENT: the gain is 2x over sequential megadl on a file that already takes minutes, and it would cost the MEGA API plus AES-CTR per chunk plus the meta-MAC reimplemented by hand (megadl already verifies it for free), which is debt of ours on every MEGA protocol change. If it ever becomes worth it, megabasterd does multi-slot out of the box, but it is 948 MiB of closure (it drags in a JRE) as measured in the cache. TOR ONLY FOR A SMALL FILE: I measured 709 KiB/s on the circuit (3 volunteer hops), which would mean ~7h and 17 GiB of DONATED bandwidth for a single file; the Tor project discourages bulk (the network is dimensioned for low latency, not for throughput) and MEGA also blocks part of the exit nodes (an immediate and repeated failure means a blocked exit, not a bad link; a new circuit issystemctl restart tor).