<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>ix</title>
    <link>https://ix.dev/</link>
    <atom:link href="https://ix.dev/feed.xml" rel="self" type="application/rss+xml" />
    <description>The changelog of the base: what changed in the index, the open monorepo every ix VM boots from.</description>
    <language>en-us</language>
    <lastBuildDate>Fri, 31 Jul 2026 09:05:00 GMT</lastBuildDate>
    <item>
      <title>index is no longer a submodule: one repo, and nothing left to bump</title>
      <link>https://ix.dev/index-merged-into-ix</link>
      <guid isPermaLink="true">https://ix.dev/index-merged-into-ix</guid>
      <pubDate>Fri, 31 Jul 2026 09:05:00 GMT</pubDate>
      <description>index is no longer a submodule: one repo, and nothing left to bump. ix vendored index as a git submodule at ./index and consumed it as a flake input. Two pointers named the same code: the gitlink and the rev in flake.lock. Keeping them agreeing needed a bot, a rolling bump pull request, a watchdog, a repair-PR bot, a pre-commit guard, a pre-push guard and a fixture suite for the guards. That is gone. index/ is ordinary tracked files in ix, and its history came with it, so git log -- index/... works and git blame reaches back through the join. ## What you have to do Pull. git submodule commands against ix now do nothing, and a stale .git/modules/index is harmless. If a script of yours runs git submodule update --init against an ix checkout, delete the line rather than leaving a step that cannot fail. Nothing about building changes. flake.nix still says index.url = &quot;path:./index&quot;. ## Why the pointer was the whole problem A path: input to a submodule records the submodule&apos;s revision: A path: input to a plain tracked subdirectory does not: No rev, no narHash. There is nothing to move and nothing that can disagree, so the machinery that existed to move it has no job. Retired with it: submodule-lock-sync.yml and its repair bot, check-submodule-lock-sync.sh and its fixture suite, the escalate watchdog for index bumps, index&apos;s sync-ix-submodule.yml dispatch, and the repository_dispatch bridge job. The lock guard is worth a note. Once the rev is gone it selects on locked.type == &quot;path&quot; and locked ? rev, matches nothing, and prints flake.lock agrees with every gitlink pin on every run, having compared nothing. It was deleted rather than left wired to an empty set. If you write a successor, give it something external to check against: a consistency check between two values we control cannot catch a fault that moved both, which is how a flake.lock pinning a revision that does not exist in any repository passed the guard on its way to a push. ## The window this closes The bump loop&apos;s most recent pull requests took 10 and 22 hours against 3 to 8 minutes in early July. During one of those windows ix&apos;s gitlink sat 92 commits behind index main, so a merged index change had no path to a machine for most of a day. ## What is not done The public indexable-inc/index repository is still writable and still the development history. Turning it into a read-only projection of ix:index/ is a separate change. Until it lands, treat index as frozen: commit in ix.</description>
    </item>
    <item>
      <title>A cancelled apply used to brick the regional evaluator, and the resolve line never said so</title>
      <link>https://ix.dev/apply-resolve-narrates-itself</link>
      <guid isPermaLink="true">https://ix.dev/apply-resolve-narrates-itself</guid>
      <pubDate>Fri, 31 Jul 2026 02:45:00 GMT</pubDate>
      <description>A cancelled apply used to brick the regional evaluator, and the resolve line never said so. Pressing Ctrl-C during ix apply could leave the region&apos;s evaluator unusable until someone removed a file by hand. One did, on 2026-07-30 at 18:31 UTC, and every apply from that account failed for the next four hours with a Nix trace that blamed the operator&apos;s flake. The evaluator holds a warm Nix store on purpose, so whatever a cancelled run leaves behind outlives it. What it left behind was shallow.lock in the git cache, and a zombie nix process still unreaped 4h41m later. Nothing held the lock; the process that wrote it had died mid-fetch. Cancelling drops the host side of the stream and signals the guest nothing. GuestExecStream has no Drop, so the socket just closes; in the guest, handle_streaming propagated the failed write out through ?; and Child::drop neither kills nor reaps. The child ran on unsupervised until its own pipes broke, and git, killed mid-fetch, never removed its lock. StreamingChild now terminates and reaps on every exit path out of that function, signalling the process group rather than the child, because nix spawns git and signalling only nix leaves the fetch holding the lock. SIGTERM first with a grace period, then SIGKILL: the grace is load-bearing rather than polite, since git unlinks its locks from a handler and cannot from SIGKILL. The first version of that fix was wrong in a way worth repeating. Rust drops locals in reverse declaration order, so a guard declared before the pipe readers runs after them: the tree lost stdout and stderr first, took SIGPIPE, and died without running any handler. It reproduced the original defect through a different door while appearing to work, and only the regression test said so. The guard owns the pipes now, so the ordering is a property of the type. Ownership alone was still not enough, because the guard returned as soon as the direct child was reaped and the caller closes the pipes the moment it returns, so a grandchild mid-cleanup could still be cut off. It waits for the group to drain. 20 of 20 under nproc x 2 saturation, against 2 failures in 12 for the intermediate version. ## The resolve line was silent for the whole ninety seconds The same apply spent 90 seconds showing resolve and nothing else, then printed a 40-line trace whose actionable line was fifth from the top. That line had been on the wire since roughly second five. The evaluator already streamed both its phase and Nix&apos;s live stderr. ResolvePhase&apos;s doc comment says it exists &quot;so the client can render progress instead of one opaque pause&quot;, and EvalFailed keeps a second copy of the stderr tail specifically because a caller &quot;whose renderer discarded the live stream&quot; would otherwise see only an exit code. The renderer discarded the live stream. Both are now rendered, along with the source upload&apos;s byte counter, which was also being thrown away. ## The progress writer could smear across the terminal Resizing the window during an apply left one ghost row per width change. The writer moved to column 0 and cleared downward, which is an in-place redraw only while the cursor is still on the row the line was drawn on; a reflow moves that row, so the clear lands elsewhere and the old text becomes scrollback. ix apply now draws through the same transport ui/tree uses, which reaches rows above the cursor by arithmetic and needs no cursor-position query. Two candidates were measured and rejected. Ratatui&apos;s inline viewport does not ghost, but it emits ESC[2J on a horizontal shrink and its cursor query goes to stdout while the backend writes stderr, so ix apply &gt; log produced no progress output at all. A test built on vt100 or termwiz would have been useless here: both resize by truncating rather than rewrapping, and the broken writer passes under them. Two smaller defects went with it. Line widths were counted in characters rather than columns, so one CJK character or emoji in a VM name would produce the same smear with no resize involved. And below 15 columns the label and spinner were emitted at full width into a narrower terminal. ## What a stalled transfer looks like A transfer that stops moving is not silent. A dead github: download emits thousands of progress events, tenths of a second apart, all reporting the same byte count. The rate now shows 0 B/s rather than disappearing, because an absent rate renders identically to the first tick of a healthy transfer, and the line says no data for 41s once that has persisted, which is the one fact it cannot otherwise express. The threshold is five seconds, calibrated against a single cold nixpkgs fetch where the rate sat at zero for about four and a half. A slower afternoon will show no data for 6s mid-transfer with nothing wrong. ## Still open Nix&apos;s own fetch bytes do not reach the CLI yet, so a cold github: input is narrated in words rather than bytes. That is ix#9299, which parses Nix&apos;s structured activity stream on the node agent. Until it lands, the no-total byte display is code with a test and no producer.</description>
    </item>
    <item>
      <title>a 6.7 MB Rust symbol name filled the fleet ClickHouse and stopped every log query</title>
      <link>https://ix.dev/clickhouse-primary-index-symbols</link>
      <guid isPermaLink="true">https://ix.dev/clickhouse-primary-index-symbols</guid>
      <pubDate>Fri, 31 Jul 2026 01:00:00 GMT</pubDate>
      <description>a 6.7 MB Rust symbol name filled the fleet ClickHouse and stopped every log query. For several hours on 2026-07-30 the fleet ClickHouse on hil-compute-2 refused every log and metric query with code 241 MEMORY_LIMIT_EXCEEDED, while the host it runs on had 263 GiB of 503 GiB free. Nothing in the fleet was unhealthy. The instrument that watches the fleet was full. profiles.function_samples was holding 92.08 GiB of primary index resident in memory to index 2.16 GiB of data on disk, which is 94% of that server&apos;s entire 98.32 GiB budget. ## why one table&apos;s index outgrew the server function is a plain String holding demangled Rust symbols, and it was part of that table&apos;s primary key. ClickHouse keeps the primary index of every active part in RAM for as long as the part is active, storing each mark&apos;s full key, so every one of those symbols was resident. Rust writes the whole generic type into a symbol name, and a tracing_subscriber layer stack nests: each .with(...) wraps the entire previous type inside a new one, so the name grows by nesting rather than by adding. Measured across a 2,000,000-row sample of the live table: | function length | bytes | | --- | --- | | median | 28 | | p99 | 286 | | mean | 2,113 | | max | 6,712,193 | A mean 75x above the median is the signature of a few enormous values rather than broad growth. Adaptive granularity then compounded it: a mark ends after 8192 rows or 10 MiB of data, whichever comes first, so multi-megabyte rows produced a mark every few rows instead of every 8192, and each mark stored its full key. The rows most expensive to index were indexed most often. The comparison across tables is the argument. otel.otel_traces holds 3.51 TiB and 67.13 billion rows, 1,600x more data, and indexes it in 52.68 MiB. ## the part worth carrying away: the fix had already merged This exact fault was diagnosed and fixed 20 hours before it recurred. Commit aab17057e1 added PRIMARY KEY (service, profile_type, timestamp) to profiles-schema.sql, and its message quotes the same 19.86 GiB part and the same 48x inversion. It merged to main. It deployed: the generated config on the host carried the new line. It could never take effect. The statement is CREATE TABLE IF NOT EXISTS, the table existed, so the whole thing was skipped, and ClickHouse has no ALTER that changes a primary key. A correct, merged, deployed fix sat inert while the fault it fixed grew back worse, from 89.14 GiB over 84 parts to 92.08 GiB over 213. Nothing anywhere compared the schema a host declares against the table the host actually has. A drift gate that closes that gap is in progress. ## what changed The table was rebuilt so the committed key takes effect, keeping all 81.28 million rows. The order was swap-first: an empty fixed table was exchanged into the live name so inserts landed in it immediately, then the history was copied out of the old table, which by then had no writer. Copying a partition at a time failed on a single 6.59 GiB allocation, because opening a part loads that part&apos;s index and these were the pathological ones. One part at a time, unloading between each, held memory flat at about 10 GiB for all 638 parts. | | before | after | | --- | --- | --- | | resident primary index | 92.08 GiB | 830.53 KiB | | index as a share of the data it indexes | 4,262% | 0.06% | | MemoryTracking | 97.29 GiB | 8.66 GiB | Row counts could not verify that copy, and checking them is how a lossy one would have passed: SummingMergeTree collapses rows sharing a sorting key, so the new table holds 249,847 fewer rows than the old and is still complete. The measure columns are the invariant, since summing is associative, and all four came across whole plus the profiler&apos;s live writes during the copy. ix#9285 caps the demangled name at the producer, 1 KiB, which is 3.5x the measured p99. Truncation appends a stable digest of the full name rather than just cutting, because a plain prefix would collapse two different generic monsters into one string and SummingMergeTree would then silently inflate one row&apos;s counts with another&apos;s. ## what is still open The profiler is still emitting multi-megabyte names until ix#9285 lands; they no longer reach the resident index, but they still cost disk and merge time. LimitNOFILE=262144 on the ClickHouse unit was pinned during recovery and is currently raised only on the running process (ENG-11573). And system.metric_log is disabled on the fleet&apos;s own observability store, so none of the accumulation curve behind this incident can be reconstructed (ENG-11574).</description>
    </item>
    <item>
      <title>index is tracked inside ix, not vendored as a submodule</title>
      <link>https://ix.dev/index-tracked-in-ix</link>
      <guid isPermaLink="true">https://ix.dev/index-tracked-in-ix</guid>
      <pubDate>Fri, 31 Jul 2026 00:30:00 GMT</pubDate>
      <description>index is tracked inside ix, not vendored as a submodule. ix consumed index as a git submodule at ./index. It no longer does: index&apos;s tree and all 3,703 of its commits were rewritten under index/ with git filter-repo --to-subdirectory-filter and merged into ix, so index/ is ordinary tracked files and git log -- index/&lt;path&gt; returns that file&apos;s real history rather than one import commit. Clone plainly. --recurse-submodules and git submodule update --init --recursive do nothing now, and a fresh worktree no longer needs a second step before it can build. There is nothing left to bump. ix&apos;s flake still reads index.url = &quot;path:./index&quot;, but a relative path input to a tracked subdirectory locks as {&quot;path&quot;: &quot;./index&quot;, &quot;type&quot;: &quot;path&quot;} — no rev, no narHash. The gitlink and flake.lock could disagree about which index a commit meant; that state can no longer be expressed, so the guard that policed it, its fixture suite, its repair bot and its required check are all gone. Three cargo git dependencies became path dependencies. astlog-core, ix-vt and audio-dsp were fetched from github.com/indexable-inc/index over the network at pinned revs. They now resolve to index/packages/... in the checkout, which also removes their fixed-output fetch hashes — and with them a recurring hazard where a store path pasted into an index doc page poisoned the fetch and failed eval for every workflow that touched the flake. The public indexable-inc/index continues to exist; it becomes a read-only projection of index/ rather than the place the work happens.</description>
    </item>
    <item>
      <title>nix-dev-build: editing the nix fork without rebuilding the closure</title>
      <link>https://ix.dev/nix-dev-build</link>
      <guid isPermaLink="true">https://ix.dev/nix-dev-build</guid>
      <pubDate>Thu, 30 Jul 2026 03:50:00 GMT</pubDate>
      <description>nix-dev-build: editing the nix fork without rebuilding the closure. Four sessions in one night iterated on the nix evaluator&apos;s C++ through a whole-package nix build, which recompiles the modular C++ closure in a sandbox for every one-line edit. The tree is a meson project and the fork&apos;s own manual documents the ninja loop, so that work was avoidable the whole time. nix run .#nix-dev-build -- --checkout &lt;path&gt; is that loop as one command. It finds the checkout, configures meson if the build directory is absent, runs ninja, and names the binary it produced. Two independent runs on the same 18 core aarch64-darwin Mac, each phase timed alone with the dev shell entry outside the span. Entry is 0.4 to 0.9s per invocation on top. | step | run A, load 13 to 24 | run B, load 7 to 10 | | --- | --- | --- | | meson setup | 12s | 11.9s | | first full build, 332 targets | 93s | 51.3s | | rebuild after touching one file | 18s, void, see below | 7.1 to 8.9s | | no-op rebuild | 1s | 0.1s | Both runs are real. The one-file row is the one to be careful with, and it took three sessions and a disagreement to pin down, because two different effects were being confused for one. Which file you touch sets the cost. At a load held between 7 and 8: 7.9s, 7.2s and 7.1s over three runs touching src/libexpr/eval.cc, 8.9s on primops.cc, 2.1s on nixexpr.cc. A 4x spread with load constant, so a single figure needs its file named or it reads as a promise about somebody else&apos;s file. Call it 2 to 9s for a body edit in libexpr. Run B recorded its file; run A did not. The rebuild is serial, and contention doubles it, but load average will not tell you which you got. The rebuild needs exactly one core, so a jobs flag earns nothing on a single-file edit: ninja -j1 costs 7.03s and 7.34s against 6.36 to 6.80s at the default. Thirteen timings of the same eval.cc edit across an evening on a shared machine by two sessions, sorted by real over user: | real / user | real | reported 1 minute load | | --- | --- | --- | | 1.04 to 1.12, nine runs | 6.36 to 9.38s | 13.18 to 45.37 | | 1.49 to 1.62, four runs | 12.33 to 15.78s | 24.90 to 37.21 | The ratio separates fast from slow exactly. Load average does not, and the two ranges overlap almost completely: the fastest run of the night sat at a reported load of 39.24, a 15.78s run sat at 24.90, and three runs at a reported load of 42 to 45 came in at 6.77 to 7.55s. That is the instrument, not the machine. Load average is a decaying one minute mean and this rebuild is a seven second event, so it reports the minute that just ended and stays inflated for a minute after a burst of other work has finished. So report real and user together rather than load. And discard the first run only after an idle gap, which is narrower than the blanket rule an earlier revision of this entry gave: the 34% penalty, 9.06s against 6.77s steady, was measured on a first run after a gap, while three consecutive runs taken immediately after building showed no penalty at all, and a third set taken while building intermittently showed 10%. This entry twice claimed something stronger and both claims are withdrawn. First that the one-file spread was file size and not load, then that load explained it with a threshold at the core count. The file spread is real, measured back to back at constant conditions. The contention effect is real, and real over user is its signal. Reported load average predicts neither, which is why run A&apos;s rebuild row above is marked void rather than reinterpreted: it was taken above the load gate its own author was enforcing, its file was eval.cc like everyone else&apos;s, and no user time was recorded, so nothing in it can be classified. user inflates when contended too, 6.10s to 9.76s, so the same compile costs more processor time and not only more wall clock. Why is open, and the two obvious candidates turn out not to be independent on this part. hw.nperflevels is 2: perflevel0 is 6 cores named Super with 128K L1d and 16M L2, perflevel1 is 12 named Performance with 64K L1d and 8M L2, and there is no efficiency tier at all. A compile displaced to the lower tier therefore loses half its L1d and half its L2 as a consequence of moving, so displacement and cache pressure are the same explanation rather than competing ones. They separate only for the narrower case where the compile stays on Super and other processes evict its lines. Nothing here measures that the cache delta causes the 1.59x. Forcing the lowest tier with taskpolicy -b costs 2.5x in user, which is an upper bound rather than a measurement, because that flag drops scheduling priority as well as tier. Isolating it means pinning to perflevel0 specifically, not changing quality of service, under contention, on a machine somebody has claimed. A body-only edit runs 3 of 10 edges and does not relink src/nix/nix at all, because the dylib symbol file is unchanged and the edit arrives through dynamic linking. The dependencies are not vendored here. Every run re-execs itself under nix develop &lt;checkout&gt;#default, so the compiler, meson, ninja and the libraries nix links against come from the checkout&apos;s own flake, and entering it costs 0.41s warm. The flake reference is a bare path rather than path:&lt;checkout&gt;, which matters more than it looks: a path: reference copies the whole directory into the store on every invocation, and in a checkout that has been built once that directory contains a 2.0G build/. A bare path resolves to git+file://, so .gitignore applies and the store copy is 17M. Anything scripting this loop by hand hits a trap the manual does not mention. configurePhase and buildPhase are stdenv shell functions, and under nix develop --command bash -c they are not defined: type -t prints nothing for both and the run dies with configurePhase: command not found. Drive meson setup and ninja directly instead, which is what this tool and packages/nix-ninja-build both do. ## Which binary you built, and why --version will not tell you The report always names the absolute path, the checkout&apos;s revision, and the nix already on PATH: The version strings are the point. Meson takes the version from .version, so a checkout build of any branch prints a bare nix (Nix) 2.34.7, while the packaged nix-ix stamps its revision in. The build is therefore the one that looks stock, which is the opposite of the intuition, and a test like &quot;does --version contain +ix&quot; identifies the system nix rather than yours. Two builds of two different branches are indistinguishable by version string altogether. So the path and the revision are the identification, and both binaries are printed side by side so the difference cannot be misread. The revision is reported rather than compiled in on purpose. nix-ninja-build writes the version into .version in its scratch copy, which is the right answer there; doing it to a developer&apos;s checkout would modify a tracked file in their working tree. ## What it refuses, and what it does not do There is no fallback to nix build. A ninja failure means the tree does not compile. A checkout that is not a nix source tree is refused, naming each marker it looked for and whether it was found. A build directory configured against a different checkout, or left broken by a failed configure, is refused rather than built through, since ninja&apos;s output in that case is confusing rather than wrong. packages/nix-ninja-build remains the other incremental lane and is not replaced. It materializes the packaged source into a scratch directory and turns each compilation unit into its own content-addressed derivation, on x86_64-linux only. This one builds the checkout you are editing, with local ninja, and is the only lane that works on aarch64-darwin. ## The ccache advice in the fork manual does not hold on this tree The manual says to use ccacheStdenv to &quot;drastically improve rebuild time&quot;, and --shell native-ccacheStdenv reaches it. On this tree it very nearly does nothing, and the obvious check for why is the wrong one: ccache is not on PATH in either shell, so its absence proves nothing either way. The compiler is the tell, and it does route through ccache, clang++ resolving to ccache-links-wrapper-4.12.1 against clang-wrapper-21.1.7 in the default shell. ccache then declines almost every call. A full 332 target build through that shell recorded 17 cacheable calls out of 330, 5.15%, all misses on a cold cache, with 284 of the 313 uncacheable ones reported as Could not use precompiled header. The tree compiles with a precompiled header, and ccache will not cache such a compilation unless sloppiness includes pch_defines,time_macros. Setting that, or dropping the precompiled header, is what would make the advice true. Neither is done here. That also means the 34.3s first build measured through the ccache shell is not a caching win. The cache was cold with zero hits; the difference from 51.3s is machine load, 7 against 10 rising to 95. Stated plainly: the tool has only been run on aarch64-darwin. The Linux side is covered by the packaged build and the repo&apos;s checks, not by execution. A prompt rule pointing future sessions at this tool is proposed separately and is waiting on a human read, since it changes what every session follows.</description>
    </item>
    <item>
      <title>the flake bump commit now names every input it moved, evaluator pin included</title>
      <link>https://ix.dev/flake-bump-names-moved-inputs</link>
      <guid isPermaLink="true">https://ix.dev/flake-bump-names-moved-inputs</guid>
      <pubDate>Thu, 30 Jul 2026 03:30:00 GMT</pubDate>
      <description>the flake bump commit now names every input it moved, evaluator pin included. ix&apos;s automated relock moved more than its commit message admitted. Three real examples, all authored by github-actions[bot], all with a subject naming only the submodule: index/nix-src is indexable-inc/nix, the evaluator every host in the fleet is built with. It advanced on its own, inside a commit whose message did not mention it, and then deployed. The reusable update-flake-lock.yml now renders the commit message and the pull request body from the lock itself, so the same commit would have read: This is reporting only. Which inputs move, when they move, and what gets deployed are all unchanged. ## the input is two levels down, which decides the whole design nix-src is not one of ix&apos;s root inputs. It is an input of the index flake, so ix inherits it, and relocking the index gitlink drags it along. A report restricted to a lock&apos;s root inputs, which is what the neighbouring escalation watcher does deliberately to keep its alerts short, names nothing at all for these three commits. So inputs are named by their path from the root, index/nix-src rather than nix-src, walked breadth first over the lock graph. Keying on the lock&apos;s own node names would have been the easier read and is wrong twice over: nixpkgs_2 style suffixes renumber whenever the graph changes, which reports a renumbering as a move, and a genuine move hides behind the rename. Diffing the lock as text is wrong the same way, in the other direction: a reformat claims every input moved. ## an empty report is a sentence, not an empty section A bump that moves a gitlink whose lock node already records the new revision moves no input at all. That case prints because a report whose quiet state is an absence is satisfied by a report that is simply broken. packages/submodule-sync-test pins both halves against the real workflow step: one case asserts the message lists exactly the moves that happened, one line each, and one drives the no-move path and asserts the sentence. Deleting the report from the step fails the first with listed: []; making the reporter fall silent on zero moves fails the second. Both were watched failing before either was trusted.</description>
    </item>
    <item>
      <title>Agent memory moves into the repo: .memories/ files, ranked BM25 recall, nothing injected unasked</title>
      <link>https://ix.dev/memories-repo-local</link>
      <guid isPermaLink="true">https://ix.dev/memories-repo-local</guid>
      <pubDate>Thu, 30 Jul 2026 03:20:00 GMT</pubDate>
      <description>Agent memory moves into the repo: .memories/ files, ranked BM25 recall, nothing injected unasked. Every repo can now carry a .memories/ directory: one fact per markdown file, YAML frontmatter, found by reading the directory. Memories.search(&quot;query&quot;) in the kernel returns them ranked, alongside the directories it actually read. The prompt rule this replaces had been pointing at a subsystem that was switched off. It told every session to maintain a MEMORY.md index inside a directory CLAUDE_CODE_DISABLE_AUTO_MEMORY=1 had disabled, so the instruction named a path the harness no longer loads while the real protocol arrived separately through a digest. Three things are derived rather than stored, because every hand-maintained index in this setup was wrong when checked and each was correct when written: MEMORY.md listed a file that does not exist, ix/docs/index.md routed changes through a worker deleted seventeen days earlier, and the prompt rule above. The two systems that never broke, lib/skills.nix and agent-context, are the two that derive from the filesystem. So weight comes from the validation log rather than a number somebody typed. Only validated: [{at, by, how, ok}] is written after birth, and how is the load-bearing field: a claim nobody can re-run is a date with a sentence attached. A failed re-check is recorded as ok: false rather than dropped, so the next reader learns the thing stopped being true instead of paying the cost again. Staleness comes from based_on: [{path, blake3}] compared at read time, which is basis_changed from the weave prelude applied to files: Nix derivation-input semantics, so no crawler and no TTL. And the reverse of related: is computed on read, so it cannot drift. There is no index on disk. file_search::EphemeralSearch builds one in RAM per query, 52ms over 228 documents, which deletes the class of bug where a stale index answers a question about a changed file. This release extends file-search with a multi-field ephemeral index so tldr and handle can be boosted honestly instead of by repeating text, which would inflate document length and discount every other term. Nothing is injected at session start. That is measured rather than preferred. A 2026-06-12 study on this fleet (14 agents, live A/B) found deliberate prior-search paying four to eight times over: 53k injected tokens against 220-400k tokens of avoided rediscovery across ten recurring task types. Ambient injection into ordinary prompts was net-negative in the same study, with three of five casual prompts pulling 0.3-9k tokens of noise, breaking even only when score-gated at 0.70 with a token cap. So memories reach a model exactly one way, by being searched for. memory-secret is the one lint rule with an incident behind it. A live lin_api_* key once reached over two hundred indexed chunks here, and validated.how holds a command line, which is exactly that shape, except a memory is committed on purpose. It reuses the redaction table at packages/source/meta/src/sanitize.rs rather than a second copy. The 150-file directory budget, by contrast, is not evidenced, and CONTRACT.md says so: the only study on this fleet is silent on corpus size. It stays as a forcing function for consolidation, not as a measurement. The companion change converts 228 ix/docs pages into the same format, so one search covers reference pages and situational notes alike, and removes the playbook docs renderer. docs/ keeps only raw artifacts: vendor datasheets and profiling figures. One result worth reporting because it is the weak half. A query made of exact tokens is decisive: ipmitool sol activate host hang returns its page at 34.76 against 12.40 for the runner-up. A prose query over the converted docs is not: three near-ties, two of them false matches where &quot;collection&quot; stems to &quot;collect&quot;. Hand-written tldr fields do much better than converted ones, which suggests a corpus property rather than a ranking defect, and it names the condition under which an embeddings lane would earn its keep.</description>
    </item>
    <item>
      <title>A default.ix can declare VM templates and pin instances of them</title>
      <link>https://ix.dev/vm-templates-instances</link>
      <guid isPermaLink="true">https://ix.dev/vm-templates-instances</guid>
      <pubDate>Wed, 29 Jul 2026 21:10:00 GMT</pubDate>
      <description>A default.ix can declare VM templates and pin instances of them. A default.ix can now export templates, each one a function from params to what index.lib.mkVm returns, plus an instances block that pins instances of them in the repo. index.lib.templates.renderConfig renders every declared instance through its template and merges the result with the config&apos;s named VMs, so a plain ix apply creates them and cannot tell which VM came from where. worker@1 is the instance name, systemd&apos;s spelling; worker-1 is the VM. They differ because they must: @ is not legal in a networking.hostName and introduces a digest in an OCI reference. The node name is derived once and injected as the name param, so a template body is index.lib.mkVm({ name, ... }) and one that names its VM anything else is refused with the reason. Params are unchecked at the boundary in this version — nix still refuses an unexpected param by name, and the module system types everything a param reaches. The JSON Schema generator that ix new worker --help will render from shipped in index#4450, but it reaches a template&apos;s params only when they are one named parameter rather than a destructured one; RFC 0042&apos;s Typed params section records that gap and index#4451 covers deep checking. The imperative half of RFC 0042 (ix new, a server-side instance record, and apply re-rendering recorded instances) is not built; a config exporting neither key evaluates exactly as it did before.</description>
    </item>
    <item>
      <title>a .ix module&apos;s type annotations now generate a JSON Schema</title>
      <link>https://ix.dev/ix-ty-json-schema</link>
      <guid isPermaLink="true">https://ix.dev/ix-ty-json-schema</guid>
      <pubDate>Wed, 29 Jul 2026 19:00:00 GMT</pubDate>
      <description>a .ix module&apos;s type annotations now generate a JSON Schema. The ix2nix-wasm plugin has a second export. builtins.wasm { function = &quot;schema&quot;; } takes .ix source and returns a JSON Schema (draft 2020-12) for the module&apos;s parameters, generated from the same TypeScript annotations that already lower to eval-time __ixTy checks. The ix2nix binary grew a --schema flag over the same code path. ## why a checker could not answer this packages/ix2nix/src/ty.rs used to walk an annotation straight onto a __ixTy.&lt;checker&gt; expression and drop the type. A checker is a Nix function, so nothing downstream could ask a type what it accepts. That single missing property is what blocked three separate things at once: a JSON Schema for params.json completion, ix new &lt;template&gt; --help text, and any ambient .d.ts export. An annotation now parses once into ix2nix::ty::Ty -- plain data -- and lowers twice, from the same value produced by the same pass: - src/checker.rs emits the __ixTy expression. - src/schema.rs emits the schema. Every .ix file in the tree converts to the same bytes it did before, checked by running both converters over all 55 of them. That is a narrower claim than &quot;byte-identical&quot;, which is false: five classes of input behave differently, and none of them appears in the tree. Four are now rejected that used to convert -- readonly on a property signature, a duplicate field in an object type, a float literal that overflows to infinity (it emitted inf.0, which is not Nix), and a destructured field whose ? disagrees with its Nix default. One is now accepted that used to be rejected: a destructured field&apos;s type may use a string-literal key, ({ a }: { &quot;a&quot;: int }). One traversal, two outputs. A separate pass over the annotations for the schema could have described a type the emitted checks do not enforce, which is the one failure a generated schema exists to rule out. ## what the schema says The root constrains the argument of export default. For packages/ix2nix/tests/golden/typed-curried.ix that is a bare $ref: Every type alias lands in $defs, reachable or not, so a named type stays named. A module whose default export is not an annotated function gets a root with only $schema on it: an empty schema says &quot;nothing is known about this argument&quot;, which is the truth, and is not the same claim as &quot;this module takes nothing&quot;. ## the schema describes the annotation, not the checker ix-ty.nix stops at weak head normal form: listOf string checks &quot;is a list&quot; and never looks at an element, and attrs reads required field names and never checks a field&apos;s type. The schema does not copy that limit. items, additionalProperties, and properties all constrain things the checker never inspects, so the published schema is stricter than the check that runs -- deliberately, because string[] does mean a list of strings, and a schema that dropped items to match the checker&apos;s reach would describe the gap instead of the type. Closing that gap is #4451. The line the schema does not cross is asserting something the annotation never said. That is why an object type stays open rather than gaining additionalProperties: false: a TypeScript object type is a lower bound, not a closed record, and attrs agrees, so closing it would be an invention rather than a tightening. Strictness the annotation stated is fine; strictness it never stated is not. Those are the same principle, not opposite calls. ## four places the mapping is lossy Each is commented at the decision in src/schema.rs, because the failure mode of guessing is silent -- a schema wider than the eval-time check green-lights a params file that then fails at eval, and a narrower one rejects data the module would have taken. - float widens to number, which is looser. JSON has one number type and draft 2020-12 counts 2.0 as an integer, so no schema can say &quot;float, not int&quot; the way builtins.typeOf does. The strict spelling (&quot;not&quot;: {&quot;type&quot;: &quot;integer&quot;}) would reject 2.0, a perfectly good Nix float. A consumer gets no editor warning that 1 is not a float and learns it from the check instead. - path becomes {&quot;type&quot;: &quot;string&quot;, &quot;pattern&quot;: &quot;^/&quot;}. A Nix path value cannot appear in a JSON document, so the only spelling left is the other branch ix-ty.nix&apos;s path accepts. Relative paths and store-path string context are gone, neither of which survives JSON anyway. - drv and function types validate against nothing. Both emit {&quot;not&quot;: {}} plus a description saying why. {&quot;not&quot;: {}} is the boolean schema false with room for a reason, and the person who needs the reason is the one staring at their editor&apos;s error on the field. A drv-typed field cannot be filled from a params file at all -- only from Nix. - Object schemas stay open, for the reason in the section above: the annotation never said the record was closed. ## the ? and the Nix default are one fact A destructured parameter has two places to say a field is optional, and only one of them reaches Nix. ({ a, b }: { a: int; b?: string }) renders { a, b }:, so Nix demands b however the annotation marked it -- while the schema, reading the ?, reported b optional. A params.json omitting b validated and then died at eval with called without required argument &apos;b&apos;: exactly the failure the schema exists to prevent, shipped inside the change that claimed to prevent it. Caught in review of #4450, fixed in #4459. Both halves are now required to agree, in both directions, and the converter says which one is missing: Rejecting beats picking a winner. Deriving required from the pattern instead would leave the ? decorative, and it still lies in the mirror spelling: a pattern default under a non-optional annotation lets Nix bind the default and then fails the field&apos;s own check on it. With the two required to agree, required follows from the annotation by construction. The cost is one spelling: { a, b }: { a: int; b?: string } no longer converts, and the meaning it was reaching for -- always passed, possibly null -- is b: string | null, which says so exactly and always worked. ## what is still missing Deep checking, per the strictness note above: a params file that passes eval can still carry hosts = [ 1 2 ] against a string[] annotation. #4451. A --help renderer still cannot reach a template&apos;s parameters, because ModuleTypes.parameters describes the export default arrow only, and a template is a function nested in the attrset that arrow returns. #4453. ## smaller things in the same change A parenthesized default export used to lose its signature: export default ((a: int) =&gt; a) recorded no parameters, so its schema came out empty. Found by the new property test, which walks every $ref in a generated schema and requires it to resolve into $defs. Duplicate fields in an object type ({ a: int; a: string }) are now rejected. They had no sensible checker -- the runtime would demand a twice -- and no sensible schema, since JSON object keys are unique so one silently wins. A destructured parameter&apos;s field types may now use string-literal keys (({ a }: { &quot;a&quot;: int })), which used to be an error. The restriction existed only because the old code compared AST key nodes; comparing names is both simpler and correct.</description>
    </item>
    <item>
      <title>the CVE ratchet stops running on pull requests</title>
      <link>https://ix.dev/cve-pr-gate-disabled</link>
      <guid isPermaLink="true">https://ix.dev/cve-pr-gate-disabled</guid>
      <pubDate>Wed, 29 Jul 2026 16:00:00 GMT</pubDate>
      <description>the CVE ratchet stops running on pull requests. The base-versus-head CVE evidence ratchet is off for pull requests as of today. A full run measured 2465 s on an idle 32-core box with a warm store, and 275 s of that -- 11% -- was scanning. The rest was the harness rediscovering what it already knew: 1053 s re-evaluating an unchanged base tree from scratch, 830 s walking 199 roots across a 191,793-path closure, 282 s rebuilding a nixpkgs catalog it should have substituted. Nothing was built, nothing was downloaded, the runner queued in 6 s, and both halves emitted byte-identical results. Only the ratchet step and its evidence upload are off, behind a single CVE_PR_GATE value in cve-scan.yml. The scanner regression corpus still builds on every trigger and is still reached through the required flake-check, the nightly scan of main is untouched, and pushes to main are unaffected because the ratchet never ran on them. What is genuinely gone is pre-merge protection: nothing now blocks a pull request that introduces a runtime CVE, regresses coverage, or adds an unscanned security root. Detection moves to the nightly scan, one day late and after the merge -- and that scan is red today, so the practical coverage until #3834 is fixed is zero rather than one day. Three fixes were tried first and one of them works. #4378 adds phase timings and drv-path addressing, 68.249 s to 0.851 s over all 199 roots with byte-identical output; it is open and green but not yet merged, so main still has none of it. #4369 was parked because its premise was false: cache.ix.dev does not serve the nixpkgs catalog, so the change made the gate build a recursive-nix full-nixpkgs derivation instead of substituting it, measuring 2523 s against a 2448 s baseline. A 16-worker nix-eval-jobs evaluator was declined outright -- it has no outputName, so a root selecting dev would be scanned as out, trading a slow gate for a silently incomplete one. The disabled state is not left to a comment nobody opens. A new watchdog job reads the switch out of cve-scan.yml every hour and keeps a tracking issue open for exactly as long as it says disabled; closing that issue by hand does not work, and flipping the switch back closes it automatically. The one lesson worth carrying: this job has never emitted a phase timing, which is why answering &quot;why is this slow&quot; took a day of measuring it from outside and why 77% of its largest phase is still unexplained. #4378 finally adds them, and merging it is the first step to turning the gate back on.</description>
    </item>
    <item>
      <title>nix-debugger skill: find what actually caused the rebuild</title>
      <link>https://ix.dev/nix-debugger-skill</link>
      <guid isPermaLink="true">https://ix.dev/nix-debugger-skill</guid>
      <pubDate>Wed, 29 Jul 2026 16:00:00 GMT</pubDate>
      <description>nix-debugger skill: find what actually caused the rebuild. Agents now carry a house skill for the question &quot;why did this rebuild&quot;. It collects the tools that answer it from data that already exists: nix-dag scores a build plan from evaluation alone and ranks the nodes whose dependents reach them only through an environment variable holding a store path, whence names the .nix line that deployed a file or installed a package with zero eval, nix log &lt;drv&gt; shows the whole build log rather than the fixed tail nix prints on failure, and nix store builds --json lists every in-flight daemon build machine-wide. The worked example is a systemd unit that keeps restarting. On hil-compute-2, the cas fabric server unit changed in 34 of the 59 most recent consecutive generation pairs, and 29 of those 34 were byte identical once the store hashes were normalized away. A pure store-path bump and a real config change look the same in diff and take completely different fixes, so the skill makes that cut first. The nixPlanShape prompt rule points at the skill for the rest of that loop.</description>
    </item>
    <item>
      <title>code review moves to Gerrit, because jj and Gerrit agree about what a review is</title>
      <link>https://ix.dev/gerrit-review-service</link>
      <guid isPermaLink="true">https://ix.dev/gerrit-review-service</guid>
      <pubDate>Wed, 29 Jul 2026 14:15:00 GMT</pubDate>
      <description>code review moves to Gerrit, because jj and Gerrit agree about what a review is. There is now a Gerrit Code Review server in the fleet, at review.ix.dev, reachable only over the tailnet. Forgejo is gone. Neither of those changes what gates a merge today: GitHub Actions is still the merge gate, and Gerrit is off on every host until the loop has been proven on real changes. ## Why Gerrit rather than another forge The two models disagree about what a review is. GitHub reviews a branch, so a fix during review is another commit and the branch accumulates &quot;address review comment&quot; noise. Gerrit reviews a commit, and a corrected version of that commit is filed as patch set 2 of the same change. jj works the same way. A jj change carries an identity that survives being rewritten, so amending it leaves it the same change. That is the same idea as Gerrit&apos;s Change-Id, which is why jj gerrit upload exists. On GitHub the equivalent rewrite is a force-push, and the review thread loses its anchor. Checked rather than assumed, on hydra: That command arrived in jj 0.34.0 on 2025-10-01 marked experimental, and no release between then and 0.43.0 declares it stable. It works by synthesizing a Gerrit Change-Id footer from the jj change id at push time. Gerrit reading jj&apos;s native change-id git header is designed in a Gerrit design doc and not shipped; that doc&apos;s own status line says no implementation is planned yet. So the footer stays load-bearing, and nothing here depends on unshipped work. ## CI parity turned out to be structural The risk in this was CI. A second review surface usually means a second, hand-mirrored job list that drifts from the first. It does not here, because this repository does not define its checks in YAML. Every required check is aggregated into one Nix package, .#required-ci-checks, with the lint set as .#ci-lint-checks. The GitHub workflow is a wrapper that shells out to a script that builds them. So a Gerrit-side runner builds the identical derivation. Add a check and both surfaces get it, because there is only one gate. Two of the gate&apos;s own scripts turned out to be forge-agnostic already: check-merge-integrity.sh takes a plain BASE HEAD pair of shas and is called both from CI and from a local pre-push hook, and check-vcfs-freeze.sh accepts a synthetic file list on stdin. Both run against a Gerrit patch set unmodified. ## Not stream-events, and not hooks either For learning about a new patch set, the reconciler polls Gerrit&apos;s Changes REST API and reconciles against the desired state, which is that every patch set carries a Verified vote. ssh gerrit stream-events was the obvious choice and is the wrong one: it has no replay, so a dropped connection loses everything that fired while it was down. Wikimedia phabricator T419009 (2026-03-04) records Zuul losing its stream, queuing 1393 events, and permanently losing a couple of thousand, requiring patches to be rechecked by hand. Server-side hooks look like the fix, since they cannot lose a subscription. They also fire exactly once with no retry, so a hook whose target is down silently never verifies that patch set. Prow, Kubernetes&apos; Gerrit integration, uses neither and says so in its docs; it polls with a saved watermark. Polling turns &quot;deliver every event&quot; into &quot;make the world match the desired state&quot;, which self-heals across any downtime and needs no extra Gerrit plugin. The cost is latency between upload and the check starting, bounded by the poll interval. The checks plugin, which most Gerrit CI tutorials still recommend, was discontinued by the Gerrit team at Google. What survives is a client-side JavaScript UI that fetches from your own CI. For a small instance that is strictly more work than voting on a label. ## Authentication is a header, so the header has no default Gerrit&apos;s auth.type = HTTP takes the username from an HTTP header and trusts it completely. There was no shared identity layer for tailnet services here to plug into: Grafana runs anonymous-admin, and Forgejo did its own GitHub OAuth login. So services.ix.gerrit.auth.identityHeader ships with no default, and enabling Gerrit without an identity source fails at eval naming the option. The module system enforces that rather than a hand-written assertion, so there is no null to coerce and no path that skips it. Proven by breaking it: The producer is a new loopback service, ix-tailnet-identity. It answers nginx auth_request subrequests by asking the local tailscaled who the connecting peer is, and returns either the username or 401 with nothing. It fails closed: no fallback identity, no &quot;unknown&quot; account. auth.httpTrustedProxyNetworks is set to loopback, because a header identity is spoofable by anything that can reach Gerrit&apos;s port without passing through nginx, and upstream added that setting for exactly this. nginx also blanks the header inbound, so a client cannot supply its own. Two details that would have been wrong if guessed at, and were instead read off a live host: - The identity is the local part of the tailnet login name, for one domain only. Passing the whole login name would have given Gerrit andrew@ix.dev@ix.dev as an account email. Restricting to one domain is what makes the local part unique, and the cost is that a member signing in through another domain cannot authenticate at all. - In this tailnet, a tagged node&apos;s login name is its own MagicDNS name, which reads like a perfectly plausible person. The resolver branches on the node&apos;s tags to refuse it. ## Forgejo was already off The removal is 5436 lines across 64 files: the module, Woodpecker, the Rust reconciler, the custom themes, the port claims, the DNS names, a golden-value check, and the docs that named them. It cost nothing to remove because it was already inert. server.enable defaulted to false with a &quot;Decommissioned on the compute fleet&quot; comment, and no host overrode it. A 1200-line module was imported onto every host and did nothing on any of them. Two dependency chains went with it, checked rather than assumed: the pierrejo flake input and the services.pierre-ssr service, because Forgejo&apos;s module was the only consumer of each. The freed ports, 3006, 2222, 3008 and 3009, are left unassigned with a comment saying what they used to be. Handing one of them to Gerrit in the same change would be a trap for anything holding a stale reference. ## Two things this found that were not Gerrit&apos;s fault A false green in the CI gate. The required-CI scope map sends everything under nix/modules/ to the Nix-config class. Five cargo workspace members live under nix/modules/, so a Rust-only change to any of them classified as a Nix-config change and skipped the Rust lane entirely. The gate then reported green having never compiled the code in front of it. ENG-11247, being fixed by deriving the Rust class from the cargo workspace members rather than a hand-written path list. A DNS mirror that had already drifted. The set of tailnet-only hostnames is written down twice by hand, in the Cloudflare zone and in the fleet&apos;s own authoritative DNS, with no check that the copies agree. The Gerrit change added review.ix.dev to the first and not the second, so the name resolved from outside the fleet and returned nothing inside it. Lint passed, all 12 node toplevels evaluated cleanly, and all three required checks went green. It was found because an agent reviewing a different pull request noticed by eye, which is a coincidence and not a control. They had drifted before that: otel.ix.dev is in the Cloudflare list only and kpi.ix.dev in the internal one only. Whether that asymmetry is deliberate is an open question, and it blocks the real fix, which is one list both consumers derive from. ENG-11249. ## What is not done - Nothing has been deployed. All of this is eval and lint. Gerrit would be this fleet&apos;s first JVM service, so its memory behaviour under the cgroup envelope is an estimate; the heap option says so in its own description. A dev-node deploy is the next step before any enable lands. - No backups. With NoteDb, Gerrit&apos;s review history and its repository data are the same blob, so losing one loses both, and nothing ships it off-host. Not urgent while the service is off everywhere; urgent the moment it is enabled. ENG-11245. - The identity resolver has never run against a real tailscaled. Its protocol facts came from a live host, but the service itself has not. - No replication to GitHub, and GitHub remains canonical. ## One argument that appeared along the way This repository&apos;s list of required checks does not exist in this repository. It is a GitHub ruleset configured outside the tree, with no artifact anyone can review, and there is already a workaround in the tree built because it cannot be edited from here. We hit it directly: gh pr merge --admin is refused on green local checks with &quot;3 of 3 required status checks are expected&quot;. Gerrit&apos;s equivalent is a submit-requirement on the refs/meta/config branch. In git, in a diff, reviewable, revertible. That is a better property than the jj ergonomics that started this, and it holds regardless of which client anyone uses. Tracked as ENG-11215.</description>
    </item>
    <item>
      <title>we removed CVE scanning, which had not scanned anything since 10 July</title>
      <link>https://ix.dev/cve-scanning-removed</link>
      <guid isPermaLink="true">https://ix.dev/cve-scanning-removed</guid>
      <pubDate>Wed, 29 Jul 2026 12:30:00 GMT</pubDate>
      <description>we removed CVE scanning, which had not scanned anything since 10 July. This repository no longer has vulnerability scanning. That is a decision, taken today, and it is worth being exact about what it changes: nothing about our actual coverage. There has been no CVE scanning here since 2026-07-10. What changes is that the absence is now visible and deliberate instead of looking like a system that works. ## Nineteen days of nothing The nightly Nix runtime CVE scan replaced vulnix on 2026-07-10 in #2605. It has never once completed. Every scheduled run since died at step 5 of 21, Export security roots and runtime graph, so Run live scan and everything after it was skipped. Not intermittently -- every run, from the first. The cause was one line. identity-cache-key.sh takes six arguments and cve-scan.yml passed five, so the script exited on ${6:?usage: ...}. The entire diagnostic, in a 544-line log, is: ${VAR:?message} names the parameter, not the variable, so the message reads line 8: 6: and never says what is missing in terms anyone would search for. gh run view --log-failed does not include the line at all. Underneath that, a second and unrelated hole: the schedule trigger did not fire at all between 07-13 and 07-24. Twelve days, zero scheduled runs, while the same workflow ran 115 times on push and pull-request triggers. The cron was unchanged and the workflow was state=active. That defect belongs to scheduled workflows generally rather than to this scanner, and removing the scanner does not fix it; it stays open as ENG-11174. ## The fix existed and we are not landing it The missing argument was found, fixed and verified. A dispatched run got past step 5 for the first time since July. We are removing the scan anyway. The reasoning is about what the thing was actually delivering. Nineteen days of never having worked, with a twelve-day hole in the trigger underneath it and a pull-request ratchet already switched off for costing 2,465 s per PR, is not a system with a bug in it. Repairing it incrementally would have restored a pipeline that generates noise and a watchdog that has filed the same issue every hour for eight days. ## Why the watchdog went too The watchdog worked perfectly. It opened the tracking issue on 2026-07-21 and updated it every hour after, and it is the only reason anyone knew. It is also the reason we can say &quot;nineteen days&quot; with confidence rather than &quot;recently&quot;. It still had to go, because it inspects only event=schedule runs of a workflow that no longer exists. With nothing scheduled it would either go permanently red or permanently green, and both are lies. A watchdog watching a thing that is gone is the &quot;reports fine, means nothing&quot; shape that made the original outage invisible, pointed at itself. ## What bringing it back would require The scanner package stays in the tree. packages/cve-scan still builds, its tests still run under flake-check, and nix run .#cve-scan still works. What was deleted is the automation, not the tool. Restoring it needs: 1. cve-scan.yml, from git history, with the sixth argument to identity-cache-key.sh supplied. 2. An answer to ENG-11174 first, or the nightly can silently stop again. 3. The callsite guard in ENG-11179, which is the one thing that would have caught this class. It is not shipped today because with the workflow gone there is no callsite left for it to check, and a check over an empty set is the problem, not the solution. 4. A decision on the pull-request ratchet, which is a cost question (ENG-11122), not a correctness one. A repository with no scanner and a note saying so is in a better position than one with a scanner that has never run.</description>
    </item>
    <item>
      <title>channels were loaded but muted; the managed layer now says yes</title>
      <link>https://ix.dev/claude-channels-enabled-by-default</link>
      <guid isPermaLink="true">https://ix.dev/claude-channels-enabled-by-default</guid>
      <pubDate>Wed, 29 Jul 2026 04:30:00 GMT</pubDate>
      <description>channels were loaded but muted; the managed layer now says yes. Our Claude Code wrapper has baked --dangerously-load-development-channels server:index for a while, so the kernel&apos;s notify(...) and its interactive-resource actions had a live transport into a running session. They were not arriving. Loading a channel and being allowed to push over it are two different decisions, and only the first one was made. The second is a managed-settings key, channelsEnabled, and its default is the trap. Read the gate in 2.1.220 and it blocks on policySettings !== null &amp;&amp; policySettings.channelsEnabled !== true; the key&apos;s own schema text says &quot;Console: default on unless managed settings exist.&quot; Managed settings are exactly what we ship -- that is where the whole render lives since #4312 -- so installing a policy file at all turned channels off. The session said so, in a toast nobody was reading: &quot;Channels are not enabled for your org.&quot; Every inbound push was dropped silently. channelsEnabled = true now rides the wrapper&apos;s house posture defaults, which means one place decides it and every consumer derives. A workstation gets it because it hands the darwin managed-settings module the whole render. The dev images hand-write a much smaller policy of their own, and rather than restate the value there they now inherit it from the same render, so an image and a laptop cannot disagree about whether channels are on. It is a default, not a lock. It sits in the house layer rather than the controlled keys, so a host that must refuse inbound pushes sets programs.claude-code.defaults.channelsEnabled = false and the render comes out false. Both directions are pinned by eval tests: the dev image&apos;s /etc/claude-code/managed-settings.json must carry the key true, and the override must reach the render as false.</description>
    </item>
    <item>
      <title>A running command&apos;s output no longer jumps sideways when it finishes</title>
      <link>https://ix.dev/ix-term-region-env-icons</link>
      <guid isPermaLink="true">https://ix.dev/ix-term-region-env-icons</guid>
      <pubDate>Wed, 29 Jul 2026 02:45:00 GMT</pubDate>
      <description>A running command&apos;s output no longer jumps sideways when it finishes. Run git pull in an ix term shell pane and its output was drawn flush against the left of the pane while it ran, then jumped 24px right the moment it finished. Measured on a running app: the pane&apos;s content box starts at x=45 and the block that replaces the live output lands at x=69. A live external is not drawn in the transcript. PaneView stacks a terminal over the pane, the program paints on that, and the captured grid goes into the transcript as an ExternalOutput block when it exits. The region&apos;s top and height were already computed so the rows land where the block will; its left edge and width were not. The width was the expensive half. Terminal sent conn.sendResize from its own box while ShellPane sent shell_resize from a probe carrying the 16px output indent, both landed on the same pty in workspace.rs:1153, and shell_resize also reflows the stored blocks. So a program printed at one column count and its block was laid out again at another: on one pane, 152 columns live against 150 in the block, cell 8.01px. Long lines rewrapped as well as shifting. ShellPane&apos;s ontail widened into onoutputbox, reporting {top, left, width} off the same probe the columns are counted from, and the region is positioned on all three. A region&apos;s terminal also drops ghostty&apos;s window padding, which never moved the grid -- the grid is absolutely positioned, so it resolves against the padding box -- and only took columns off the size reported to the pty. Mid-stream afterwards, overlay, terminal, grid, probe and block all measure left 69, width 1202. Two things in that area are still wrong and are written into #9009 rather than left implied: a region sits 2px above where its block lands, because the block carries a 2px top margin the region does not model; and a full-pane takeover still has two authorities on the pty&apos;s column count, which produces no jump only because a takeover produces no block. ## A pane no longer inherits the agent session that launched the app claude in a pane was printing Transcript saving is off — inherited CLAUDE_CODE_CHILD_SESSION marker. A pane inherits the server&apos;s whole environment and the server inherits the shell that launched the app, so an ix term started from a Claude Code shell carried that run&apos;s markers for its whole life and handed them to every tab. On the app in question, ps -p &lt;app&gt; -wwE showed CLAUDE_CODE_CHILD_SESSION=1 and CLAUDE_CODE_SESSION_ID for a session in another window, and its term-nu had the same set. CLAUDE_SYS_PROMPT rode along too, putting one run&apos;s system prompt in front of every unrelated program started from any pane. AGENT_SESSION_MARKERS is nine variables that name one running session, removed at both places a pane starts: Sessions::dressed for a PTY session and ShellManager::create for the nu engine, since a shell pane&apos;s externals inherit from the engine rather than through a pts. A named list rather than a CLAUDE_ sweep, because CLAUDE_CODE_DISABLE_ and friends are the operator&apos;s login configuration and mean the same thing in a pane. The cost is that the next such variable leaks until someone adds it, and that is written next to the list. Verified against a server started with all sixteen CLAUDE_ variables set: both a term-nu and a zsh -l came out with the nine markers absent and the seven configuration variables present, TERM, COLORTERM and IX_TERM_SESSION_ID intact. The same environment diff also shows TERM_PROGRAM=ghostty, TERM_PROGRAM_VERSION, four GHOSTTY_, ATUIN_SESSION, ATUIN_HISTORY_ID and STARSHIP_SESSION_KEY, all inherited from the window that launched the app. So a pane still tells every program it is ghostty 1.3.2 and hands out another shell&apos;s atuin and starship session ids. Same class, separate decision, in #9012. ## Tab icons take an SVG ixterm icon --image logo.png --pane claude already worked for PNG, JPEG, GIF and WebP. SVG was refused, and MAX_ICON was 16 KiB, which turns away an ordinary logo. SVG had been left out because its safety is a property of the element that draws it and not of the bytes: through &lt;img&gt; it is script-disabled and cannot fetch subresources, while inlined as &lt;svg&gt; -- which is what somebody reaches for when they want the icon to take currentColor -- the same bytes run script in this origin. The comment argued that and nothing enforced it. So the rule is now a test. icons.test.ts reads PaneIcon.svelte and fails if a declared icon stops being drawn by an &lt;img&gt;, or if an inline &lt;svg&gt; or {@html} appears in it; dropping SVG again is the other way to satisfy it, and the test says so. Watched fail, then restored. The CLI needed a sniffer for the one format with no magic number: the first tag that names an element has to be svg, so an HTML page carrying an inline &lt;svg&gt; is not declared one. That clause was broken too, and the eight cases went red on &lt;?xml version=&quot;1.0&quot;?&gt; ahead of the tag. MAX_ICON is 64 KiB now, with the cost beside it rather than implied: a declared icon rides inside every workspace frame, to every viewer, so ten iconned tabs make a 640 KiB frame on every rename, focus and progress update. #9023 is the shape that removes the ceiling rather than moving it. ## What is not fixed The TUI bullets are still drawn by a system font. Advance per character at font-size: 100px in the terminal&apos;s own stack: M, U+2502 and U+2514 all come out at exactly 60.00, Berkeley Mono&apos;s cell; U+25CB, U+25CF, U+25C7, U+25B8 and U+26A0 at 60.21, from a face that is none of the three in the stack; and U+2800/U+28FF at 68.36, 13.9% wider than the cell, which is every spinner in every TUI. A cmap scan says why. Box drawing is covered 25 by Berkeley and 103 by the supplement, missing none, which is why the connectors look right. Geometric shapes are missing 85 of 96, miscellaneous symbols 253, arrows 102, and braille all 256. The fix is to draw them into the supplement face at Berkeley&apos;s cell, the way box drawing already is, and widen the unicode-range. It is not done. make-supplement-font.py&apos;s borrow() path cannot help, because Symbols Nerd Font Mono has none of these codepoints either, so every one is a new outline. #9015 carries the numbers and the plan.</description>
    </item>
    <item>
      <title>House prompt: define acronyms, derive instead of copy, verify in a second agent</title>
      <link>https://ix.dev/prompt-acronyms-derivation-and-subagent-verification</link>
      <guid isPermaLink="true">https://ix.dev/prompt-acronyms-derivation-and-subagent-verification</guid>
      <pubDate>Tue, 28 Jul 2026 16:00:00 GMT</pubDate>
      <description>House prompt: define acronyms, derive instead of copy, verify in a second agent. Three rules land in the house prompt, so every agent wrapper picks them up on its next lock bump. defineAcronyms expands an acronym or coined shorthand at first use, or does not use it: &quot;pressure stall information (PSI)&quot;, then PSI after. The exception is a closed list of twenty-seven forms a programmer reads without expanding, not a judgment call, and packages/agent-prompt/default.nix now refuses to render a prompt carrying an all-caps token that is neither on the list nor expanded in the text. The gate found two counterexamples inside the prompt it was added to: Chrome DevTools Protocol (CDP), which was bare in the browser rule and is now expanded, and a bare directed acyclic graph reference in the fork rule, which was rewritten to say what it meant. A carve-out phrased as &quot;forms a general programmer already knows&quot; would have left both, because the calibration deciding what counts is the one that wrote them. The gate took two rounds to become one. Its first version skipped every backticked span, so one pair of backticks hid an acronym; it split S3 into a letter and a digit; it read CDPs as ordinary mixed case; and a lone (RPC) anywhere in the text excused every other use of the token. Each of those was demonstrated by editing one rule and re-rendering, then fixed and re-demonstrated. One hole remains and is written down next to the code: any lowercase word before the parenthesis counts as an expansion, so &quot;commands (RPC)&quot; passes. firstPrinciples reasons from the mechanism rather than from precedent: derive what must be true from how the system works, then check the established practice against that. Where a claim about behavior rests on what the surrounding code already does, name the constraint that produced the pattern and check it still holds. redesignAtTheRoot already covered judging the shape of a system once you are designing it; this covers the step before, where an answer is inherited from the nearest example instead of derived, which is how a wrong convention survives every review that only asks whether a change matches its neighbors. subagentVerification moves checking out of the context that did the work: spawn a fresh subagent to check a change against its requirement when the change spans more than one file, or when you cannot name the failure mode you checked for, and otherwise verify inline and say what you ran. Those two conditions replaced an unconditional mandate, and then replaced &quot;scale it to the change&quot;, which was the same unenforceable carve-out in different words. packages/agent/policy/hooks.nix ships alwaysOnReview = false because an always-armed Stop gate turned a one-line fix into a four-agent fan-out, and a rule that says &quot;always spawn one&quot; reinstates in prose what that default withholds.</description>
    </item>
    <item>
      <title>PR descriptions now distinguish Linear&apos;s closing verbs from Refs</title>
      <link>https://ix.dev/linear-closing-verbs</link>
      <guid isPermaLink="true">https://ix.dev/linear-closing-verbs</guid>
      <pubDate>Tue, 28 Jul 2026 10:30:00 GMT</pubDate>
      <description>PR descriptions now distinguish Linear&apos;s closing verbs from Refs. An audit of the ENG team&apos;s In Progress column found 39 issues stuck there with merged PRs attached; roughly two thirds were actually finished. The cause was a single word: the PRs referenced their issues as Refs ENG-1234, which Linear treats as a non-closing link. A non-closing link still fires the &quot;PR opened&quot; automation, so the issue jumps to In Progress, but the &quot;PR merged&quot; automation never fires for it, so nothing ever moves the issue to Done. The workflow skill taught this verb split for GitHub issue numbers and said nothing about Linear identifiers, so agents applied the conservative Refs everywhere. Two second-order failure modes surfaced in the same audit. A follow-up PR that merely refs an already-Done issue drags it back to In Progress on open, and with no closing verb of its own, nothing re-closes it after merge; two issues were reopened this way within hours of being correctly auto-closed. And in one observed case Linear&apos;s merge automation missed a properly formed closes-link entirely, leaving the issue In Progress after the fixing PR merged. The workflow skill now spells out the contract: Fixes ENG-1234 or Closes ENG-1234 in the PR description when the PR resolves the issue, Refs ENG-1234 only for partial or related work, a state check after merging any PR that references a Done issue, and a verification that the issue actually reached Done after a closing PR merges.</description>
    </item>
    <item>
      <title>Every Rust binary now carries a GNU build-id, and the build refuses one that does not</title>
      <link>https://ix.dev/build-id-on-every-linux-link</link>
      <guid isPermaLink="true">https://ix.dev/build-id-on-every-linux-link</guid>
      <pubDate>Tue, 28 Jul 2026 06:30:00 GMT</pubDate>
      <description>Every Rust binary now carries a GNU build-id, and the build refuses one that does not. Every binary the fleet ran was missing the twenty bytes that let a tool find its symbols. Read off a live production daemon, the complete list of its ELF notes was .note.ABI-tag and .note.gnu.property. No .note.gnu.build-id. That note is the key an address is looked up by. debuginfod queries by it, a separate .debug file is named after it, coredumpctl debug finds symbols through it, a continuous profiler caches symbols under it, and Antithesis symbolizes coverage with it. None of them has a fallback. Neither rustc nor mold emits it unless asked, and only one derivation in the tree asked: the Antithesis build, which needed it and said so in a comment. index&apos;s Rust policy now emits -Wl,--build-id=sha1 on every -linux- triple, under a typed linker.buildId option. It is gated on the platform rather than on the linker choice, because mold and lld both accept the flag and neither emits the note by default, so it belongs to the output format. sha1 over the linked output rather than a UUID keeps a reproducible build&apos;s build-id reproducible, and twenty bytes is the length consumers are actually tested against. On the ix side, workspace-bins.nix is the one place every shipped binary passes through, so it is where a build-id-less binary becomes impossible rather than merely unlikely: the build now reads the note off the installed copy, after any patchelf, and fails naming the binary if it is absent. Guest binaries get the stronger check, that the stripped wire copy and the unstripped debug/ copy carry the same build-id. The comment beside that strip had always claimed strip preserves the note; it is checked now. The reason this came up is profiling. The OpenTelemetry eBPF profiler emits the GNU build-id as an OTLP mapping attribute and reports a native frame&apos;s address as an offset into the file, which makes (build-id, file offset) the join key for turning a production stack trace into Rust function names. Without the note there is nothing to join on. Measuring the debug info to make that work turned up something worse, filed separately as ix#8937: every host&apos;s system closure carries 36 GiB of .dwp split-DWARF sidecars, thirty files between 1.3 and 1.5 GiB each, and the binaries still contain about 50 MB of .debug_* sections apiece. The split that was supposed to move DWARF out of the ELF is leaving it in and shipping a copy beside it.</description>
    </item>
    <item>
      <title>lazy trees is off again: its own mutation fix costs hydra 13x the kernel time</title>
      <link>https://ix.dev/lazy-trees-off-hydra</link>
      <guid isPermaLink="true">https://ix.dev/lazy-trees-off-hydra</guid>
      <pubDate>Tue, 28 Jul 2026 06:05:00 GMT</pubDate>
      <description>lazy trees is off again: its own mutation fix costs hydra 13x the kernel time. lazy-trees is back to false on hydra, the only host that had it on. Measured on the flake it was enabled for, with the arms interleaved so machine load cannot favour one: the same warm-cache nix eval of homeConfigurations.&quot;andrewgazelka@hydra&quot;.activationPackage.drvPath spends 118.1s, 123.2s and 118.9s of system CPU with the setting on, against 6.6s and 12.2s with it off. Roughly 13x, and the three readings with it on agree within 4%. Wall clock is the number people ask for and the one worth refusing to quote precisely here: it ranged 222-328s on and 36-92s off, about 4x, on a box that other agent sessions keep busy and whose flake tree they keep writing to mid-measurement. System CPU is the work itself rather than time spent waiting, which is why it holds steady while wall clock does not. User CPU is 17-22s either way, so none of the gap is the evaluator. Two measurements taken a different way agree with the kernel time. A stack sample of a live nix eval caught 3131 of 3131 samples in AutoDelete -&gt; _deletePath -&gt; unlinkat, after evaluation had already finished. And unlink runs at 11,257 entries/s on this host, so a 698,301-file clone is about 62 seconds of teardown per invocation on its own. Lazy trees exists to avoid copying source trees into the store. On hydra it now performs a larger copy than the one it avoids. ## How that happened Reading the live worktree for the whole evaluation means a concurrent writer can change a file between the mount-time hash and the on-demand copy, which aborts the eval (#3749: two home-manager switch runs died that way in one day, the writer being agent sessions editing files under the flake). Patch 0032 fixed it by cloning the mutable tree into $TMPDIR with clonefile(2) at mount time and evaluating from the clone. The patch measured 70ms on a 4,000-file worktree, which is honest. Two costs were not in that 70ms. clonefile on a directory is copy-on-write, so creating the clone is nearly free, but deleting it is one unlinkat per file. Measured on this host: 11,257 entries per second, so a 698,301-file tree is 62 seconds of teardown per nix invocation. And the clone is unfiltered. paths.cc:126-141 widens the clone source to the repo root deliberately, because a git workdir accessor omits .git and the re-fetch of the snapshot needs it. Hydra&apos;s ~/.config/nix holds 698,301 files; git tracks 18,299. The other 680,002 are agent worktrees, node_modules, Rust target/ and a venv, all gitignored, all cloned and deleted on every nix command. Git filtering does run, but afterwards, when the clone is hashed. The fallback the snapshot replaced is strictly better on a tree this shape: FetchMode::Copy goes through the git accessor, so it copies the 18,299 tracked files, and the result is content-addressed, so an unchanged tree is a cache hit next time. ## A third symptom Cleanup is a function-local static AutoDelete, so it runs on normal exit and nowhere else, and _deletePath checks for an interrupt at every node. A Ctrl-C during the multi-minute teardown throws mid-recursion, the destructor swallows it, and the partial clone stays. The slowness therefore manufactures the leak: teardown looks like a hang, the operator interrupts it, and the interrupt is what strands the tree. hydra had 707 abandoned snapshot directories holding 22,048,824 entries, the oldest 16 hours old, accumulating at one to five per minute. Manual cleanup needs chmod -R u+w first: store-sourced directories are mode 555 and clonefile preserves that, so a bare rm -rf fails inside them (verified: it removed 82,003 of 82,188 entries and stranded 185). Nix&apos;s own _deletePath chmods as it descends, so this affects only the manual sweep. ## What is unchanged None of the patches were removed. Patches 0025-0029 stay as the off-by-default backport of the upstream-merged NixOS/nix#15711 lineage; 0032 stays because it is the only thing preventing the #3749 race for anyone who turns the setting on, and it is correct for a normally sized tree. This is a setting decision, not a patch defect: drvPath byte-equality on and off was verified when the setting was enabled, and the escape hatch was written into the config comment at the time. Linux was never affected. cloneTreeSnapshot is inside #ifdef __APPLE__ and returns std::nullopt everywhere else, so Linux hosts always took the eager filtered path, and none of them set lazy-trees in the first place. Checked live: dev-compute-1 and hil-compute-2 report the setting off and hold zero snapshot directories. ## What would make it worth turning back on Two fixes, tracked in #4297. Clone the git file set plus .git rather than the directory. Both halves are already at the call site: for a git workdir readDirectory (source-accessor.hh:139) returns the filtered view and getPhysicalPath (:153) gives each entry&apos;s real path, so the snapshot becomes a walk of the accessor with mkdir per directory and clonefile per file. Syscalls go from 1 to 18,299; 680,002 inodes are never created and never deleted; measured teardown falls from 62s to 1.6s. Make teardown survive a kill, either with an uninterruptible delete or a reaper over nix-eval-snapshot-* whose owning pid is gone, chmoding as it descends. There is a larger question behind both. clonefile appears exactly once in the whole nix tree, in patch 0032, aimed at a temporary that gets deleted. The copy that happens on every platform and every invocation, the eager copy of the git file set into the store, still moves every byte; upstream has a standing TODO about it at derivation-builder.cc:1776. Reflinking that copy where the filesystem supports it (clonefile on APFS, FICLONE on btrfs, XFS and bcachefs, an ordinary copy elsewhere) attacks the same cost on the path that actually runs, over the files that actually matter, with a result the store keeps.</description>
    </item>
    <item>
      <title>claude subcommands work again: the wrapper stops prepending flags ahead of them</title>
      <link>https://ix.dev/claude-subcommand-argv</link>
      <guid isPermaLink="true">https://ix.dev/claude-subcommand-argv</guid>
      <pubDate>Tue, 28 Jul 2026 02:00:00 GMT</pubDate>
      <description>claude subcommands work again: the wrapper stops prepending flags ahead of them. claude remote-control answered with Error: Unknown argument: --dangerously-load-development-channels, and so did every other subcommand. The flag was ours. Our wrapper prepends six root flags (--debug, the development-channels pair, --thinking-display=, --system-prompt-file=, --mcp-config=) before whatever you typed. Claude Code picks a subcommand from the first argv token and then reads the raw process.argv again by fixed index: remote-control hands process.argv.slice(3) to its own parser, and the entry fast paths compare process.argv[2] against remote-control, rc, daemon, --bg-spare and friends. Six tokens in front shift every one of those indices, so the subcommand&apos;s parser gets handed the wrapper&apos;s flags and rejects them. The launch spec now carries the tokens the CLI dispatches positionally, and config-launch prepends nothing when the first user argument is one of them. A root flag means nothing to a subcommand, so nothing is lost; env, PATH and the settings layer still apply to every invocation. A prompt is matched whole, so claude &quot;mcp is broken, fix it&quot; is still a session with the house prompt. Three things keep it fixed. packages/claude-code/subcommands.tsv is generated: the registered commands come from claude --help, so one Anthropic adds appears at the next version bump, and the hidden ones are curated with the byte pattern in the bundle that proves each is still dispatched. The knob-reference check pins that file to the pinned CLI version, so a bump that skips regeneration turns main red. And the install check drives every token through the real launcher and asserts the target sees it as argv[2] with nothing ahead of it. That check previously used claude mcp list as its example and asserted the flags rode in front, which is the broken behavior written down as a guarantee. A new hidden subcommand is still undetectable from outside, so it would break the same way. The failure is loud, not silent.</description>
    </item>
    <item>
      <title>an example the aggregator cannot call now says which argument it choked on</title>
      <link>https://ix.dev/example-discovery-loud-skip</link>
      <guid isPermaLink="true">https://ix.dev/example-discovery-loud-skip</guid>
      <pubDate>Tue, 28 Jul 2026 00:00:00 GMT</pubDate>
      <description>an example the aggregator cannot call now says which argument it choked on. exampleFleetsFor in lib/discovery.nix walks examples/*/default.ix and turns each one into the &lt;example&gt;-{up,health,status,logs,replace,switch,diff} fan-out under legacyPackages. An example whose default.ix takes an argument the aggregator has no way to supply -- a sibling flake, a service repo -- was dropped and nothing was said. Skipping is the right call, and the comment already in the code explains why: the alternative is one example depending on a service repo breaking the eval of the whole tree. Silence is the part that was wrong. An example that stops being discovered looks exactly like an example that was never added, so a default.ix can be committed, reviewed, and merged while the aggregator never once touches it. #4334 shipped two like that, invisible from their first commit, and its own eval assertions passed because they call ix.importIx directly rather than going through the aggregator. Each skip now prints the example&apos;s relative path and the argument names that caused it: trace: exampleFleetsFor: skipping examples/warnprobe/deliberate: default.ix requires someServiceRepo, which this aggregator cannot supply The second skip path gets the same treatment. A default.ix that takes no index argument at all cannot be called with { index = ...; } either -- Nix rejects the unexpected argument -- and that case was folded into the same silent null: trace: exampleFleetsFor: skipping examples/multi-vm/switch-multi: default.ix takes no index argument, so there is no way to call it ## lib.warn is the obvious spelling and the wrong one The issue suggested lib.warn, which is what anyone would reach for. It does not work here. This repo and ix both set abort-on-warn, so the first skip ends the eval: evaluation warning: exampleFleetsFor: skipping examples/minecraft/hyperion: default.ix requires hyperion, which this aggregator cannot supply error: aborting to reveal stack trace of warning, as abort-on-warn is set That is precisely the tree-wide failure the skip exists to prevent, reintroduced by the thing meant to report it. builtins.trace is the loudest mechanism left that still lets the eval finish, so the no-deprecated-trace lint is suppressed in place with the reason written next to it. ## watched failing before being trusted A temporary examples/warnprobe/deliberate/default.ix taking ({ index, someServiceRepo }) was added, evaluated, and removed. It produced the trace quoted above, and nix eval .#legacyPackages.aarch64-darwin still exited 0. One thing that cost a few minutes and is worth writing down: the probe printed nothing until it was git added. The flake&apos;s source is its git tree, so an untracked default.ix is invisible to discovery entirely -- which is its own quiet way for a new example to do nothing. ## two examples on main are skipped, and both are supposed to be Pointing the trace at unmodified main before anything else found two, and neither is a bug. Every example directory carries its own flake.nix, which is where the arguments the repo cannot supply come from: | example | argument | supplied by | in the aggregate? | | --- | --- | --- | --- | | examples/minecraft/hyperion | hyperion | its own flake, github:hyperion-mc/hyperion | no | | examples/multi-vm/switch-multi | nixpkgs, and no index | its own flake | no, and the single-VM filter would drop it anyway | So the mechanism is working as designed: hyperion depends on a service repo, and that is the exact case the skip was written for. What the trace does expose is a coverage gap nobody had stated. hyperion returns mkFleet, the same shape examples/k8s/k3s returns, so it is the one example in the tree that gets no health-check- from this repo -- and it was edited this morning. That is #4347, which asks for a decision rather than a change: take a hyperion input, or write down that service-repo examples are covered only by their own flake. ## not done The other silent drop is untouched. An example whose converted value is not the single-VM nodes + planValue shape is still dropped without a word, and that one is deliberate: most examples in the tree are multi-VM, so tracing it would print a dozen lines every eval and train everyone to ignore all of them. If that class ever needs visibility it wants a different surface than stderr. No test asserts the trace fires. The guard is the deliberate break above, run by hand.</description>
    </item>
    <item>
      <title>tree-sync: pushing a source tree no longer deletes files at the wrong depth</title>
      <link>https://ix.dev/tree-sync-git-view</link>
      <guid isPermaLink="true">https://ix.dev/tree-sync-git-view</guid>
      <pubDate>Mon, 27 Jul 2026 21:20:00 GMT</pubDate>
      <description>tree-sync: pushing a source tree no longer deletes files at the wrong depth. An agent syncing an ix worktree to a dev box ran this, and lost a source file: --exclude &apos;result&apos; names the result and result- symlinks a Nix build leaves at the top of the tree. An rsync pattern with no slash in it matches at every path depth, so the same flag also removed crates/codec/src/impls/result.rs from the destination. rsync said nothing about it. The failure surfaced ten minutes later as error[E0583]: file not found for module &apos;result&apos; partway through a Rust build, which points at the compiler and not at the sync. Two more traps sat in the same command line: --exclude &apos;target&apos; eats any nested target/ directory, and --exclude &apos;.git&apos; misses the case where .git is a file, which is every linked worktree and every submodule. nix run .#tree-sync -- &lt;source&gt; &lt;host&gt;:&lt;path&gt; replaces that command. The package is packages/tree-sync. The file set is not a glob at all. It is git ls-files --cached --others --exclude-standard, so target/, result and everything else drops out because the repo&apos;s own .gitignore says so. Whether the tree is a git tree is decided by asking git, not by looking for a .git directory, which is what makes a linked worktree and a submodule work; submodule contents are listed by recursing into each gitlink. Outside git the tool falls back to a gitignore-aware walk that skips .git by name whatever type it is. Extra --exclude patterns are anchored at the source root. result is matched as /result, so it cannot reach a subdirectory. A pattern that genuinely wants every depth says so, either as */result or through --exclude-any-depth. Every rule prints the pattern it was actually matched with and how many paths it removed, including zero, so both an over-broad rule and a typo are visible in the output rather than in a build failure: Deletion is opt-in and confined. --delete computes the destination&apos;s own listing minus the source, refuses any path that is absolute or climbs through .., and runs rm after a cd into the destination root. It also refuses outright when the source selected no files, which is what a mistyped source path looks like right before it empties the destination. The remote needs nothing installed beyond tar, find, xargs and a shell: the archive is built in process and streamed into ssh &lt;host&gt; tar -x. Which program carries the command is TREE_SYNC_SSH, defaulting to ssh, which is how the tests drive the real far-end commands without a network hop. What this does not do, stated plainly. It sends whole files, not deltas. An unchanged file is skipped by size and mtime, the same quick check rsync uses by default, so a repeat sync moves only what changed, but a one-line edit to a large file still moves the whole file. If that becomes the bottleneck the answer is zsync, which already does FastCDC chunking and content-addressed dedup over the same ssh shape; this package is deliberately the small, dependency-light half of that idea, aimed at the correctness bug rather than at the wire. --delete does not prune directories that become empty. And the file set honours a global gitignore, which is correct and occasionally surprising: a developer whose ~/.gitignore_global lists result will not see that symlink in the set at all. The global agent instructions now say to reach for this instead of rsync when syncing a source tree.</description>
    </item>
    <item>
      <title>a 23 MB Rust binary was dragging 2.5 GiB of build inputs behind it</title>
      <link>https://ix.dev/cargo-unit-path-remap</link>
      <guid isPermaLink="true">https://ix.dev/cargo-unit-path-remap</guid>
      <pubDate>Mon, 27 Jul 2026 20:20:00 GMT</pubDate>
      <description>a 23 MB Rust binary was dragging 2.5 GiB of build inputs behind it. bedwars, a game server built out of hyperion with cargoUnit.buildWorkspace, is a 23 MB executable. Its runtime closure was 2.5 GiB across 559 store paths, with 110 direct references: Two of those references were real: glibc and gcc&apos;s runtime library. The other 108 were crates the binary links, retained as source trees, plus the Rust toolchain. ## the mechanism is file!(), not linkage panic!, unwrap, assert and every #[track_caller] location expand file!() to the absolute path of the source file. Under cargo-unit that path is a store path, so the location string of a panic in a vendored crate is a literal store path sitting in the binary&apos;s read-only data: Nix&apos;s reference scanner looks for the 32-character hash part of a store path anywhere in an output, so that one string pins the whole sharded-slab source derivation into the closure. Debug info was already off (-C debuginfo=0 -C strip=debuginfo); these are ordinary string constants and no strip removes them. The single biggest entry was not a crate. Upstream compiles std against a virtual /rustc/&lt;commit&gt; prefix, but rustc translates that straight back to the local rust-src directory whenever the component is installed (-Ztranslate-remapped-path-to-local-path, on by default). One unwrap on a std generic monomorphized in your crate therefore names the toolchain, and the toolchain was 1.9 of the 2.5 GiB. ## every unit now compiles with --remap-path-prefix cargo-unit maps two prefixes on every rustc and clippy invocation it renders: the unit&apos;s own source to /build/&lt;crate&gt;-&lt;version&gt;, and the toolchain&apos;s rust-src to /rustc. Not an opt-in flag: a caller who has never heard of this gets the small closure anyway, and a crate that bakes a location agrees with the crate that links it. The same bedwars: 2.5 GiB to 68.3 MiB, 559 paths to 7, 110 direct references to 2. ## what changes for you Panic messages and backtraces now read /build/anyhow-1.0.104/src/error.rs and /rustc/library/core/src/option.rs where they used to read a store path. The crate, version, file and line are all still there; only the hash is gone. Nothing resolves those paths at runtime, so nothing breaks. --remap-path-prefix rewrites source spans, not environment variables or file loading, so include_str!, include! and env!(&quot;CARGO_MANIFEST_DIR&quot;) are untouched. A crate that deliberately embeds a store path to reach a data file keeps both the path and its reference: the ix2nix golden test does exactly that, still resolves its fixtures, and still lists cargo-unit-source-ix2nix-0.1.0-... among its references. Coverage reads these paths back, so coverageReport maps the placeholder to a workspace-relative SF: line using a remapPrefix now recorded in sourceAudit. ## why this was blocking a deployment ix apply builds a closure inside the guest and a guest gets a 14 GB root disk. A NixOS system carrying two of these binaries came to 7.0 GiB over 1743 paths and filled the disk twice. The false references were most of it. A tests/default.nix fixture now links a workspace rlib, two vendored crates and a rust-src toolchain into one small binary and fails by name if any of them comes back.</description>
    </item>
    <item>
      <title>A malformed .svx frontmatter now fails lint instead of main</title>
      <link>https://ix.dev/svx-frontmatter-gate</link>
      <guid isPermaLink="true">https://ix.dev/svx-frontmatter-gate</guid>
      <pubDate>Mon, 27 Jul 2026 15:15:00 GMT</pubDate>
      <description>A malformed .svx frontmatter now fails lint instead of main. Cache push was red on every push to main for four consecutive runs on 2026-07-27. The site derivation aborted with ./updates/worktree-gate-covers-bash.svx: frontmatter failed to parse (no metadata export). One link label caused it: A plain YAML scalar may not contain a colon followed by a space. git-guard: mutating ends the scalar, the mapping becomes invalid, mdsvex exports no metadata, and the loader in src/lib/updates.ts throws. Quoting the label fixes it. The sibling labels above and below have no colon and parsed fine, which is why one file out of 218 broke the whole site. Nothing caught it before the merge. The site build runs in the Cache push workflow, not in the required Check, so branch protection never saw the failure and the author of the merge learned about it from a red main. lint now has a site-frontmatter stage. It parses the frontmatter of every .svx under plans, updates and stories with a real YAML parser and prints the file, line and column: That is 0.5 seconds over 218 files, and lint runs in the pre-push hook, so the failure now arrives before the commit is public. Parsing instead of pattern matching on colons means the stage also catches an unterminated flow sequence, a tab, or bad indentation, which are the other ways a hand-written block stops being YAML. The gate stops at parseability. A frontmatter block that is valid YAML but missing a field the page reads, or carrying a postedAt the loader cannot turn into a date, still gets through here and fails later in the site build.</description>
    </item>
    <item>
      <title>A complexity ratchet, with thresholds measured instead of quoted</title>
      <link>https://ix.dev/complexity-ratchet</link>
      <guid isPermaLink="true">https://ix.dev/complexity-ratchet</guid>
      <pubDate>Mon, 27 Jul 2026 13:20:00 GMT</pubDate>
      <description>A complexity ratchet, with thresholds measured instead of quoted. nix run .#complexity ranks every function in the tree by cognitive complexity and, as the complexity lint stage, fails the build when more units sit above their language&apos;s threshold than complexity.toml allows. 19,771 units across 2,065 files, measured in 1.1 seconds. It reuses ast-merge-langs and repo-walker, so it adds no new parsing dependency. The metric is Campbell cognitive complexity, not cyclomatic: each break in linear flow costs one plus the current nesting depth, so twenty nested branches cost far more than twenty flat ones. That distinction is the point. An fMRI study of 19 programmers found cyclomatic complexity has no correlation with either comprehension time or correctness (Peitek et al., ICSE 2021), and a survey of 121 metrics against 63 developers found none of them captured understandability (Scalabrino et al., TSE 2021). Cognitive complexity is the only code-only metric with a published validation against human ratings, and a reanalysis of that same data found it no better than the metrics it replaces. The tool says as much in its own module docs: it is triage, not measurement. The thresholds are the part worth copying. None of the familiar numbers are empirical — McCabe called his 10 &quot;reasonable, but not magical&quot;, and SonarSource arrived at 15 by raising it until the output felt less noisy. Each threshold here is instead this repo&apos;s own size-weighted 90th percentile, derived by the method in Alves, Ypma and Visser (ICSM 2010), so it means the worst 10% of this repo by volume in that language: Rust 10, Nix 4, Elixir 3, Python 23, TypeScript 43. Re-derive them with --quantiles. A single global threshold would have been wrong for at least four of the six languages. Two languages needed semantics nobody has published. Elixir has no keyword node kinds at all, so def, case and if are ordinary calls told apart by the text of their target and the dispatch arms are stab_clause nodes. Nix has no functions in the usual sense, so the unit is the attribute binding — with the rule that a binding whose value is an attribute set is a namespace and is descended into. Without that second rule the top of the ranking was a single 2,964-line groups = { that had absorbed every member&apos;s score. The gate opens at 435 against a measured 396, the roughly 10% headroom clone.toml learned to leave after a flush budget reddened main on ordinary drift. What is missing is the strongest signal of all: change history predicts defects better than any static metric, but it needs a .git the lint derivation does not have, so it belongs in the ranking rather than the gate and is not built yet.</description>
    </item>
    <item>
      <title>A primary checkout is now read-only to agents, not just uneditable</title>
      <link>https://ix.dev/worktree-gate-covers-bash</link>
      <guid isPermaLink="true">https://ix.dev/worktree-gate-covers-bash</guid>
      <pubDate>Mon, 27 Jul 2026 12:58:00 GMT</pubDate>
      <description>A primary checkout is now read-only to agents, not just uneditable. The shared ix checkout on this workstation was found on 2026-07-27 sitting on a branch deleted upstream, 604 commits behind main, with 534 files staged by nobody. 157 of those files differed from origin/main: 106 were stale copies main had moved past, 51 had been edited away from both. Telling one group from the other took a classification pass before the checkout could be reset, and the contents were preserved as ix@rescue/2026-07-27-primary-checkout first. This is the second such rescue branch in two days. No edit tool call produced any of it. The worktree guard denies Edit and Write whose target resolves into a protected checkout, and the workstation profile already listed this checkout among the protected globs, so the guard was live and correct the whole time. git add and git switch through Bash are not edits. Both layers now say the same thing. The prompt rule opens Never work in a primary checkout instead of Never edit in a primary checkout, phrased as a gate on starting work rather than advice about edits (#4217). And git-guard grew a third rule: a state-mutating git subcommand aimed at a protected checkout is refused whatever the tree looks like -- add, commit, switch, checkout, reset, stash, rebase and the rest of a closed enumerated set, so an unrecognised subcommand still fails open (#4219). Reads are untouched, and so are the three commands the refusals themselves recommend: worktree add, stash list, and naming a branch. The refusal prints the worktree add line for the repo you are standing in, derived from that checkout&apos;s own origin URL. Two consequences worth knowing before you meet them. Hand-cleaning a protected checkout is now refused too, because unstaging a stray git add is itself a mutation; CLAUDE_CODE_DISABLE_GIT_GUARD=1 is the way through and the refusal names it. And git submodule update stays allowed on purpose, since it is half of the worktree add recipe the refusals print. One layer is still open. git-guard is a PreToolUse hook, so it sees the Bash tool and nothing else, while the kernel&apos;s Cmd.run and Cmd.sh never pass through hooks. Cmd.sh(&quot;git add -A&quot;, cd: &quot;&lt;protected checkout&gt;&quot;) from an exec cell is still unrefused, which is the same shape as #3871, where kernel Edit.write bypassed the Edit-side guard and the fix was to enforce the denylist inside the kernel. Tracked as #4225.</description>
    </item>
    <item>
      <title>nox self-build crosses five repaired boundaries</title>
      <link>https://ix.dev/nox-self-build-closure-fixes</link>
      <guid isPermaLink="true">https://ix.dev/nox-self-build-closure-fixes</guid>
      <pubDate>Mon, 27 Jul 2026 00:56:00 GMT</pubDate>
      <description>nox self-build crosses five repaired boundaries. The nox release-fast self-build now completes the command that first failed while registering a Rust crate derivation with the system Nix daemon: The verified run on main commit 6bab9bc plus the cache-selection fix completed in 276.6 seconds. It evaluated 5,115 derivations, delegated a 4,019-derivation import-from-derivation closure, expanded 511 Cargo units, and finalized 511 build nodes. Five bugs appeared in order: 1. The worker-protocol export rebuilt a shallow registry value and changed a child derivation path. It now reads the canonical derivation recorded when that path was first computed. 2. Build submission rebuilt requests from shallow selector values. It now takes the builder, arguments, environment, outputs, and inputs from the same canonical derivation record. 3. The Linux sandbox closure considered only physically present candidates. It now resolves declared references across the host and nox stores, then unions validity metadata into the mount graph. 4. A concurrent multi-cache lookup selected the first HTTP response before applying signature policy. It now considers responses in priority order and selects the first trusted narinfo. A cached response that no longer satisfies the active policy triggers a fresh query. 5. The fleet trusted four cache keys but omitted the signer used by the CI plain cache. The fleet trust source now includes ix-nix-cache-1. A deploy-surface check requires the signer on all five dispatcher nodes. Format, both workspace clippy gates, and 217 affected-package tests passed on the final branch. The repeated HTTP/3.0 warnings did not stop this build. Issue 387 tracks that transport diagnostic separately.</description>
    </item>
    <item>
      <title>The fleet&apos;s log store answers operator machines, and a refusal stops reading as a wrong password</title>
      <link>https://ix.dev/clickhouse-tailnet-acl</link>
      <guid isPermaLink="true">https://ix.dev/clickhouse-tailnet-acl</guid>
      <pubDate>Mon, 27 Jul 2026 00:20:00 GMT</pubDate>
      <description>The fleet&apos;s log store answers operator machines, and a refusal stops reading as a wrong password. Querying the fleet&apos;s ClickHouse from a laptop returned AUTHENTICATION_FAILED with the correct password. The password was never the problem: the ix user&apos;s &lt;networks&gt; list covered loopback and the two vRack ranges, and a tailnet address is in none of them. ClickHouse reports a rejected source address to the client as a wrong-password error, deliberately and indistinguishably. Only the server&apos;s own log says what happened: The list was inherited, not chosen. Before ENG-2022 the ix user was passwordless and the ACL was the only control. ENG-2022 made the password the control and said so in the module&apos;s own comment, but left the ranges alone, because nothing reached ClickHouse from off the vRack at the time. Tailscale&apos;s v4 CGNAT pool and its v6 ULA are now in the list. The password is still required; tailnet membership is an enrolled-device check on top of it. The visible effect is that the fleet dashboard&apos;s OOM and cgroup-event drill-downs answer on an operator machine instead of rendering &quot;no log store configured&quot;. Those drill-downs are the only place that says which process a cgroup killed, or what was running in a cgroup when it started refusing forks. A refusal is now its own state, because &quot;unreachable&quot; was a misdiagnosis. An HTTP 401 or 403 means the store answered and declined; rendering that as &quot;log store unreachable&quot; sends an operator to check a network that is working. The dashboard has a fifth state for it, and the wire carries a causeMasked boolean so the widget knows whether ClickHouse could name the cause. Only AUTHENTICATION_FAILED is genuinely ambiguous between a bad credential and the ACL. UNKNOWN_USER and WRONG_PASSWORD are not, and the widget no longer tells an operator to go read a journal for an answer already on their screen. The interesting part is the bug the fix shipped. The comment explaining the new ranges used -- as punctuation. That block is an XML comment, and XML forbids the sequence inside one. Nix renders the template as an opaque string, so it evaluated, built, passed an eval gate and merged completely clean. clickhouse-server would have refused to start on it, on the fleet&apos;s only store node. What caught it was a check written an hour earlier and for a different reason. ENG-2022 had landed three eval assertions pinning the authenticated user shape; the eval-first test migration deleted the file holding them and carried none of them forward, so for months nothing failed if the loopback-only user regained a network or access_management came back. Restoring them as a seam check turned up the parse error on its first run against main. No host took the broken config, and the deploy carrying it was cancelled mid-run. The general shape is worth keeping: a build system that treats a config file as a string will happily ship one its consumer cannot parse. The guard has to speak the consumer&apos;s language, and the assertion nobody re-landed after a migration is exactly the one you find out about the expensive way.</description>
    </item>
    <item>
      <title>two of three subagents move to the Elixir kernel</title>
      <link>https://ix.dev/subagents-mcp-ex</link>
      <guid isPermaLink="true">https://ix.dev/subagents-mcp-ex</guid>
      <pubDate>Sun, 26 Jul 2026 06:50:00 GMT</pubDate>
      <description>two of three subagents move to the Elixir kernel. The default MCP servers already point at mcp-ex, the Elixir kernel. Three subagents still spawned the Python server for their own private kernel. Two of them moved; one did not, and the one that did not is the interesting part. cursor-search and executor only ever shell out. In the Python kernel that meant the blessed nu() wrapper inside python_exec: mcp-ex deliberately has no nu(), and its README is worth quoting on why: the Python kernel needed a managed, non-blocking, cancellable shell path because one synchronous call on a shared asyncio loop freezes every session&apos;s jobs. On a preemptive runtime that wrapper is redundant surface area. A cell that needs a subprocess writes one: For executor this is better than a port, it is an upgrade. That agent exists for fidelity: the parent wants verbatim output and the real exit code. System.cmd/3 returns the code as data rather than raising, and takes arguments as a list, so nothing re-splits or re-globs them between the brief and the process. ## The one that stayed index-action-runner is spawned for browser automation and image or PDF sweeps. browser is a Python-server tool. mcp-ex exposes exec plus in-language callables — Read.file, Memory., Ix.trace, Sessions. — and nothing that drives a browser. Swapping it anyway would have looked like a finished migration while deleting the capability the agent is spawned for. So it still runs the Python server, with a note saying it is the last consumer, why, and what has to exist before packages/mcp can be deleted. That also settles the wider question. The Python MCP cannot come out of the fleet base profile or the embed CLI yet, because this agent still needs it. The order is: a browser in mcp-ex, then the swap, then delete the package everywhere. Not: delete the package and discover what depended on it.</description>
    </item>
    <item>
      <title>a CI cache entry that wanted to be a derivation</title>
      <link>https://ix.dev/nixpkgs-index-derivation</link>
      <guid isPermaLink="true">https://ix.dev/nixpkgs-index-derivation</guid>
      <pubDate>Sun, 26 Jul 2026 06:30:00 GMT</pubDate>
      <description>a CI cache entry that wanted to be a derivation. The CVE scanner needs to answer one question about every path in a closure: what package is this, and what version? So it keeps a catalog of all of nixpkgs: That catalog is a pure function of one input, the locked nixpkgs revision. Same revision, same catalog. It was being recomputed in the workflow and stashed in actions/cache under a hand-written key: Three things are wrong with that, and the third is the one that bites. It runs on a self-hosted machine that already has a local Nix store and a fleet binary cache on the same network, and then fetches from GitHub over the internet instead. It evaluates all of nixpkgs inside the CI job&apos;s memory envelope rather than the daemon&apos;s, which is where an evaluation that size belongs. And the key is a string a human maintains, so it can drift from the thing it claims to describe. That last one is not hypothetical. The same file paired a cache/restore at v4.3.0 with a cache/save at v6.1.0, in the same cache operation, and nobody noticed for months. ## Why it could not just be a derivation The obvious fix is to make the catalog a derivation and let the store handle it. The obstacle is that pname and version are evaluation-time metadata. They are attributes of the recipe, not files inside the result. You cannot read them off a built package because they were never written there. So producing the catalog means evaluating nixpkgs, and a build sandbox is designed to make that impossible: no network, no daemon socket. A build runs programs on its inputs. It cannot ask Nix questions. There is a second wall behind the first. The catalog is keyed by output path, and computing an output path means instantiating a derivation, which writes a .drv to the store. Sandboxes cannot write to the store either. ## recursive-nix, and why not IFD recursive-nix hands the sandbox a socket back to the daemon, so the build can evaluate and instantiate. It is the designed escape hatch for a build whose job is to ask Nix something, and it is already enabled and advertised across this fleet. Import-from-derivation is the usual reflex here and it does not help. IFD changes when a build happens relative to evaluation; it grants the builder no new capability. The derivation IFD builds is still an ordinary sandboxed derivation that cannot evaluate nixpkgs. IFD is a consumer-side tool and this is a producer-side problem. Avoiding IFD is also worth something on its own. This repo already pays for it elsewhere: an entire category of cache-push roots exists so that a Mac can substitute eval-time IFD outputs instead of trying to build x86_64-linux derivations during evaluation. Nothing here is IFD. The scanner reads the catalog at runtime from a path, so evaluation stays pure. ## The part that needed checking The catalog records a revision, and it is not the git rev. It is a SHA-256 over the serialized lock entry. That matters for the cache key. Feeding the derivation the whole flake.lock would rebuild a full nixpkgs evaluation whenever any unrelated input moved, so it gets a synthesized lock holding only the nixpkgs entry. That is only sound if serialization is canonical, and it is: this project does not enable serde_json&apos;s preserve_order, so its maps are BTreeMap and the bytes depend on content alone, not on key order in the source file. Worth verifying rather than assuming, because the failure would have been quiet: the derivation would produce a valid catalog carrying a revision that disagreed with the one CI computes. Checked independently, against the real lock, without going through the tool: The first build produced 38,191 output paths. ## What changed Six workflow steps become two lines: The cache key is now the derivation hash, so it cannot drift from its inputs. cache.ix.dev serves the result to every host and every repo. And the evaluation runs in the nix-daemon cgroup instead of the job&apos;s. The advisory cache stays exactly where it is. That data comes from OSV, NVD, KEV and EPSS over the network; it is genuinely impure and a derivation would be the wrong shape for it. The point is not that caches are bad. It is that a cache holding a pure function of a pinned input is a derivation someone wrote by hand, and the hand-written version is the one that drifts.</description>
    </item>
    <item>
      <title>the Claude Code updater now regenerates everything a version bump invalidates</title>
      <link>https://ix.dev/claude-code-updater-regenerates-the-bump</link>
      <guid isPermaLink="true">https://ix.dev/claude-code-updater-regenerates-the-bump</guid>
      <pubDate>Sun, 26 Jul 2026 05:10:00 GMT</pubDate>
      <description>the Claude Code updater now regenerates everything a version bump invalidates. nix run .#claude-code.updateScript used to refresh two of the five artifacts that carry the pinned CLI version. The 2.1.215 -&gt; 2.1.220 bump ran it, landed, and then failed three separate gates on main, one at a time, each discovered after the previous fix. The updater now refreshes four of the five in one pass; the fifth is deliberately still manual and the run ends by saying so. ## a bump is one command again The two new lines are what used to be a footnote in a comment. env-registry.tsv is rebuilt from the .#claude-code.envRegistry derivation and copied into the tree, which was the documented manual step. The env reference block in packages/agent/home-manager/claude-code.nix carries the version in its BEGIN marker and a written count of env names in its prose (&quot;all 830 names&quot;); both are now substituted. checks.claude-code-knob-reference asserts the first two at eval, so skipping them turned flake-check red. Nothing asserts the written count, which is worse: it just quietly said 812 while the file held 830. ## the version is substituted from one place, and the count is not written down at all The chain runs manifest.json to the envRegistry derivation to the TSV header to the reference block. The updater writes the version once, into manifest.json. It then builds the registry, which reads that manifest, and parses both substituted values back out of the generated TSV: the version from the header line the derivation stamps, and the count as the number of non-comment rows. There is no second literal of either anywhere in update.nix, so the two cannot disagree with the artifact they describe. Running it twice is a no-op. On the current tree, already at 2.1.220, both runs print the same three lines and git status stays clean. ## a reworded marker stops the updater instead of being skipped This is the failure mode the whole change exists to prevent: an updater that cannot find the line it was going to rewrite, shrugs, and exits zero. Each marker is a regex that must match exactly one line, and anything else is fatal: The check runs before the registry build, not after, so a drifted marker costs a second rather than a download plus a scan of a 257MB binary, and it never leaves a tree with a new manifest and a stale reference. ## the dev-channels anchors stay manual, and the run says why devChannelsGateAnchor in packages/claude-code/default.nix is the fifth artifact and the one that is not automated. It is a per-system byte-patch target -- the minified source text of the gate condition -- counted against the pinned binaries. 2.1.220 reminified three of the four. The byte patcher&apos;s expect = 1 gate already catches that at build time with COUNT DRIFT ... expected 1, found 0, which names the problem precisely. Teaching a script to rewrite a security-relevant patch target would convert that loud failure into a silent one, so the updater only prints a reminder at the end of a full run: ## what this does not cover The GPG fail-closed path is untouched: the manifest is still rejected unless its detached signature verifies against the pinned release key, and the updater still writes nothing when it does not. The env reference block&apos;s contents are still hand-curated. The updater re-stamps the version and the count; it does not add newly documented variables to the list or drop removed ones. That was a human diff for 2.1.220 and it still is. Nothing checks that the curated list agrees with upstream&apos;s docs page either, which remains the gap called out when the extractor was made helper-agnostic. --registry-only was added alongside --prompts-only so the registry half can be re-run on its own, which is also how the guard above gets exercised without a network round trip.</description>
    </item>
    <item>
      <title>what a broken cache lane was hiding</title>
      <link>https://ix.dev/cache-push-first-green</link>
      <guid isPermaLink="true">https://ix.dev/cache-push-first-green</guid>
      <pubDate>Sun, 26 Jul 2026 04:30:00 GMT</pubDate>
      <description>what a broken cache lane was hiding. The cache-push lane spent eighteen hours unable to finish a single run. The cause was cores = 1: every build in CI compiled single-threaded because the workflow overrode a daemon setting the daemon cannot refuse. With that fixed and the realise sharded, the first run through looked like this: Thirteen seconds to realise forty roots, in a phase that had previously run for three hours and ten minutes and then been killed. The three shard jobs took 44, 45 and 51 seconds end to end. Every shard also failed, and that is the more interesting half. ## Two roots that had been failing on every merge A run that never reaches the end of its realise never reports what broke in it. Failed roots were recorded at warning level and the job was killed before the summary, so the lane had been rebuilding and re-failing the same derivations on every single merge without ever saying which ones. Nix does not cache failures, so this repeated indefinitely. The first run that got to the end named them immediately. whoami-serve, and through it the whole nixos-system-k3s-agent-1 closure: lib/util/writers.nix ran ruff check against a store path without --no-cache. Ruff puts .ruff_cache beside the files it checks, the store is read-only, so the check could never succeed. The repo&apos;s own repo-wide lint stage already passes --no-cache, with a comment giving this exact reason. The Python writer simply never got it, and nothing that ran regularly built these derivations. Then nix-cargo-unit: render_build_script_run_phase crossed clippy&apos;s limit. It crossed it in commit 551dfae7, &quot;run cbindgen build scripts offline against a vendored registry&quot;, which added an eighth parameter. That commit is on main. It merged, and the gate that would have caught it was the very lane that could not complete. ## The fix is a deletion The tempting fix is #[allow(clippy::too_many_arguments)]. The signature did not need it: The caller was passing a struct and, separately, one of that struct&apos;s own fields. Three other lines in the same caller already read build_script_run.compile_index directly. Dropping the parameter and reading the field inside the function gives seven arguments, one source of truth, and no way for the two to disagree. Clippy&apos;s argument limit is a blunt instrument and it is often worth suppressing. It is worth reading first, though, because a signature that has drifted past seven has usually drifted for a reason. ## What this says about broken gates A slow gate and a broken gate fail differently. A slow one costs time and everybody knows it is slow. A broken one stops being a gate, and nothing announces that: main keeps going green, the merge queue keeps moving, and regressions land through a check that has quietly stopped checking. The eighteen hours of red runs were the visible cost. 551dfae7 merging with a clippy error was the invisible one, and it is the one worth remembering. Before concluding a red lane is merely slow, it is worth asking what it was supposed to be catching, and whether it has caught anything lately.</description>
    </item>
    <item>
      <title>the Claude Code env extractor no longer pins the minifier&apos;s helper name</title>
      <link>https://ix.dev/claude-code-env-registry-helper-agnostic</link>
      <guid isPermaLink="true">https://ix.dev/claude-code-env-registry-helper-agnostic</guid>
      <pubDate>Sun, 26 Jul 2026 04:30:00 GMT</pubDate>
      <description>the Claude Code env extractor no longer pins the minifier&apos;s helper name. flake-check on main is green again. The 2.1.215 -&gt; 2.1.220 bump left packages/claude-code/env-registry.tsv stamped with the old version, and the documented way to fix that refused to run: The bundle layout did not change. The minified name of one helper did. ## esbuild&apos;s __export helper is a fresh two-letter name every release Claude Code ships as a Bun single-file executable with cli.js embedded as plain text. Every env var it reads is declared in a family of lazy ESM modules, each compiled to an export object of the shape &lt;helper&gt;(&lt;module&gt;,{NAME:()=&gt;ref,...}). extract-env-registry.py found those objects with a regex that spelled the helper out: In 2.1.220 the same call reads tt(hki,{no_proxy:()=&gt;kdh,.... Nothing else about the shape moved — same NAME:()=&gt;ref entries, same Pe.str() / Pe.bool() / Pe.int({min:1}) accessors, same eight modules in the same order. The one identifier the script pinned is the one identifier the minifier is free to renumber on any release, so this was going to fire again. The call target is now matched as any identifier and the discrimination is left entirely to the shape of the object literal, which is what was doing the real work already: strict reconstruction (the block must be NAME:()=&gt;ref entries and commas, nothing else), at least 30 entries, at least 90% of keys env-var-shaped, and at least one CLAUDE_/ANTHROPIC_/OTEL_ key. On the 2.1.220 binary that generic scan finds exactly eight modules, all under the helper tt, and zero false positives. ## before and after | | 2.1.215 | 2.1.220 | | --- | --- | --- | | registry modules | 8 | 8 | | rows | 812 | 830 | | unresolved accessor types (?) | 0 | 0 | Net +18, and the module ordinals are stable, so the diff reads as a normal release: MCP_DISCOVERY_CACHE{,_TTL_S,_MAX_STALE_S}, CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS, CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH, a pile of internal experiment gates with codenames (CLAUDE_CODE_HERON_TALLOW, CLAUDE_CODE_JUNIPER_SUNDIAL), and three names dropped (API_TOKEN, CLAUDE_CODE_OWNERSHIP_FRAME, CLAUDE_CODE_SKILL_DESC_REFRAME). The two new subagent caps are documented upstream, so they join the curated env reference block in packages/agent/home-manager/claude-code.nix; the codenamed gates stay TSV-only. ## a near-empty parse used to pass the gate checks.claude-code-knob-reference pins the TSV by version marker and nothing else. A parse that degraded from 830 rows to three would still carry the right marker and go green while gutting the reference it exists to guard. The extractor now refuses to emit in that case: - fewer than 500 rows total, or - any of eight long-lived documented knobs missing (ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL, ANTHROPIC_MODEL, CLAUDE_CODE_ENTRYPOINT, CLAUDE_CONFIG_DIR, DISABLE_TELEMETRY, HTTP_PROXY, MAX_THINKING_TOKENS). That second list is a smoke test, not a registry. The registry stays derived from the binary; these are names whose absence means the parse broke rather than that upstream deleted the variable. ## the failure message now hands over a lead &quot;the bundle layout changed&quot; told the next person nothing. Every candidate that gets past the entry-count threshold and is then rejected is now tallied by which filter rejected it, and the five largest near misses are printed with a key sample. Simulating a shape change (sentinel prefixes that match nothing) against the real 2.1.220 binary: The third line is the tell: 231 env-shaped keys sitting one filter away from acceptance says the objects are still there and the sentinel rule is what moved. That is the shape of information the old one-liner threw away. ## what this does not fix Nothing here re-cross-checks the curated env reference block against upstream docs beyond the two new documented names. The block still omits the OTEL_* family and ambient probes like SHELL and TMPDIR that appear in the docs page; that was a pre-existing editorial call and this change did not revisit it. The extractor is also still a regex over a 257MB blob rather than a parse. It survives an identifier rename now, but a genuine change to how esbuild emits lazy module exports would still need a human. It will at least say which filter to look at. The version bump itself is unchanged.</description>
    </item>
    <item>
      <title>every build in CI was compiling single-threaded</title>
      <link>https://ix.dev/ci-cores-one</link>
      <guid isPermaLink="true">https://ix.dev/ci-cores-one</guid>
      <pubDate>Sun, 26 Jul 2026 04:10:00 GMT</pubDate>
      <description>every build in CI was compiling single-threaded. The cache-push lane went eighteen hours without a single successful run. Three consecutive attempts died the same way: Three hours, ten minutes, two seconds each. A number that exact is not a build failure. It is services.ix.ciDispatcher.runnerMaxRuntimeSecs, which caps a runner unit at 190 minutes and sends it SIGTERM. The workflow declared timeout-minutes: 300, so it believed it had five hours and could never reach the ceiling it was written against. Every overrun surfaced as a bare 143 with nothing in the log naming a cause. That explains why the runs ended. It does not explain why they needed three hours in the first place, and that turned out to be the real defect. ## cores = 1 The workflow&apos;s NIX_CONFIG carried two lines: The comment above them explained the reasoning: the closure is wide, so parallelism should come from building many derivations at once rather than from threads inside any one build. Sound enough as far as it goes. The same env: block also stated the general posture, that the self-hosted runner&apos;s Nix daemon owns the tuning and the workflow only sets client-side eval knobs. Both things cannot be true at once, because cores is the one setting the daemon cannot own. Almost every Nix setting a client sends travels in a map that the daemon filters by trust. The CI runner UID is deliberately untrusted, so those settings are dropped with an ignored-setting warning, which is exactly what the posture above describes. But cores and max-jobs do not travel that way. They are dedicated fields of the SetOptions wire message, written positionally and then explicitly removed from the override map: and the daemon assigns them before it consults the trust flag at all: So the client always wins. cores = 1 overrode the fleet&apos;s cores = 16 on every dispatcher host, and every build in this repository&apos;s CI ran with NIX_BUILD_CORES=1. ## the fix that was already there The unpleasant part is that this exact failure had already been diagnosed and fixed, one layer down. From the dispatcher module, next to cores = 16: &gt; replacing both base&apos;s cores=0 (-j128 per build: a full 32-build batch is &gt; 4096-way thread oversubscription with unbounded aggregate compile RAM) and &gt; the old forced cores=1 (one thread per build: when the graph narrows to &gt; its critical path a gate crawls, e.g. ninja -j1 compiling nix-ix for &gt; ~50 min while max-jobs=32 sat idle, ix#7249). That fix was correct and had been inert for as long as the workflow line existed. A setting applied at the layer that owns the hardware, silently overridden by a line in a YAML file in a different repository. The controlled comparison was sitting in the fleet the whole time. The private monorepo has its own publish lane, cache-warm, running on the same runner pool, dispatched by the same service, against the same daemons. Its NIX_CONFIG sets neither knob, so the daemon&apos;s 16 applies. It finishes in twenty to sixty minutes, every run. ## why &quot;the closure is wide&quot; stops being true The original rationale holds while the graph is wide, and a realise starts wide. It does not end that way. The last roots have nothing left to run beside them, so they build one at a time, and at that point single-threaded is not a small tax on a parallel workload, it is the entire remaining runtime. Every realise ends in that tail. The wider the rebuild, the longer the tail. Three changes landed together, because fixing only the first would leave the lane one wide rebuild away from the same wall: cores and max-jobs come out of all eleven workflows, so the daemon&apos;s per-host values apply. timeout-minutes drops from 300 to 170, under the runner cap, so an overrun fails as a legible job timeout naming the shard instead of an opaque kill. And the realise is sharded across a job matrix, so the roots spread over the five dispatcher hosts rather than queueing on one. ## a ten-minute budget per derivation The lane also now sets timeout = 600: no single derivation may run longer than ten minutes. Unlike the two settings that came out, this one the daemon does accept from an untrusted UID. timeout, max-silent-time, poll-interval and connect-timeout are the explicit exceptions to the trust filter, on the reasoning that a client asking for less time cannot escalate anything. Ten minutes is a budget rather than an estimate for any particular package. The behaviour it replaces is one derivation quietly absorbing an entire runner lifetime with no line in the log naming it. A timeout fails that one root, names it, and lets --keep-going finish the others. The tempting way to grant an exception is a per-derivation attribute, some __buildTimeout = 3600 on the packages that need it. That is a trap worth naming, because it is the same one this repository&apos;s overlay was cleaned of earlier the same day. An attribute is part of the derivation, so it changes the output hash, so the package stops matching the binary cache and rebuilds from source on every host forever. The packages that genuinely need more than ten minutes are rustc, ghc, chromium: precisely the ones you most need to substitute. The exception has to live somewhere that is not part of any derivation. ## what to take from it An override is only as good as the layer that can enforce it. The dispatcher module reasoned carefully about threads per build, asserted its value in a deploy gate, and documented the incident that motivated it. None of that mattered, because the setting it chose is one of the two a client can always override, and a workflow in another repository did. Worth asking of any setting you tune centrally: is there a layer above this that can quietly win, and would anything tell me if it had?</description>
    </item>
    <item>
      <title>a dirty ix checkout broke home-manager switch outright</title>
      <link>https://ix.dev/starship-pin-dirty-checkout</link>
      <guid isPermaLink="true">https://ix.dev/starship-pin-dirty-checkout</guid>
      <pubDate>Sun, 26 Jul 2026 00:05:00 GMT</pubDate>
      <description>a dirty ix checkout broke home-manager switch outright. home-manager switch failed with no obvious connection to anything being switched: The trigger is having uncommitted work in ~/.config/nix/ix, which is the normal state while working in it. A path: flake input carries no lastModified when its checkout is dirty, and this read it unguarded, so the whole switch aborted over a decorative prompt segment. ## the comment was the bug The parameter it fed was documented as safe: That was true when written. ix later became a vendored submodule (ix.url = &quot;path:./ix&quot;, nix#147) and the comment did not follow. The sibling index pin already handles exactly this, and says why: So the fix was to stop treating the two differently. ixPinTimestamp (an epoch, ints.positive) becomes ixPinRepo (a path), and the segment shells out to git log -1 --format=%ct at prompt time like index&apos;s does. There is no eval-time input left to be missing, and the prompt now tracks a bump without a switch. ## the index pin had been dead for a while Reading the same code turned up a second one, silent rather than loud: That resolves to ~/.config/nix/index. index lives at ~/.config/nix/ix/index (ix#8119) and this path never followed the move, so it named a directory that does not exist. pinModule deliberately short-circuits a failing epoch substitution so a host without the checkout renders an empty segment instead of a bogus age, which meant this failure looked exactly like &quot;you don&apos;t have that checkout&quot;. The index pin age had simply been blank. cfg.paths.indexCheckout already held the correct path. The two bugs are opposites worth noticing together: one aborted a whole switch over a prompt decoration, the other silently rendered nothing for weeks. Neither is the behaviour you want from a status segment.</description>
    </item>
    <item>
      <title>cache-push runs ten shards, because the dispatcher now sizes them small</title>
      <link>https://ix.dev/cache-push-ten-shards</link>
      <guid isPermaLink="true">https://ix.dev/cache-push-ten-shards</guid>
      <pubDate>Sun, 26 Jul 2026 00:00:00 GMT</pubDate>
      <description>cache-push runs ten shards, because the dispatcher now sizes them small. The cache-push lane went from three parallel shards to ten. Nothing about the work changed; what changed is how much of a host each shard reserves. ## the fleet set the number, not the work A shard&apos;s parallelism was never limited by how many roots there are. The dispatcher classified every indexable-inc/index job as large unconditionally, and the large class reserves 128 GiB of a host&apos;s 176 GiB runner slice. One index job per host, five dispatcher hosts, five index jobs fleet-wide — shared with Check (the gating lane), CVE scan, and Update content. An earlier attempt at six shards plus a separate darwin-eval job was seven large-class jobs for a five-slot pool: they could not co-schedule, and they queued in front of Check. Three was the largest count that left the gate room. classify in ix crates/ci/dispatcher/src/handler/workflow_job.rs now routes any job whose runner label ends in -cache-push to the small class instead, keyed on the same suffix the push-credential gate already uses. Small is 24 GiB, so seven shards fit where one used to, and the constraint moved off the fleet. ## the eval budget is what makes small honest The two must move together. nix-eval-jobs --max-memory-size is a bound per worker, not a total, so the flake-check gate&apos;s --workers 16 --max-memory-size 6144 is a 96 GiB ceiling — and that product is exactly where index&apos;s measured 94.1 GiB peak and its large class came from. The shards now run --workers 3 against 4096 MiB, a 12 GiB ceiling, which fits 24 GiB with room for the checkout, the xargs fan-out and attic. Raising either number without raising the class OOMs the unit. Realisation is not in that budget at all. Builds execute in the nix-daemon cgroup (nixDaemonMemoryMaxBytes), not in the runner unit the class sizes. Only eval is charged to the shard, which is why capping eval is enough to make an otherwise heavy job small. ## what gated it The routing is a property of the leader, not of the fleet: classify runs only on the dispatcher leader (vin-compute-1), before worker selection, because the class is also the placement affinity key. So the widening was parked until that host actually carried the routing commit. It does now — prod deploy run 30184647173 converged with 13eecd8183, whose workflow_job.rs contains the -cache-push branch. ## not measured yet Whether ten shards actually balance. The shard count is read from strategy.job-total rather than duplicated, so rebalancing is a one-line edit, but no one has looked at per-shard wall clock to see whether the roots divide evenly at ten the way they did at three. The fleet holds roughly 32 small-class slots, so ten is well inside capacity; the open question is skew, not room.</description>
    </item>
    <item>
      <title>every Elixir package moves to 1.20 on OTP 29, and hex gets the darwin sandbox allowance that made it possible</title>
      <link>https://ix.dev/elixir-1-20-otp-29</link>
      <guid isPermaLink="true">https://ix.dev/elixir-1-20-otp-29</guid>
      <pubDate>Sun, 26 Jul 2026 00:00:00 GMT</pubDate>
      <description>every Elixir package moves to 1.20 on OTP 29, and hex gets the darwin sandbox allowance that made it possible. All seven Elixir packages in the repo now build against Elixir 1.20.2 on Erlang/OTP 29: agent-harness-ex, tui/ex, google/gmail/ex, mcp-ex, hive, plumb/ex, and unibind/conformance-ex. Nothing selects an older toolchain. ## the blocker was hex, not Elixir Every Elixir build on a Mac died before it compiled anything: Mix 1.19 added Mix.PubSub. Mix.Sync.PubSub binds @loopback {127, 0, 0, 1} at compile time to coordinate concurrent OS processes, unconditionally: there is no environment variable that turns it off. The darwin nix sandbox denies the bind. The Linux sandbox permits it, so CI never saw this and the failure only ever reproduced on a developer machine. nixpkgs’ beamPackages.hex does not set __darwinAllowLocalNetworking, and hex is itself built with mix, so hex is the first thing to die and every package that needs it dies behind it. lib/build/elixir-hex.nix is a two-line override that adds the allowance: Three call sites had the same beamPackages.hex.override written out longhand: lib/build/elixir-check.nix, packages/agent-harness-ex, and packages/mcp-ex. They share ix.elixirHex now, so the allowance cannot be added to two of them and forgotten on the third. The real fix belongs upstream, in nixpkgs’ beamPackages.buildMix and hex, which should carry the allowance for any Elixir at or past 1.19. This override is carried until that lands. ## a bitstring size variable must be pinned from 1.20 IxMcp.Utf8 walks backward from a byte offset to find a codepoint boundary, and matched with binary-size(cut_at) where cut_at came from the function head. Elixir 1.20 reads an unpinned name inside size(...) as a new binding rather than the existing value, so the match now needs ^cut_at: ## the comments that a mechanical bump erased An earlier 1.18 to 1.19 sweep across five files rewrote the version strings and left the comments explaining why 1.18 was required. One of them, in packages/mcp-ex/default.nix, described the exact failure the sweep was about to cause: &gt; 1.18 rather than 1.19: Mix 1.19&apos;s PubSub opens a loopback TCP socket at &gt; compile time, which the darwin sandbox denies (:eperm). Two more, in tui/ex and google/gmail/ex, recorded a runtime constraint no build catches: ix-mcp-ex loads those .beam files at runtime, and the BEAM will not load bytecode from a newer OTP than its own, so the three have to move together. They do, and the comments now say that rather than naming a version. ## not done ix-mcp-ex-check still fails two of 181 tests, both in IxMcp.Memory.SemanticTest: This is not caused by the toolchain move. Building the same package at Elixir 1.19 with the hex fix applied fails identically, so the pinned weave binary predates semantic recall and the failure is independent of the Elixir version. The green result on the previous commit was a cache hit, not a fresh run.</description>
    </item>
    <item>
      <title>two mcp-ex tests blamed weave for four days; the fixture&apos;s shebang was unrunnable in the sandbox</title>
      <link>https://ix.dev/port-exec-failure-is-not-a-bad-answer</link>
      <guid isPermaLink="true">https://ix.dev/port-exec-failure-is-not-a-bad-answer</guid>
      <pubDate>Sun, 26 Jul 2026 00:00:00 GMT</pubDate>
      <description>two mcp-ex tests blamed weave for four days; the fixture&apos;s shebang was unrunnable in the sandbox. IxMcp.Memory.SemanticTest failed two of its tests in every sandboxed build: No weave was involved. The suite mocks the binary with test/fixtures/weave_mock.exs, and the mock never ran. ix-mcp-ex-check now reports 181 passed, 2 excluded. ## exit status 2 from a port means execve failed Port.open({:spawn_executable, bin}, ...) asks the kernel to run a file, so the kernel resolves its shebang. When that resolution fails the port closes with a small status and produces no output at all, which is indistinguishable from a program that started and exited badly. Measured directly, with a file whose shebang names a path that does not exist: The mock was spelled #!/usr/bin/env elixir. A nix build sandbox has no /usr/bin/env, so the mock never started, and the raise in lib/ix_mcp/memory/semantic.ex attributed the silence to a weave too old for semantic recall. ## pointing at elixir directly does not fix it Rewriting the shebang to the absolute interpreter moved the status from 2 to 8 and no further. nixpkgs ships bin/elixir as a bash script: The hash is redacted on purpose. This repo is fetched whole as a fixed-output derivation wherever it is a git dependency, and nix refuses an FOD whose output names a store path in the fetch&apos;s own closure: a real bash path pasted into any tracked file fails that fetch with fixed-output derivations must not reference store paths, which breaks every consumer&apos;s eval, not this page. No kernel follows a shebang into another shebang. The test now writes a /bin/sh trampoline into its temp store and spawns that. /bin/sh is the one interpreter a nix sandbox always provides, and it is a real binary. ## the error message was the expensive part The raise named a cause it had not established. It now distinguishes the two cases: ## not done The suite still warns that test/fixtures/weave_mock.exs matches none of Elixir 1.19&apos;s :test_load_filters / :test_ignore_filters. That is noise, not a failure, and no filter was added for it. No other fixture in the repo uses a #!/usr/bin/env shebang, so no sweep was needed, but nothing stops the next one from being written that way.</description>
    </item>
    <item>
      <title>bare cargo could not run in index at all, because ix&apos;s config leaks in</title>
      <link>https://ix.dev/index-cargo-features-inherit</link>
      <guid isPermaLink="true">https://ix.dev/index-cargo-features-inherit</guid>
      <pubDate>Sat, 25 Jul 2026 23:50:00 GMT</pubDate>
      <description>bare cargo could not run in index at all, because ix&apos;s config leaks in. Every bare cargo command in this repo failed, debug builds included: index is a git submodule of ix, and cargo merges every .cargo/config.toml from the working directory up to the filesystem root. So cargo inside index reads ../.cargo/config.toml, which sets: That key is gated behind the unstable profile-rustflags feature. ix opts into it at the top of its Cargo.toml, and that declaration does not reach this workspace, so cargo saw the key without the opt-in and aborted before doing anything. One line in index/Cargo.toml fixes it: cargo check now runs in 2.32s. The flags themselves are --remap-path-prefix entries for /app and /opt/cargo and are inert on a developer checkout, where those paths do not exist. ## what this cost before it was found Adding a workspace member without cargo means adding its Cargo.lock entry by hand, which is what happened for packages/submodule-sync-test. Every attempt to let cargo do it hit the parse error, so the entry was written manually and verified by a full nix build. That is a slow loop for something cargo does instantly. ## the leak this does NOT fix The same parent config sets: A child config cannot unset a parent key, so bare cargo in index still cross-compiles to musl by default and fails on ring, aws-lc-sys and libmimalloc-sys. Pass the host triple for a local check: Both facts are recorded in a comment above the new line rather than left for the next person to rediscover. None of this touches packaged builds. Those run in a Nix sandbox from a store path, which has no parent directory to inherit from. Confirmed by rebuilding .#submodule-sync-test before and after: the same output path, 01l447vj502n1r3x6jzfk3v9kxnhj84g, so the declaration is build-neutral.</description>
    </item>
    <item>
      <title>Claude Code bumps to 2.1.220</title>
      <link>https://ix.dev/claude-code-2-1-220</link>
      <guid isPermaLink="true">https://ix.dev/claude-code-2-1-220</guid>
      <pubDate>Sat, 25 Jul 2026 23:45:00 GMT</pubDate>
      <description>Claude Code bumps to 2.1.220. The vendored Claude Code moves from 2.1.215 to 2.1.220 via the signed updater: nix run .#claude-code.updateScript fetched Anthropic&apos;s per-version manifest.json, verified its detached GPG signature against the pinned release key, and rewrote the per-platform SRI hashes in packages/claude-code/manifest.json. All four platforms moved together (darwin-arm64, darwin-x64, linux-x64, linux-arm64). The updater fails closed, so an unverifiable signature would have left the manifest untouched rather than pinning hashes for a binary nobody checked. ## the updater is slower than it looks Worth knowing before you run it: claude-code-update declares nix in its runtimeInputs, and in this repo nix is the patched nix-ix. On a machine without those paths cached that means building nix from source before the updater does any work at all: On aarch64-darwin there is no remote builder to offload to, so it compiles locally. The updater itself only needs nix hash convert, so the whole nix-ix closure is heavier than the job requires. Not fixed here, just recorded. The stock system-prompt snapshots are unchanged by this bump.</description>
    </item>
    <item>
      <title>git stops writing a commit-graph on every fetch</title>
      <link>https://ix.dev/git-commit-graph-chain</link>
      <guid isPermaLink="true">https://ix.dev/git-commit-graph-chain</guid>
      <pubDate>Sat, 25 Jul 2026 19:40:00 GMT</pubDate>
      <description>git stops writing a commit-graph on every fetch. git pull --rebase in ~/.config/nix started failing outright: The object store was fine. git fsck --connectivity-only passed, git log worked, and every ref resolved. Only the cache was bad, which is the annoying case: nothing is lost, but the commands you need most stop working, and the error names a file rather than a cause. Three settings in the base profile combined to produce it, and each one is reasonable alone. fetch.writeCommitGraph writes a split commit-graph. That is what the option does: rather than rewriting the whole graph after each fetch, it drops one small increment on top of the existing chain. Cheap per fetch, and it keeps the graph current for the next git log. maintenance.auto = false stops git from ever compacting that chain. That setting is not an accident either. It is there because auto maintenance detaches an uncapped git repack --cruft, and at agent-fleet commit volume those stack without bound: 1,033 of them piled up in about twelve minutes and swapped a 128 GB workstation (ix#8161, #4001). maintenance.repo then covers only repos explicitly registered with git maintenance register, and ~/.config/nix is not one of them. So the chain grows by one file per fetch and nothing ever merges it. This checkout reached five chained graph files, and at five it broke. The failure looks like corruption because it is, but the cause is that the chain was never allowed to end. Deleting the chain does not fix it for long. The next fetch writes a fresh increment immediately, which is why the first repair attempt appeared to work and then failed again on the very next pull. The manual repair is one command, and it is worth knowing because it collapses the chain to a single verified file: The fix here is to stop appending. fetch.writeCommitGraph comes off, and gc.writeCommitGraph stays on: gc rewrites the graph whole rather than appending to a chain, so the read speedup that made the setting attractive survives without the part that grows without bound. Pairing matters more than the individual settings. A cache that is only ever appended to needs something that compacts it, and turning off auto maintenance removed that something for every repo on every host. The two settings were added for different reasons at different times, and neither is wrong on its own.</description>
    </item>
    <item>
      <title>import ctypes works again on macOS 27</title>
      <link>https://ix.dev/libffi-macos-27-ctypes</link>
      <guid isPermaLink="true">https://ix.dev/libffi-macos-27-ctypes</guid>
      <pubDate>Sat, 25 Jul 2026 05:30:00 GMT</pubDate>
      <description>import ctypes works again on macOS 27. On macOS 27, every nixpkgs Python aborted the moment it imported ctypes: nixpkgs on Darwin builds Apple&apos;s libffi (libffi-40, from opensource.apple.com) instead of the upstream project. Apple&apos;s arm64 closure code cannot map executable memory on demand, so it dlopens a companion libffi-trampolines.dylib holding pre-built trampoline pages, and nixpkgs links its own copy from src/aarch64/trampoline.S. That copy carried an LC_DYLD_CHAINED_FIXUPS header whose seg_count was larger than the number of segments in the binary. dyld on macOS 26 ignored the mismatch. dyld on macOS 27 rejects the load with chained fixups, seg_count exceeds number of segments, dlopen returns NULL, and the assert fires. The symptom looked nothing like the cause. A home-manager switch died building h5py 3.15.1, which runs build_ext under a Python that imports ctypes, making it the first derivation in the closure to hit the abort. Nix prints the last 25 log lines on failure, and all 25 were copying h5py/tests/... from the phase before, so the assert never appeared in the error at all. nix log on the drv has it. nixpkgs #541990 adds -Wl,-no_fixup_chains to the trampoline link, which drops the malformed header. The nixpkgs input here moves from 61b7c44c (2026-07-17) to e2587cae (2026-07-23) to pick that up. Everything downstream follows index/nixpkgs, so the workstation config and the fleet inherit the fix from this one bump. That bump also carries git 2.54.0 to 2.55.0, and there is no rev in between: nixpkgs bumped git on 2026-07-11, four days before the libffi fix merged, so any tree with the fix already has the new git. packages/git asserts that nixpkgs&apos; git version matches the git-src pin, so the single ix patch (linked worktrees borrowing the common-dir submodule object store, #3610) is rebased onto the v2.55.0 tag. The rebase lands on a new branch, ix-patched-v2.55.0, instead of rewriting ix-patched. git-src is pinned by rev, and force-pushing the old branch would eventually let GitHub collect the old commit and break every index rev still pointing at it. The bump has one more piece of fallout. msgpack moved to 1.2.1, and mitmproxy 12.2.3 declares msgpack&gt;=1.0.0,&lt;=1.1.2, so pythonRuntimeDepsCheckHook refused the wheel: There is no newer mitmproxy to move to. 12.2.3 is the latest release, and nixpkgs master still hits this, since its recipe relaxes eight other mitmproxy pins through pythonRelaxDeps and has not reached msgpack. The ceiling itself is stale. Upstream deleted the msgpack dependency outright after 12.2.3, and relaxing the cap locally builds clean with mitmproxy&apos;s own suite at 1776 passed and 1 skipped against msgpack 1.2.1. So a one-line pythonRelaxDeps override would have worked. The reason it is not the fix here is the pattern behind it. mitmproxy generates an upper bound for every dependency from its uv.lock, most of them pinned to a single version: That is a lockfile-first policy which assumes one private environment per tool, and it cannot hold against a distribution that ships one version of each library. nixpkgs already carries aioquic_1_2 as a version-pinned attribute for this exact recipe. Every nixpkgs bump that moves a library past one of those ceilings breaks the build again, so the override would be permanent maintenance rather than a fix. mitmproxy is therefore no longer a nix package. uvx mitmproxy gives it the private environment its dependency policy expects, resolves msgpack to 1.1.2 inside that environment, and leaves the shared python set at 1.2.1 for everyone else. It also brings its own CPython, so it sits outside the libffi problem this page opens with. Verified on this host: mitmproxy 12.2.3 on Python 3.12.13, macOS-27.0-arm64, seven seconds cold. The ~/.mitmproxy out-of-store symlink stays. uvx mitmproxy reads the same directory, so the private CA is not regenerated and does not have to be re-trusted. Behind mitmproxy sat a second failure, which only became visible once mitmproxy stopped aborting the build first. gunicorn 26.0.0 has a fixture that asks the kernel for a free port: The Darwin build sandbox has no loopback interface unless a derivation opts in with __darwinAllowLocalNetworking, so the fixture errors at setup and the build fails. Linux sandboxes do provide loopback, which is why nixpkgs ships it this way: the recipe already disables two sibling integration tests for the same class of failure, &quot;failure while starting a gunicorn instance&quot;, and simply never needed the sandbox flag on the platform its CI runs. lib/overlay.nix sets the flag on Darwin only, so the Linux build keeps matching cache.nixos.org. Setting it beats skipping the test: the suite then runs in full at 1874 passed and 350 skipped, with test_asgi_worker.py exercised rather than deselected. Nothing wants gunicorn directly. It arrives as a test input of aiohttp, which the python env depends on, so a package nobody asked for failing its own test suite is enough to stop a home-manager switch. dunamai stopped the next attempt for a different reason. One of its tests commits to a scratch repo and asserts the commit timestamp is within a minute of now: That minute is a wall-clock budget for the test&apos;s own setup, and a busy machine loses it. This host had ten other agents building, the suite took 194 seconds, and the commit was 72 seconds old by the time the assertion ran. 55 other cases passed. The overlay widens the window to 30 minutes rather than deleting the test, since dunamai read the timestamp correctly and only the freshness bound was wrong. The suite is then 56 passed and 6 skipped. Machine load is not usually thought of as an input to a build, but a test with a wall-clock budget makes it one. ipython is the same story with a worse disguise. test_system_interrupt starts a subprocess that sleeps five seconds and asserts a SIGINT interrupts it. Under load the pexpect spawn behind that fails, and ipython&apos;s own error path then reads child before it is assigned, so the case dies as: which looks like a code bug rather than a timeout. 1592 other cases passed and the suite took twelve minutes. There is no bound to widen here, so the case joins the list nixpkgs already keeps in this recipe for exactly this reason, labelled &quot;timing sensitive&quot;, which holds test_debug_magic_passes_through_generators and test_nest_embed and had simply not reached this one. Four failures came out from behind the libffi fix, and none of them were caused by it. A stale dependency ceiling, a sandbox without loopback, and two tests with wall-clock budgets were all sitting in the closure already. Each became visible only when the one in front of it stopped aborting the build first. Behind all four sat the actual cost. With the failures cleared, 947 of the 1205 remaining derivations turned out to be Haskell: GHC 9.10.3 itself, then QuickCheck, tasty, HUnit, prettyprinter, optparse-applicative, ghc-exactprint. Nothing was substitutable, because packages/nix-output-monitor patches haskellPackages with a forked nix-derivation, and patching the set invalidates all of it. cache.nixos.org has the unpatched set and cache.ix.dev only carries revs index CI has already built, so a nixpkgs bump means compiling a Haskell toolchain from source on the workstation. That fork exists for a small reason: upstream nom cannot parse content-addressed derivations and spams DerivationParseError on this repo&apos;s builds. A GHC bootstrap per nixpkgs bump is a steep price for quieter build output, so the workstation now takes the plain nixpkgs nix-output-monitor that the base profile already ships, which substitutes as a single 8.9 MiB download. nom and nh keep working and the parse noise comes back. The programs.nh override mattered as much as the package entry. nh puts its own nom on PATH ahead of home.packages, so it had been rewired to the patched fork; leaving that override in place would have kept pulling GHC in even with the home.packages entry commented out. Dropping the fork did not remove GHC, though. nix why-depends puts it behind something else entirely: shellcheck is Haskell and nixpkgs builds its man page with pandoc. Plain nixpkgs shellcheck substitutes as 14.4 KiB of fetches, so the question was never why we want Haskell. It was why ours does not match the cache. Comparing the two shellcheck derivations input by input answers it. They have the same five inputs and an identical stdenv-darwin; the divergence is one patch, ten levels down: GHC&apos;s build needs Sphinx, Sphinx pulls git-minimal, and git-minimal links curl. So the curl wakeup patch sits inside GHC&apos;s own build closure, and every Haskell package follows. The same patch reaches rustc-bootstrap, cargo-bootstrap and the cargo vendor tooling. That patch is worth keeping: curl 8.21.0 eats the curl_multi_wakeup() eventfd from curl_multi_perform() and can stall Nix&apos;s file-transfer worker for its full ten-second idle timeout. But its real cost is not the rebuild time. It is that we compile packages nixpkgs ships prebuilt, so their test suites run here, on macOS 27, under whatever load the machine has. Six packages failed that way in one switch, and five of the six failed on a hardcoded time budget: 60 seconds for a git commit, 3 seconds for a CTest case, 70 milliseconds for an event-loop timer. None of them was broken. The patch is kept and the cost is not. It applies to one component now: modular/src/libstore/package.nix is the only nix component that takes curl as an input, and libstore owns the file-transfer worker the regression stalls, so scoping it there keeps the fix exactly where the stall happens. Everything else goes back to matching cache.nixos.org. packages/nix/default.nix already had the seam: the same overrideScope that wires wasmtime into libexpr for builtins.wasm. Measuring first is what changed the shape of this. Against nixpkgs e2587cae on aarch64-darwin, nix-store --query --requisites puts curl-8.21.0-fix-multi-wakeup.patch.drv inside arrow-cpp (2376 input drvs), souffle (1574) and erlang, and plain nixpkgs fetches all three as binaries: arrow-cpp at 28.5 MiB, souffle at 2.8 MiB. torch is 75.7 MiB, pyspark 433.6 MiB, ray 70.8 MiB. packages/mcp/default.nix says as much in a comment already written against ps.torch, &quot;torch substitutes from the official cache&quot;, which was true when it was written and had quietly stopped being true. Ten of the overrides on this page are gone with the cause: graphite2, harfbuzz, gunicorn, requests-futures, watchfiles, dunamai, ipython, inquirer3, uvloop and the python valkey client. All ten substitute at this rev, so none of their suites runs here any more. lib/overlay.nix drops from 330 lines to 119. That leaves the trap worth naming, because it hides itself. An override changes the derivation hash, which forces the local build, which runs the suite, which is what the override was working around. Adding one always looks like it fixed something, and it did, but it also created the work it fixed. Two of these, graphite2 and harfbuzz, were not Darwin-gated the way the python ones were, so they were detaching the Linux tree for CI and the fleet as well, with pango and gtk sitting under harfbuzz. The valkey server override stays. That failure was reproduced five consecutive times from source across two Linux machine classes, loaded and idle, sandboxed and unsandboxed, so it is not an artifact of this host. Publishing to cache.ix.dev from index CI is still worth doing, and it is now a smaller claim: it would cover the paths that genuinely have no upstream binary, rather than paying for a bootstrap we were creating ourselves.</description>
    </item>
    <item>
      <title>Every VM now ships the tools people actually reach for first</title>
      <link>https://ix.dev/base-image-user-baseline</link>
      <guid isPermaLink="true">https://ix.dev/base-image-user-baseline</guid>
      <pubDate>Sat, 25 Jul 2026 01:55:00 GMT</pubDate>
      <description>Every VM now ships the tools people actually reach for first. A fresh ix/base VM could walk a kernel struct out of a core dump but could not run python3 -c &apos;print(1)&apos; or extract a .zip. The base profile was written for debugging appliances; ix run&apos;s default image is where people and agents actually land. Every VM now carries the consensus baseline of the sandbox market (surveyed across E2B, Daytona, Modal, Codespaces, Vercel, and Cloudflare defaults): git on the system PATH for every user, not just root&apos;s profile, plus python3, node, uv, gcc, make, unzip, zip, wget, dig, sqlite3, openssl, tree, tmux, and friends. Two paper cuts went with it. The first git commit no longer dies on identity auto-detection: /etc/gitconfig carries a lowest-precedence fallback identity that any real git config wins over. And a missing command in bash, zsh, or fish now prints the escape hatch instead of a bare error: nix run nixpkgs#&lt;tool&gt; resolves from the warm cache.ix.dev in about a second, which was true before but invisible.</description>
    </item>
    <item>
      <title>the BEAM toolchain tables name beamPackages only, because a deprecation warning is a build failure here</title>
      <link>https://ix.dev/beam-toolchain-beam-packages-only</link>
      <guid isPermaLink="true">https://ix.dev/beam-toolchain-beam-packages-only</guid>
      <pubDate>Sat, 25 Jul 2026 00:00:00 GMT</pubDate>
      <description>the BEAM toolchain tables name beamPackages only, because a deprecation warning is a build failure here. ix.languages.elixir.toolchain and ix.languages.erlang.toolchain map a version string onto a nixpkgs attribute. Every entry now comes out of a beam package set (beamPackages.elixir_1_19, beam28Packages.erlang), and every version that cannot instantiate against the current OTP is gone from the tables: Elixir 1.15, 1.16 and 1.17, and Erlang/OTP 26. Every consumer in the repo moved to the newest pair, Elixir 1.19 on OTP 28. ## why a deprecation is not advice Nixpkgs deprecated the top-level BEAM attributes on 2026-06-15: warnAlias is lib.derivations.warnOnInstantiate, which wraps every attribute of the derivation except meta, name, type and outputName in lib.warn. So the warning does not fire when the attribute is looked up. It fires the moment anything forces drvPath, outPath, or an output attribute — which is exactly what a buildEnv does when it collects a profile. This repo and ix both set abort-on-warn, so that is not a warning, it is a hard eval failure of whatever host or image reached it. That is how it surfaced. All six dev-compute- hosts in ix stopped evaluating config.system.build.toplevel.drvPath with nothing but and a trace whose only ix-shaped frame was systemd.services.home-manager-hari.serviceConfig. Evaluating pkgs.elixir_1_19 on its own looked fine, because the interesting frames were home-manager-path → buildenv/default.nix:123 → drv.${outName}. The package came from a third-party pin (harivansh-afk/nix, whose modules/users/user-config/packages.nix puts elixir_1_19 on hari&apos;s profile), so ix carries a scoped shim at the boundary where it hands that tree a package set, guarded by a check that throws once the pin stops needing it. ## the removed versions were worse than deprecated pkgs.elixir_1_15 and pkgs.elixir_1_16 are throws, not warnings — nixpkgs dropped them on 2026-04-01 along with erlang_26 as EOL. elixir.nix still advertised both in its version table and its docstring, so anyone who took the documentation at its word got a nixpkgs removal message rather than this repo&apos;s own &quot;unknown version&quot; error listing what is actually supported. They are gone from the table and the docs. ## 1.17 was the trap in the obvious fix Rewriting pkgs.elixir_1_17 to pkgs.beamPackages.elixir_1_17 looks like the same mechanical change as 1.18 and 1.19, and it is wrong. Elixir 1.17 asserts its Erlang range, and beamPackages now tracks OTP 28: So the rewrite trades a deprecation warning for a hard assertion failure. Keeping 1.17 would mean pinning it to beam27Packages specifically, a different shape from every other entry, to carry a version nothing in the repo selects. It is dropped instead. The beamPinnedDrvPaths assertion below is what caught this, one step after the rewrite that introduced it. ## every consumer is on the newest pair Five packages were still asking for Elixir 1.18 on OTP 27: agent-harness-ex, tui/ex, google/gmail/ex, mcp-ex, and hive. All seven Elixir packages in the repo declare elixir: &quot;~&gt; 1.18&quot; in mix.exs, which means &gt;= 1.18 and &lt; 2.0, and plumb and unibind/conformance-ex already ran 1.19 against that same constraint. So the five moved to Elixir 1.19 on OTP 28 and nothing selects an older toolchain now. Elixir 1.18 and OTP 27 stay in the tables. They are one release behind current and still supported upstream, so dropping them would remove working capability rather than cruft. ## the guard tests/default.nix now forces drvPath for every advertised minor — Elixir 1.18/1.19, Erlang 27/28 — rather than only the latest alias it checked before. Forcing is the point: a table entry spelled pkgs.elixir_1_ resolves fine until something forces it, and then it kills the eval of a host nobody was thinking about. Reverting elixir.nix to the old spelling makes that assertion abort with the deprecation warning. ## not done The upstream spelling in harivansh-afk/nix has not been fixed; ix carries a local shim instead, and that repo&apos;s main still spells elixir_1_19 (it has since moved the reference into lib/nvim-packages.nix). No sweep was run for other top-level nixpkgs aliases this repo may still name — the class here was enumerated from the BEAM alias block in pkgs/top-level/aliases.nix, not from the repo.</description>
    </item>
    <item>
      <title>a failed cache-push root now prints its builder log, and ix-site builds again</title>
      <link>https://ix.dev/cache-push-prints-builder-logs</link>
      <guid isPermaLink="true">https://ix.dev/cache-push-prints-builder-logs</guid>
      <pubDate>Sat, 25 Jul 2026 00:00:00 GMT</pubDate>
      <description>a failed cache-push root now prints its builder log, and ix-site builds again. ix-site-0.1.0 failed to realise on three consecutive main merges, and the entire diagnosis available in CI was this one line: That is the whole story the job log told, in runs 30179574924, 30180017487 and 30180206346. The lane knew which root was broken and published a correct, loud error saying so. It had no way to say why, so the root stayed broken and the lane stayed red. ## why the cause was invisible The realise step printed every line of realise.err matching error:. For a build failure nix writes exactly one such line — Cannot build &apos;&lt;drv&gt;&apos; — and sends the builder&apos;s own output to the nix log store instead of to stderr. So the compiler output, the test output, the stack trace, whatever actually failed, was never in the job log for anyone to read. Reproducing the build by hand was the only way to see it. The step now runs nix log for each derivation nix named and prints a bounded tail next to the root name, inside a collapsed group. Bounded on both axes — 200 lines per derivation, 5 derivations per shard — because a builder that fails late after a chatty phase would otherwise bury every other annotation in the run, and the shard reports how many logs it withheld. It keys on the quoted store path rather than on a phrasing, so all four ways nix words this are covered: The dependency case is the one that matters most and the one a naive fix gets wrong: failed-roots.txt is written by the xargs child and only knows the root, whose own log is empty when the break is underneath it. Harvesting the drv nix named picks the dependency, which is where the log actually lives. ## what ix-site was actually failing on Two minutes of nix log on the failing drv, which is now what CI prints: One markdown link. Updates are routed at /&lt;id&gt;, not /updates/&lt;id&gt;, so the committed-artifact change in ifd-ix2nix-wasm.svx pointed at a path this site has never served. SvelteKit&apos;s prerender crawler follows in-page links and treats a dangling one as fatal, so one wrong path prefix took down the whole site build, which took down the shard, which held the cache-ready pin. Fixed to /committed-ix2nix-wasm/ — trailing slash to match trailingSlash = &apos;always&apos; in the root layout. packages.x86_64-linux.site realises green again. ## what this does not fix The gate still does not build the site. nix run .#lint&apos;s site-ids stage front-loads the pure file-scan invariants (#3669), but referential integrity is not one of them, so a broken in-page link still reaches main and still fails there rather than on the PR. No lint stage was added for it: the entire site corpus contains exactly one absolute in-page markdown link, the one this change fixes, so a link checker would guard nothing today while carrying a route inventory that drifts. The real repair is building the site in the Check gate. Correction, same day. The first sentence of that paragraph is wrong. The Check gate does build the site: requiredGateRoots.x86_64-linux.closure-site and packages.x86_64-linux.site are the same derivation, and flake-check on 4fb45b3a failed on this exact 404 half an hour before cache-push did. What was missing is that the commit was pushed straight to main, so the verdict arrived after the fact, and flake-check was already red for an unrelated root. The link check landed in lint after all, deriving its route inventory rather than listing one; see the follow-up. The other roots seen failing in the same lane — search-eval, nixos-system-kernel-build-*, nixos-system-dev — were not investigated. They will now print their own builder logs on the next merge, which is the point.</description>
    </item>
    <item>
      <title>two clippy gates were red on main, and the log tail hid why</title>
      <link>https://ix.dev/clippy-red-on-main</link>
      <guid isPermaLink="true">https://ix.dev/clippy-red-on-main</guid>
      <pubDate>Sat, 25 Jul 2026 00:00:00 GMT</pubDate>
      <description>two clippy gates were red on main, and the log tail hid why. flake-check on main was failing rust-submodule-sync-test.clippy and rust-ix2nix.clippy. Both are green again. Neither lint was silenced. ## what was actually wrong The GitHub annotation for a failed nix build is the last 25 lines of the builder log, which for a clippy check is the shell hook trace after the diagnostics have already scrolled past. Nothing in run 30183590379 named a lint. Reproducing was the only way to find out, and nix log on the derivation is no help either, because failed builds have no cached log. submodule-sync-test tripped three lints: - clippy::anonymous_tuple_return_type on fn pinned(&amp;self, name) -&gt; Result&lt;(String, String)&gt;. A caller reading let (gitlink, locked) = fx.pinned(&quot;index&quot;)?; has to trust the order. It returns a named Pinned { gitlink, lock } now. - clippy::too_many_lines on a main() measured at 181 lines against a limit of 100. The three behaviours the test pins are three named functions -- stale_pins_advance_together, current_pins_are_a_noop, lost_race_retries_onto_new_tip -- over a Fixture that owns its own setup and holds the pinned sources as a Vec rather than one copy of the setup per source. - clippy::literal_string_with_formatting_args on the embedded git shim, which spelled its positional parameters &quot;${1:-}&quot;. A braced parameter expansion inside a Rust string literal reads to clippy as a stray format argument. The braces were only there to survive set -u, so the shim tests its arity and uses &quot;$1&quot;. ix2nix tripped clippy::format_push_string four times in tests/properties.rs, all of the shape out.push_str(&amp;format!(&quot;{key}: {value}, &quot;)). They are write! and writeln! into the same String now. One assertion got wider on the way through. The race case previously checked the second source only for &quot;unchanged&quot; and the no-op case checked neither pin at all. assert_pinned_at_tips walks every source on every case, so a pin that moves when nothing asked it to now fails wherever it happens. ## why it surfaced when it did The ix2nix violation is older than the failure. tests/properties.rs landed on 23 July and the check passed, from cache, on a clippy built before the fork inputs were repinned. A cargo unit&apos;s source is its whole package directory, so moving the .ix converter back to IFD -- which only edited import-ix.nix and wasm/default.nix -- re-keyed the unit and re-ran a check that had been answered from cache since before the repin. The lint had been firing for anyone who ran it; nobody ran it. That is the general shape worth remembering: a green cargo-unit check means &quot;green at the last source hash&quot;, not &quot;green today&quot;. A toolchain bump does not invalidate a cached check result until something else touches the unit. ## what is still awkward Reproducing a clippy failure on darwin does not work locally. Stock cargo clippy dies with E0602 on the two lints the indexable-inc/rust-clippy fork adds, and nix eval of any Rust unit&apos;s tests attribute fails on aarch64-darwin before it gets that far, because building the test manifest runs a natively built unibind that cannot find its own libstd dylib. The route that works is nix build of the check derivation for x86_64-linux, which lands on a remote builder and takes tens of minutes.</description>
    </item>
    <item>
      <title>the .ix converter goes back to IFD, and the committed copy is gone</title>
      <link>https://ix.dev/ifd-ix2nix-wasm</link>
      <guid isPermaLink="true">https://ix.dev/ifd-ix2nix-wasm</guid>
      <pubDate>Sat, 25 Jul 2026 00:00:00 GMT</pubDate>
      <description>the .ix converter goes back to IFD, and the committed copy is gone. lib/ix2nix.wasm is deleted. index.lib.importIxWasm now reads &lt;out&gt;/lib/ix2nix.wasm from the ix2nix-wasm package, so importing a .ix module is import-from-derivation again. This reverses the committed-artifact change from two days earlier. ## what the committed copy actually cost The artifact was committed so that a .ix import realized nothing mid-eval: offline evals, no builder, no substitution. That part worked. The price was a binary in the tree that had to be regenerated by hand, and the regeneration trigger was far broader than &quot;the converter changed&quot;: The built bytes embed the ix2nix unit-source store path in two panic-location strings, and that unit source is the whole packages/ix2nix directory. Any edit anywhere under packages/ix2nix, any toolchain bump, and any nixpkgs bump re-keyed the artifact and reddened ix2nix-wasm-fresh. The gate names the fix in its own failure message, and the fix still did not happen on its own: flake-check sat red on main until someone ran nix run .#ix2nix-wasm-regen by hand. A gate that fires on unrelated edits, and whose repair is a manual command, converges only as often as someone is paying attention. ## what it costs now This is a real trade, not a free win, and the cost lands on exactly the case the committed artifact was introduced to protect. An eval that touches a .ix file needs the wasm32 output, so a Mac needs a warm cache.ix.dev or a remote builder. Evaluating packages.aarch64-darwin.cve-scan.name on a cold store now does this first: That is #4125/#4127 undone: those introduced the committed artifact to keep ix init scaffold evals off the wasm32 graph, and scaffold evals are back on it. The wasm drvs are already harvested as explicit cache-push roots through workspacePackageIfdRoots, which went from a nicety to load-bearing in the same change, because every .ix eval now forces that graph rather than only the pre-#4125 scaffolded flakes that interpolated the package output themselves. ## the build-host pin is load-bearing importIxWasmFor ignores its argument and always asks for the x86_64-linux build. That is not a cache optimization. The wasm32 artifact is not bit-identical across build hosts: the native toolchain&apos;s store path feeds -C metadata, so symbol hashes differ per host. Drop the pin and a Mac builds its own converter, and .ix modules convert through different bytes locally than in CI. The committed artifact carried the same pin for the same reason. The reason outlived the artifact, so the comment explaining it now lives at the importIxWasm definition and in packages/ix2nix/wasm/default.nix where the retired gate used to be. ## what went away ix2nix-wasm-fresh and nix run .#ix2nix-wasm-regen both existed only to police a file in the tree. With the converter read straight from the package output, the two cannot disagree, so the gate has nothing to compare and the regen command has nothing to regenerate. Both are deleted, along with packages/maintainers/scripts/ix2nix-wasm-regen.py. Callers that pass their own converter are unaffected. It was always a parameter of packages/ix2nix/import-ix.nix.</description>
    </item>
    <item>
      <title>the version tables only advertise versions that still build, after a sweep of every nixpkgs alias this repo names</title>
      <link>https://ix.dev/nixpkgs-alias-sweep</link>
      <guid isPermaLink="true">https://ix.dev/nixpkgs-alias-sweep</guid>
      <pubDate>Sat, 25 Jul 2026 00:00:00 GMT</pubDate>
      <description>the version tables only advertise versions that still build, after a sweep of every nixpkgs alias this repo names. The BEAM fix earlier today ended with a known gap: &quot;No sweep was run for other top-level nixpkgs aliases this repo may still name.&quot; This is that sweep. The pinned nixpkgs (26.11, e2587ca) carries 2412 top-level alias attributes. Two of them were live breakages in ix. Fourteen more were dead entries in the ix.languages. version tables, waiting for the first caller to name them. ## how the class was enumerated Not by guessing names. config.allowAliases = false removes exactly the attributes pkgs/top-level/aliases.nix contributes, so the alias set is the difference between the two package sets: with allowAliases: 27682 attributes without allowAliases: 25270 attributes alias set: 2412 Classified against their definition sites in that file: 1957 throws (1779 written out one per line, 162 generated from the deprecatedPlasma5Packages list, 16 written as dotted paths under a parent attribute like lxde.lxpanel), 183 warnAlias shims (182 plus the whole xorg set, which is one attribute mapping warnAlias over ~30 members), and 272 silent renames. The three classes fail in three different ways, and only one of them is visible at lookup: - a throw alias fails the moment the attribute is selected; - a dropped attribute raises attribute missing; - a warnAlias resolves fine and fails only when something forces drvPath, because lib.derivations.warnOnInstantiate wraps every attribute of the derivation except meta/name/type/outputName in lib.warn. Under abort-on-warn -- set in both repos -- that is a hard eval failure. A fourth shape turned up that is not an alias at all: gradle_7 is marked insecure, so forcing its drvPath trips an assert unless the caller opts into permittedInsecurePackages. ## the two live breakages were in ix, not here pkgs.system is a warnAlias for stdenv.hostPlatform.system now. ix named it in three places, and all three were already broken: $ nix eval .#packages.x86_64-linux.casImage.drvPath evaluation warning: system has been renamed to/replaced by stdenv.hostPlatform.system error: aborting to reveal stack trace of warning, as abort-on-warn is set nix build .#casImage, .#cloudflare, .#status-page and .#ovh could not evaluate. None of them is in a host closure, which is why the twelve fleet hosts stayed green and nobody noticed. ## what changed here xorg.libX11 and friends are the deprecated spelling; the set was retired on 2026-01-29 and the packages live at the top level under lowercase names now. packages/minecraft/bossbar-overlay takes the four it uses as libx11, libxcursor, libxrandr, libxi -- the rename is lowercase-x, not a capitalisation-preserving move. The version tables in lib/languages advertised fourteen versions that could not be produced: | table | removed | why | | --- | --- | --- | | cpp gcc | 9, 10, 11, 12 | unmaintained, removed upstream | | cpp clang | 16, 17 | unmaintained, removed upstream | | go | 1.23 | EOL | | zig | 0.12 | removed upstream | | javascript | 20, 25 | EOL 2026-04-30 and 2026-06-01 | | python | 3.10 | attribute gone from the channel entirely | | java openjdk/temurin/zulu | 23, 24 | EOL | | java gradle | 7 | no security updates since Gradle 9; marked insecure | Nothing in either repo selected any of them, so they are deleted rather than pinned back to something that still builds. The docstrings that advertised them are corrected in the same commit -- a table that lies in its documentation is how a caller ends up reading a nixpkgs removal message instead of this repo&apos;s own &quot;unknown version, here is what is supported&quot; error. Four newest-available versions were added where the table had drifted behind the channel: gcc 16.1.0, Node 26.5.0, Elixir 1.20.2, Erlang/OTP 29.0.3. ## deliberately not the newest go_1_27 and python315 exist in the pinned channel and are not in the tables. They resolve to go-1.27rc2 and python3-3.15.0b4. A release candidate is not a version to advertise as selectable, and both were caught by forcing drvPath and reading the version out of the store path rather than trusting the attribute name. temurin-bin-26 exists too, but only in that one distribution, and a JDK major that resolves for Temurin and throws for OpenJDK, Corretto and Zulu is worse than not offering it. ## the guard The BEAM change added beamPinnedDrvPaths, which forced drvPath for every advertised Elixir and Erlang minor. That is now languageTableDrvPaths and covers every version table in lib/languages: 60 versions across Elixir, Erlang, Go, Zig, Node, Python, gcc, clang, four JDK distributions, and Gradle. Forcing is the point -- an entry spelled pkgs.gcc12 looks fine in a diff and in nix repl until something instantiates it. Watched failing before being trusted. Re-adding &quot;12&quot; = pkgs.gcc12; to the gcc table: error: gcc12 has been removed from Nixpkgs, as it is unmaintained and obsolete ## not done The 272 silent-rename aliases are mostly untouched. Three were fixed because the modern spelling is unambiguous and the rename is free (nettools to net-tools, nixos-rebuild to nixos-rebuild-ng, plus the xorg set above). The rest -- linux_6_12, linuxPackages_6_18, wasm-bindgen-cli, utfcpp, wlroots -- are aliases upstream deliberately maintains, several marked preserve in aliases.nix, and rewriting them would pin a version this repo does not want to own. They emit nothing and break nothing. No sweep was run over the nested* alias sets -- python3Packages, haskellPackages, nodePackages each carry their own aliases file, and only the top-level set was enumerated here.</description>
    </item>
    <item>
      <title>a dangling site link now fails lint by filename, and the Check gate always did build the site</title>
      <link>https://ix.dev/site-link-lint</link>
      <guid isPermaLink="true">https://ix.dev/site-link-lint</guid>
      <pubDate>Sat, 25 Jul 2026 00:00:00 GMT</pubDate>
      <description>a dangling site link now fails lint by filename, and the Check gate always did build the site. nix run .#lint now fails an absolute in-page link that resolves to no prerendered route, and says which file and line wrote it: That is 0.14s of extra file scanning, inside the site-ids stage that already front-loads the site&apos;s other pure invariants. No new stage, no new script, and nothing new is built. ## the premise this started from was false The brief was &quot;the Check gate never builds the site, so add it.&quot; It already does, and the note in the cache-push write-up saying otherwise was wrong. site is in packageSet, so cachePushRoots carries it, so requiredGateRoots exposes it as closure-site, which is exactly what check required builds. The two attributes resolve to one derivation: It did not just build the site. It caught this bug, on the commit that introduced it, twenty-two minutes before the first cache-push failure. From the flake-check job of Check run 30178648484 on 4fb45b3a: Two things kept that from stopping anything. 4fb45b3a was pushed straight to main, so the gate&apos;s verdict arrived after the commit was public; there was no pull request for it to block. And flake-check was already red for an unrelated reason — the commit before it, 0b88b11a, failed on ix2nix-wasm-fresh — so a new red carried no signal. Every Check run on main in that twelve-hour window is a failure. ## timings, since the cost was the reason it was deferred Measured against the x86_64-linux remote builder with a warm store: | what | wall | | --- | --- | | nix build .#packages.x86_64-linux.site, one .svx changed | 25s | | the same build, failing on a dangling link | 49s | | the vite build inside it | 3.83s | | lint-stage site-ids before this change | 0.11s | | lint-stage site-ids after | 0.25s | | nix run .#lint, 13 stages in parallel | 3.5s | The lint&apos;s wall clock does not move: astlog is still the critical path at 3.5s and site-ids finishes in 0.5s beside it. So the deferral traded a gate for 25 seconds that were already being spent. The 436MB ix-site-node-modules closure is the only expensive input, and it is unchanged by a content edit. ## why a lint stage is still the right place The build is the better validator and it stays the validator. What it cannot be is early, for a repo whose normal path to main is git push. Lint runs before the commit; the build runs after it. That is the same reasoning that put plan-number collisions in this stage after #3669, and the same failure shape: a content invariant that only the bundler knew about, learned too late to matter. It is also the better message. SvelteKit names the linking route — linked from /ifd-ix2nix-wasm/ — and never the file, so fixing it means mapping a URL back to a .svx by hand. The lint names packages/site/src/lib/updates/ifd-ix2nix-wasm.svx:16. ## the route inventory is derived, because a listed one would drift Nothing is hardcoded. Static routes are the directories under src/routes owning a +page.svelte, +page.ts or +server.ts. A dynamic [id] directory is expanded with the ids of whichever $lib/&lt;collection&gt; its own loader imports, read out of the loader source — which is how /rfcs/[id] and /plans/[id] both correctly draw from $lib/plans. Add a route, rename a collection, or delete a page and the inventory follows. A committed route list that silently passes a 404 is worse than no check at all. Prose that quotes a broken link is not a broken link: fenced blocks are dropped and inline code is blanked before scanning, which is what keeps the cache-push write-up — it quotes the exact bad link in backticks — green. The first version of that scan cost 4.0s, not 0.25s, and would have made site-ids the lint&apos;s critical path on its own, ahead of astlog. It folded over every line of every plan accumulating a list, and nushell copies the list on each append, so a linear scan was quadratic. Selecting candidate lines first — a line with no ](/ or href=&quot;/ in it cannot carry a link — and only then asking whether they sit inside a fence is what got it back under baseline noise. ## what this does not do Only .svx under plans, updates and stories is scanned. Components are not: they route through resolve() and are type-checked, and they do not grow by one file per change the way the corpus does. Relative links are not checked either, because the corpus has none, so the rule would be untested. Neither is the trailing slash enforced, even though trailingSlash = &apos;always&apos;: the slashless spelling redirects rather than 404s, and a rule I could not watch fail is not a rule I will ship. The chronically red flake-check is untouched and is the larger problem here. A gate nobody can read is a gate nobody has.</description>
    </item>
    <item>
      <title>the submodule-sync test moves to Rust, and the shell fence goes green</title>
      <link>https://ix.dev/submodule-sync-test-rust</link>
      <guid isPermaLink="true">https://ix.dev/submodule-sync-test-rust</guid>
      <pubDate>Sat, 25 Jul 2026 00:00:00 GMT</pubDate>
      <description>the submodule-sync test moves to Rust, and the shell fence goes green. nix run .#lint has failed on a clean main checkout since 2026-07-24. One stage of thirteen: 6000b487 added that 155-line script and never touched shell-allowlist.txt. The allowlist says in its own header that it only shrinks, so the fix is a port, not an entry. packages/submodule-sync-test replaces it and the fence now passes 13 of 13. ## why it merged red shell-fence is not a required check on the index main ruleset, so a PR that fails only the fence can merge. Everyone who pulled afterward saw a red stage they did not cause, on a file they had never opened. That is the expensive part: a gate that is red for someone else&apos;s reason trains people to skip reading it, and the next real finding gets skipped with it. This is the second one in a day. ix main was simultaneously red on every commit because a rustfmt violation reached it the same way, through a ruleset that requires build while treefmt-check runs inside nix. ## what the test pins Three behaviours of the Bump submodules step in update-flake-lock.yml, all carried over: 1. A stale pin advances the gitlink and flake.lock together. Either alone is a broken tree. 2. A current pin is a true no-op, creating no commit. 3. A push that loses a race against an unrelated ix/main commit retries onto the new tip and preserves that commit instead of clobbering it. The third is the one worth having. It works by putting a git shim on PATH that fires exactly once, on the worker&apos;s first push to main, and lands a competing commit in that window. The race is deterministic rather than timing-dependent. ## one assertion the shell version was missing The original never checked that the shim fired. If the worker&apos;s push arguments ever changed, the [ &quot;$3&quot; = HEAD:refs/heads/main ] guard would stop matching, no race would be injected, and the test would still pass all three assertions while proving nothing about retry behaviour. The port fails loudly instead: A test that silently stops testing is worse than a missing test, because it reports success. ## what stayed shell The step under test is bash inside a workflow YAML, so the port still extracts that block and runs it under bash. That is the thing being tested and cannot be ported away from here. What the fence targets is committed shell and generated-shell call sites, and there is now one fewer of the former. Dropping jq and yq fell out of it: the Rust version parses the workflow YAML with serde_norway and the lock with serde_json in-process, so check.yml no longer provisions either tool.</description>
    </item>
    <item>
      <title>The hosted-mac cache lane is gone; darwin consumers pin cache-ready again</title>
      <link>https://ix.dev/cache-push-darwin-delete</link>
      <guid isPermaLink="true">https://ix.dev/cache-push-darwin-delete</guid>
      <pubDate>Fri, 24 Jul 2026 23:30:00 GMT</pubDate>
      <description>The hosted-mac cache lane is gone; darwin consumers pin cache-ready again. cache-push-darwin.yml is deleted. It ran on a GitHub-hosted macos-14 runner because the fleet has no darwin machine, and in 299 runs it never concluded success once — 271 cancelled, 26 failed. It never created cache-ready-darwin, so nothing has ever pinned that ref. Nothing regresses, because nothing was being delivered. What a mac can substitute is the Linux→Darwin cross closure, and the linux lane already realises and publishes every one of those roots from cachePushRoots.x86_64-linux. Darwin consumers pin cache-ready, the same ref the rest of the fleet tracks. What a mac now builds locally is the set of genuinely native aarch64-darwin roots with no cross story yet — Apple-framework packages (ghostty, vmkit, screencast), the repo&apos;s own Rust CLIs, and the site and Python trees. That was already true in practice; it is now true by declaration. #3586 tracks moving each of them onto the cross lane, at which point they publish through the linux lane like btop, codex and nix-ix already do. The removal also takes the last GitHub-hosted Nix installer with it, so no lane in this repo installs Determinate Nix any more.</description>
    </item>
    <item>
      <title>the prompt reads jj state in a jj workspace instead of the exported git view</title>
      <link>https://ix.dev/vcs-prompt-jj-segment</link>
      <guid isPermaLink="true">https://ix.dev/vcs-prompt-jj-segment</guid>
      <pubDate>Fri, 24 Jul 2026 00:00:00 GMT</pubDate>
      <description>the prompt reads jj state in a jj workspace instead of the exported git view. Starship&apos;s git_branch and git_status cannot be disabled per directory, so inside a colocated jj repo — every fork this repo maintains — the prompt described the exported git view: a detached HEAD parked wherever jj git export last wrote, with none of the working-copy commit&apos;s state. packages/vcs-prompt replaces both modules. It walks up from the prompt directory, taking .jj over .git within a level so colocation resolves to jj while a git checkout nested inside a jj workspace still resolves to git, then renders one segment. In a jj workspace that is the working-copy change id, the nearest ancestor bookmark with the distance to it, and the conflict / divergent / non-empty flags: on 󱗆 lsurukvy ix-patched+2 *. Anywhere else it is the branch, the same status symbols git_status was configured with, and the upstream distance: on main !3?1⇡2. Both backends shell out to the VCS rather than linking it. jj-lib is a heavy build for a prompt, which is why the third-party jj-starship stayed commented out here, and jj log --ignore-working-copy answers in ~20ms without snapshotting the working copy on every render. The git side started on libgit2 and moved to git status --porcelain=v2 after measuring: in the workstation config repo, three levels of submodules deep, the libgit2 status walk took ~180ms against git&apos;s ~50ms, and dropping the dependency took a vendored C build out of the crate as well.</description>
    </item>
    <item>
      <title>POC: Gherkin (cucumber-rs) specs audited with cargo-mutants</title>
      <link>https://ix.dev/gherkin-mutation-poc</link>
      <guid isPermaLink="true">https://ix.dev/gherkin-mutation-poc</guid>
      <pubDate>Thu, 23 Jul 2026 19:05:00 GMT</pubDate>
      <description>POC: Gherkin (cucumber-rs) specs audited with cargo-mutants. packages/gherkin-poc is a worked example of two testing techniques the repo had no sample of: - Gherkin / BDD: the behavior lives in tests/features/account.feature as plain-language Given/When/Then scenarios (overdraft floor, rejected operations leaving the balance untouched), bound to the crate API by cucumber-rs step definitions. The suite runs under the normal libtest harness (cucumber&apos;s suggested harness = false is only about output ordering), so nextest and cargo-unit treat it like any other test. - Mutation testing: the crate README records a cargo mutants --package gherkin-poc run against the suite, per the rust-style skill&apos;s guidance that cargo-mutants is a package-owner audit tool, not a CI gate. The crate is deliberately tiny: an Account with integer-cent balances and an overdraft limit, just enough arithmetic and boundary conditions to give mutants something to bite.</description>
    </item>
    <item>
      <title>nix-cargo-unit: unit names now hash build-script-run deps (collision fix)</title>
      <link>https://ix.dev/cargo-unit-run-dep-name-collision</link>
      <guid isPermaLink="true">https://ix.dev/cargo-unit-run-dep-name-collision</guid>
      <pubDate>Thu, 23 Jul 2026 19:00:00 GMT</pubDate>
      <description>nix-cargo-unit: unit names now hash build-script-run deps (collision fix). nix-cargo-unit dedupes units by a full-identity hash that walks every dependency, but named the resulting derivations with a second hash that skipped build-script-run dependencies. Two merged units that differed only in which build-script-run variant they depended on rendered under the same attr key, and the generated cargo-units.nix failed to import with attribute already defined. The trigger was mundane: adding a dev-dependency (cucumber, for the Gherkin POC) perturbed feature unification between the [--workspace] and [--workspace --tests] target sets, producing two tree-sitter build-script-run variants whose downstream tree-sitter-dockerfile-updated run units collided. The name hash now includes run-custom-build dependency edges, mirroring the merge hash, so names are injective over merged units. Cost: a one-time rename of every unit whose closure contains a build script, i.e. most of the workspace rebuilds once. Toolchain bumps already rename every unit the same way, so this is an established event class, not a new one.</description>
    </item>
    <item>
      <title>Agents use the Elixir kernel and keep a native shell</title>
      <link>https://ix.dev/elixir-mcp-native-shell</link>
      <guid isPermaLink="true">https://ix.dev/elixir-mcp-native-shell</guid>
      <pubDate>Thu, 23 Jul 2026 19:00:00 GMT</pubDate>
      <description>Agents use the Elixir kernel and keep a native shell. Agent wrappers now start the persistent Elixir MCP server by default. The old default started the Python notebook kernel even after the workstation configuration had moved to BEAM. Codex also keeps its native shell when the kernel is present. Commands and search use the native shell. Stateful Elixir, fleet, and data work stay in the kernel. A dead MCP connection no longer removes the only command path.</description>
    </item>
    <item>
      <title>Key-blind agent sandboxing is a reusable module</title>
      <link>https://ix.dev/sandboxed-agent-module</link>
      <guid isPermaLink="true">https://ix.dev/sandboxed-agent-module</guid>
      <pubDate>Thu, 23 Jul 2026 19:00:00 GMT</pubDate>
      <description>Key-blind agent sandboxing is a reusable module. The kernel-build example&apos;s sandbox machinery moved into services.sandboxed-agent, a general module: a fixed-uid confined user, a uid-keyed nftables egress policy that admits exactly one loopback port, a credential-injecting proxy that owns the secret, a systemd-run wrapper on PATH, and nix-daemon scoping. Consumers set the agent&apos;s user, uid, command, and the proxy&apos;s upstream host, header, and key file. The proxy itself is now a small Rust binary built with cargo-unit, replacing the example&apos;s stdlib Python. It relays upstream responses byte for byte, so SSE streams and compressed bodies pass through untouched. The kernel-build example shrank to intent: git-clone the tree, install the toolchain, point the module at Claude.</description>
    </item>
    <item>
      <title>.ix modules accept TypeScript type annotations, lowered to runtime checks</title>
      <link>https://ix.dev/typed-ix-annotations</link>
      <guid isPermaLink="true">https://ix.dev/typed-ix-annotations</guid>
      <pubDate>Thu, 23 Jul 2026 19:00:00 GMT</pubDate>
      <description>.ix modules accept TypeScript type annotations, lowered to runtime checks. .ix files can now carry TypeScript annotations: parameter and return types, type aliases, and as casts. ix2nix lowers each annotation to a runtime check in the emitted Nix, so passing a set where a string goes fails with a positioned error naming the module, line, column, and argument instead of a deep eval trace: The pieces: - Annotations lower to calls against a small __ixTy runtime the import shims pass per module; the wrapper convention is now { __dir, __importIx, __ixTy }:. - Two modes, chosen at import time over identical emitted source: assert (the default in both shims) runs the checks; erase makes them free. - Checks are force-safe: a check may force the annotated value to weak head normal form and read attrset keys, never field values or list elements, so typed code never evaluates deeper than untyped code. - Type spellings: string, bool, int, float, drv, object, T[], Record&lt;string, T&gt;, inline object types with optional fields, function types, literal unions, T | null, Rust-width integer refinements u8/u16/u32/i8/i16/i32, and the nixpkgs lib.types basics port, path, nonEmptyStr. number is a hard error (Nix splits int and float), as are interfaces, generics, and satisfies, in keeping with the converter&apos;s no-fallbacks rule. - as T checks at the cast site, the opposite of TypeScript erasure, on purpose: casts are where JSON and imported plain Nix enter typed code. as unknown stays the escape hatch. Annotations are optional everywhere; unannotated modules emit no __ixTy reference at all. Static checking (tsc against an ambient ix.d.ts) and a deep-checking mode are follow-ups.</description>
    </item>
    <item>
      <title>main lint gate green again after quoting a legacyPackages splice</title>
      <link>https://ix.dev/lint-main-unquoted-splice</link>
      <guid isPermaLink="true">https://ix.dev/lint-main-unquoted-splice</guid>
      <pubDate>Thu, 23 Jul 2026 16:15:00 GMT</pubDate>
      <description>main lint gate green again after quoting a legacyPackages splice. nix run .#lint was red on main: astlog&apos;s no-unquoted-splice rule flagged nixpkgs.legacyPackages.${hostSystem} in lib/default.nix (the importIxFor helper), where the antiquote interpolates outside a string. The fix quotes the splice — legacyPackages.&quot;${hostSystem}&quot; — matching the existing call site in lib/image/default.nix. While clearing that, the lint run also caught lib/per-system.nix out of alejandra formatting (the kernel-unit attrs under the x86_64-linux optionalAttrs block were under-indented), so that file was reformatted too — whitespace only, no syntactic change. The lint gate on main now reports zero blocking findings.</description>
    </item>
    <item>
      <title>Package names are IFD-free again: example fan-outs move to legacyPackages</title>
      <link>https://ix.dev/ifd-free-package-names</link>
      <guid isPermaLink="true">https://ix.dev/ifd-free-package-names</guid>
      <pubDate>Thu, 23 Jul 2026 14:05:00 GMT</pubDate>
      <description>Package names are IFD-free again: example fan-outs move to legacyPackages. The default.ix example conversion (#4079) quietly made the flake&apos;s packages.&lt;system&gt; key set depend on an IFD: classifying single- versus multi-VM examples forced every example through the ix2nix converter derivation, and lifecyclePackages spread those keys into packages. Any consumer that merely probes index.packages.&lt;system&gt; then needs IFD, and ix&apos;s CI lint gate is exactly such a consumer: a no-build proof that evaluates with allow-import-from-derivation disabled and max-jobs 0. Both mains went red for a few hours on 2026-07-23. The fix (#4088) moves the example-derived fan-outs out of packages: the per-example health-check-* lifecycle packages and the &lt;example&gt;-{up,health,...} fleet wrappers now live under legacyPackages.&lt;system&gt;, the same surface that already keeps kernel-unit&apos;s eval-time IFD out of every gate closure. The health-checks and health-checks-zellij runners keep their static names in packages. Operator-visible changes: - nix build .#health-check-&lt;example&gt; becomes nix build .#legacyPackages.&lt;system&gt;.health-check-&lt;example&gt;; same for the &lt;example&gt;-up style wrappers - examples/nixos/switch-multi moved to examples/multi-vm/switch-multi - the invariant to keep: packages.&lt;system&gt; names must stay enumerable with IFD disabled; example-derived or unit-graph-derived names belong in legacyPackages The same restoration landed two unrelated latent breakages that had merged while the ix gate was down: a missed Error::internal_error call-site migration in orch-rpc (ix#8366) and treefmt drift in the run-viewer slices.</description>
    </item>
    <item>
      <title>CI budget kills now announce themselves in the log, the annotations, and the mirror results</title>
      <link>https://ix.dev/ci-budget-loud-kills</link>
      <guid isPermaLink="true">https://ix.dev/ci-budget-loud-kills</guid>
      <pubDate>Thu, 23 Jul 2026 07:00:00 GMT</pubDate>
      <description>CI budget kills now announce themselves in the log, the annotations, and the mirror results. When the ci-budget worker&apos;s validation clock expired, it killed the gate&apos;s process group and said nothing: no annotation, no log line, and the hosted mirror jobs read blank results. Diagnosing one such kill took two full CI runs plus a host-journal dig (#4139), and the same signature hit several other PRs the same day. The worker now reports before it signals. The job log and a ::error annotation both carry one line naming the clock that fired, the elapsed time, and the remedy (add the ci/big-change label for the extended_validation_seconds=10800s budget), and any mirrored output the script never published is backfilled as budget-exceeded, so the lint and build mirror jobs report the actual cause instead of missing. Phase results the script published before the kill are kept as-is. Budget semantics are unchanged: same clocks, same TERM/KILL contract, same exit code 124.</description>
    </item>
    <item>
      <title>mkapp scaffolds ship the real shadcn-svelte registry, themed from the ghostty palettes</title>
      <link>https://ix.dev/mkapp-shadcn-svelte</link>
      <guid isPermaLink="true">https://ix.dev/mkapp-shadcn-svelte</guid>
      <pubDate>Thu, 23 Jul 2026 05:40:00 GMT</pubDate>
      <description>mkapp scaffolds ship the real shadcn-svelte registry, themed from the ghostty palettes. The mkapp template used to carry three hand-rolled components (Button, Card, Skeleton) that imitated the shadcn look in scoped CSS. Agents building anything past a card wall were on their own. The template now vendors the actual shadcn-svelte registry: 54 components (accordion to tooltip, including dialog, table, tabs, sidebar, and sonner) under staging/lib/components/ui/, on Tailwind v4 and bits-ui, imported through a $lib alias that the staging typecheck resolves against the tree the agent actually edited. Only chart (layerchart) and form (needs SvelteKit) are left out. Colors come from the operator&apos;s terminal. A build-time script parses the ghostty themes (modules/home/ghostty/themes/custom-light, custom-dark) and emits the shadcn variable block as light-dark() pairs, so scaffolded apps match the terminal in both modes, follow the OS scheme with no .dark class to toggle, and never hand-copy a hex value. Changing the terminal theme re-themes every future scaffold at the next nix build. The cost is measured and modest: a cold npm install in a fresh scaffold goes from 4.7s to 7.5s, and node_modules from 55MB to 124MB. The serve gate (npm run check:staging, then promote) typechecks the full vendored tree, about 1100 files, in roughly 3s.</description>
    </item>
    <item>
      <title>Agent memory grows an entity graph: about edges, minted ids, graph-walking recall</title>
      <link>https://ix.dev/memory-entity-graph</link>
      <guid isPermaLink="true">https://ix.dev/memory-entity-graph</guid>
      <pubDate>Thu, 23 Jul 2026 00:45:00 GMT</pubDate>
      <description>Agent memory grows an entity graph: about edges, minted ids, graph-walking recall. Memories were flat facts: a slug, a one-line hook, a prose body. Related knowledge was findable only by regex or embedding similarity. Now each memory links into an entity graph: Memory.remember(..., about: [&quot;gh:indexable-inc/ix#8088&quot;, &quot;host:hydra&quot;]) writes typed edges to minted ids for issues and PRs (gh:&lt;org&gt;/&lt;repo&gt;#&lt;n&gt;), repos, hosts, tools, and people, and Memory.graph/1 walks them. The store&apos;s recall projection (journal data, not engine code) traverses these edges two hops out, degree-gated so hubs like a whole repo don&apos;t drown the walk: recalling one memory about an issue now surfaces the sibling memories that cite the same issue, host, or tool. The 1,000 existing memories were backfilled with 2,175 about edges; the id convention lives in the store itself as mem:memory-ontology, and the SessionStart digest teaches agents to write the granular form at the moment of learning.</description>
    </item>
    <item>
      <title>the .ix converter is committed, so imports realize nothing mid-eval</title>
      <link>https://ix.dev/committed-ix2nix-wasm</link>
      <guid isPermaLink="true">https://ix.dev/committed-ix2nix-wasm</guid>
      <pubDate>Thu, 23 Jul 2026 00:00:00 GMT</pubDate>
      <description>the .ix converter is committed, so imports realize nothing mid-eval. index.lib.importIxWasm (and importIx; they are one implementation now) loads the ix2nix converter from a file committed in the index tree, lib/ix2nix.wasm, instead of realizing the ix2nix-wasm derivation output during evaluation. Importing a .ix module is now pure in-eval work: no import-from-derivation anywhere on the path, no substitution from a cache, so a fresh ix init project evaluates offline and repo evals stop stalling on a mid-eval store realization. A CI gate byte-compares the committed file against the built .#ix2nix-wasm on every push, and nix run .#ix2nix-wasm-regen regenerates it when the converter source changes. The wasm detour is still transitional: once nix has proper parallel IFD we hope to drop the wasm converter and run the native ix2nix binary instead.</description>
    </item>
    <item>
      <title>kernel module autoloading works in guests, nftables firewall starts green</title>
      <link>https://ix.dev/guest-modprobe-wiring</link>
      <guid isPermaLink="true">https://ix.dev/guest-modprobe-wiring</guid>
      <pubDate>Thu, 23 Jul 2026 00:00:00 GMT</pubDate>
      <description>kernel module autoloading works in guests, nftables firewall starts green. Every ix guest boots the host-provided linux-ix kernel and gets that kernel&apos;s full module tree injected at /lib/modules/&lt;kver&gt;, but until now no image shipped a module loader: images set boot.isContainer = true, and nixpkgs&apos; container-config.nix responds by disabling the whole modprobe machinery on the theory that containers have no kernel. The result was that /proc/sys/kernel/modprobe still pointed at the kernel&apos;s compiled-in default /sbin/modprobe (which does not exist in the guest), so request_module() always failed and every =m kernel feature was dead. Concretely, nftables.service failed on every boot with &quot;Unable to initialize Netlink socket: Protocol not supported&quot; -- the in-guest firewall the platform declares was never actually enforcing -- and every switch-to-configuration exited 4 on it. The platform profile now re-enables the upstream modprobe module (boot.modprobeConfig.enable), which puts kmod (modprobe, lsmod, modinfo, ...) on the system PATH and points /proc/sys/kernel/modprobe at kmod&apos;s modprobe from an activation snippet. Activation runs at boot and during a switch, so already-running VMs pick the fix up through a plain ix apply with the new index pin -- no base-image rebuild needed for switched VMs. Module resolution follows the running kernel: nixpkgs kmod only takes a candidate directory whose uname -r subtree exists, so even a user toplevel carrying modules for a different kernel resolves against the injected /lib/modules tree.</description>
    </item>
    <item>
      <title>scaffolded flakes eval again, and index.lib.importIxWasm ends the deep-path era</title>
      <link>https://ix.dev/import-ix-stable-entry</link>
      <guid isPermaLink="true">https://ix.dev/import-ix-stable-entry</guid>
      <pubDate>Thu, 23 Jul 2026 00:00:00 GMT</pubDate>
      <description>scaffolded flakes eval again, and index.lib.importIxWasm ends the deep-path era. The package flattening (#4119) moved packages/nix/ix2nix/import-ix.nix to packages/ix2nix/import-ix.nix, and every flake ix init had ever scaffolded imports the old path from index main, so all existing projects failed eval with &quot;path does not exist&quot;. A compat shim now lives at the old path: one expression forwarding to the moved file, kept only because scaffolded user flakes are external consumers we cannot migrate by editing this repo. The class is closed by a stable entry point: index.lib.importIxWasm (and importIxWasmFor &lt;system&gt;) is the in-eval builtins.wasm importer already wired to the compiled ix2nix converter, so consumer flakes never spell a repo-internal path or the converter&apos;s store layout again. The ix init template and the in-repo example flakes now bind importIx = index.lib.importIxWasm; instead of importing the deep path.</description>
    </item>
    <item>
      <title>Jason works in kernel exec cells</title>
      <link>https://ix.dev/kernel-jason-compat</link>
      <guid isPermaLink="true">https://ix.dev/kernel-jason-compat</guid>
      <pubDate>Wed, 22 Jul 2026 19:00:00 GMT</pubDate>
      <description>Jason works in kernel exec cells. Agents writing cells for the Elixir kernel (ix-mcp-ex) reach for Jason.decode! out of habit -- it has been the de facto JSON library for a decade -- and until now every such cell died with UndefinedFunctionError: module Jason is not available, wasting a full exec round trip before the agent rediscovered OTP&apos;s built-in JSON module. The kernel release now carries jason ~&gt; 1.4 as a plain runtime dep, so both spellings work in cells. Nothing in the kernel itself switches over: the MCP wire format and internal encoding still ride the built-in JSON module. Jason is purely a compatibility affordance for agent muscle memory. It was already in the dependency closure (credo pulls it in for the test environment), so the vendored-deps hash and lockfile are unchanged; the dep only had to be promoted so mix release ships it.</description>
    </item>
    <item>
      <title>mkapp + Serve.app, check-gated hot reload for AI-built web apps</title>
      <link>https://ix.dev/mkapp-serve-gated-hot-reload</link>
      <guid isPermaLink="true">https://ix.dev/mkapp-serve-gated-hot-reload</guid>
      <pubDate>Wed, 22 Jul 2026 19:00:00 GMT</pubDate>
      <description>mkapp + Serve.app, check-gated hot reload for AI-built web apps. An agent building a web page for the user used to hand over a file, or worse, a screenshot. Now it hands over a live URL. mkapp scaffolds a Svelte 5 + Vite + TypeScript app whose store survives hot reloads (an import.meta.hot handoff plus a debounced sessionStorage mirror), with shadcn-style Button/Card/Skeleton primitives on the dashboard&apos;s Obsidian tokens and an AgentStatus strip that narrates what the agent is doing. The kernel&apos;s Serve.app(dir) runs npm run dev and a gate loop as supervised jobs. The agent edits the app&apos;s staging/ tree only; when an edit settles (~300ms), the gate runs npm run check:staging (svelte-check plus tsc) and promotes green code into the live src/, which Vite hot-reloads without losing the store. A red check leaves the last good page up and records the compiler output where Serve.status(dir) shows it, so a typo never replaces a working page with a blank one. Once vite prints its port, the kernel writes ESC ]5522;open-url;&lt;url&gt; BEL to the ix-term session pts (or /dev/tty), and the terminal opens the app in its split-view doc pane (ix#8185). A new prompt rule tells agents to narrate through the store&apos;s status field, mark still-generating sections loading so skeletons show, and keep updating the page until the work is done.</description>
    </item>
    <item>
      <title>Child-agent spawns name a missing working directory instead of dying as exit 2</title>
      <link>https://ix.dev/agents-cli-runner-cwd</link>
      <guid isPermaLink="true">https://ix.dev/agents-cli-runner-cwd</guid>
      <pubDate>Wed, 22 Jul 2026 16:30:00 GMT</pubDate>
      <description>Child-agent spawns name a missing working directory instead of dying as exit 2. The Cmd fix for the missing-working-directory failure left one sibling untouched: the agent CLI runner opens its child port the same way, so a child spawned into a deleted or bogus directory died as the raw chdir errno -- exit 2 for a missing path, exit 20 for a file -- with empty output, indistinguishable from the CLI failing on its own. The runner now validates the working directory before the spawn and raises an error naming the path (with the launch-dir hint when the boot-time default vanished), so the lead sees the real culprit instead of a bare exit status. A directory deleted after the check is caught too: a nonzero exit with the directory now gone raises with the errno caveat rather than reporting a status that may not be the CLI&apos;s own. Clean exits and runs that already produced a final result are never second-guessed.</description>
    </item>
    <item>
      <title>cargo-unit plans against a manifest-scoped stub, so a source edit no longer re-resolves the workspace</title>
      <link>https://ix.dev/manifest-scoped-cargo-planner</link>
      <guid isPermaLink="true">https://ix.dev/manifest-scoped-cargo-planner</guid>
      <pubDate>Wed, 22 Jul 2026 16:00:00 GMT</pubDate>
      <description>cargo-unit plans against a manifest-scoped stub, so a source edit no longer re-resolves the workspace. Fixes #3900. Every buildWorkspace evaluation runs two eval-time IFD stages: a whole-workspace cargo build --unit-graph resolve and a nix-cargo-unit render. Both used to take the full source tree as input, so editing one line in any crate re-ran both planners before a single unit could cache-hit. Cargo&apos;s planning phase reads manifest contents (Cargo.toml, Cargo.lock, the in-tree cargo config) and discovers targets by file existence alone; it never opens a target file and runs no build script. The unit-graph stage now plans inside a stub tree built from exactly those inputs: manifests verbatim, every other source file an empty stub at its exact relative path. A body edit changes neither the manifest slice nor the path list, so the planner derivation is untouched; adding or removing files, or editing a manifest, still re-plans. The render stage keeps the real tree as input because its include_str! closure scan reads file contents, and it rewrites the stub&apos;s store prefix back to the real source so rendered output is byte-identical to the old path. Net effect on the repo workspace: a .rs body edit re-runs one cheap render instead of the render plus four whole-workspace cargo resolves, and the edited crate&apos;s unit is the only unit derivation that changes. (authored by Claude Code, Fable 5)</description>
    </item>
    <item>
      <title>each bundled mcp module&apos;s Nix definition now lives next to its Python source</title>
      <link>https://ix.dev/mcp-per-module-dirs</link>
      <guid isPermaLink="true">https://ix.dev/mcp-per-module-dirs</guid>
      <pubDate>Wed, 22 Jul 2026 16:00:00 GMT</pubDate>
      <description>each bundled mcp module&apos;s Nix definition now lives next to its Python source. packages/mcp/default.nix had grown into a 5,900-line file holding every bundled module&apos;s definition and most of its 77 test derivations. Each module directory now carries a module.nix next to its Python source (src/imessage/module.nix, plus ix_notebook_mcp/module.nix for the server package), discovered by the same marker-file walk the package registry uses for package.nix. A module file declares only the slice of the assembly it touches and returns its module derivation and tests; sibling modules arrive under their historical bindings, so the per-module test envs from #3897 (passthru.ixFirstPartyDeps, firstPartyClosure) moved verbatim with their modules. default.nix shrinks to assembly: third-party pins, the pinned interpreter, env composition, the shipped wrappers, and the genuinely cross-module tests. Adding a bundled module is now mkdir src/&amp;lt;name&amp;gt; plus a module.nix; no registry edit. Behavior is preserved: the test attribute list is byte-identical on darwin and linux, and 75 of the 77 test derivations kept their exact store paths (the two that moved only re-order the interpreter env&apos;s inputs). Fixes #3928 (authored by Claude Code, Fable 5)</description>
    </item>
    <item>
      <title>Rev-pinned builtins.fetchGit inputs evaluate without network once cached</title>
      <link>https://ix.dev/fetchgit-head-cache</link>
      <guid isPermaLink="true">https://ix.dev/fetchgit-head-cache</guid>
      <pubDate>Wed, 22 Jul 2026 14:45:00 GMT</pubDate>
      <description>Rev-pinned builtins.fetchGit inputs evaluate without network once cached. Evals that use builtins.fetchGit with a pinned rev (crane&apos;s vendorCargoDeps for Cargo.lock git dependencies, for example) used to spawn a serial git ls-remote --symref &lt;url&gt; HEAD per input on every evaluation, even when the rev was already in the local git cache: the fetcher resolved the remote&apos;s default ref before deciding whether anything needed fetching, and the cached HEAD file could never refresh once fetches stopped happening, so after one tarball-ttl expiry the network round trip recurred forever. On hydra this put 40-100s of git subprocess wall time on top of an ~18s-cpuTime home-manager eval (#4028). The libfetchers: resolve git refs lazily and refresh the cached HEAD patch in the nix-ix fork series (an indexable-inc/nix megamerge commit) makes the daemon resolve git refs lazily (a cached rev-pinned input now runs zero remote git subprocesses, allRefs included) and refreshes the cached HEAD after every successful network lookup, bounding HEAD resolution for unpinned inputs to once per tarball-ttl instead of once per eval. Measured on a warm nix eval --no-eval-cache of a rev-pinned github input: 448ms and one network ls-remote per eval before, 37ms and zero git subprocesses after.</description>
    </item>
    <item>
      <title>Fork patches are now jj megamerge commit DAGs</title>
      <link>https://ix.dev/jj-megamerge-forks</link>
      <guid isPermaLink="true">https://ix.dev/jj-megamerge-forks</guid>
      <pubDate>Wed, 22 Jul 2026 14:30:00 GMT</pubDate>
      <description>Fork patches are now jj megamerge commit DAGs. All 14 maintained forks moved from in-repo patch series (patches/*.patch plus a generated dag.json) to real fork repos where every patch is a git commit whose parents are its true dependencies. An &quot;ix megamerge&quot; commit seals each series: its tree equals the full series applied linearly, its parents are the DAG heads, and the flake input pins that one commit, so builds fetch a plain git tree and never apply patches. What this deletes: rebase-patches and its scratch-repo rebase engine, the byte-commutation check, the per-attempt closure gates, mirror fork-branch, and every dag.json. Rebases are now jj rebase in the fork repo (colocated; the remote stays plain git); a conflict marks the guilty patch commit instead of aborting the run, and jj evolog keeps every prior version of every patch across bumps. Two rules keep the setup honest. Every bookmark push carries a permanent refs/pins/&lt;date&gt;-&lt;sha12&gt; ref in the same operation that bumps flake.lock, because jj rebases rewrite history and GitHub GCs unreachable commits: the pin refs are what keep every previously locked megamerge fetchable. And a conflicted jj commit is never pushed: plain-git readers cannot parse jj&apos;s conflict encoding. Migration fidelity: 11 of 13 megamerge trees hash byte-identical to the old patchedSrc outputs. The two diffs favor the new world: the old nix tree shipped GNU patch .orig backup droppings, and mesa&apos;s CSVs picked up the CRLF its own .gitattributes asks for (GitHub tarballs honor it; the old git fetch did not).</description>
    </item>
    <item>
      <title>js-nix: write Nix in JavaScript syntax, compiled at eval time by builtins.wasm</title>
      <link>https://ix.dev/js-nix-wasm-builtin</link>
      <guid isPermaLink="true">https://ix.dev/js-nix-wasm-builtin</guid>
      <pubDate>Wed, 22 Jul 2026 11:30:00 GMT</pubDate>
      <description>js-nix: write Nix in JavaScript syntax, compiled at eval time by builtins.wasm. The nix-ix daemon now carries builtins.wasm (patches 0038-0039, imported from the open upstream PR NixOS/nix#15380, the upstream form of Determinate Nix&apos;s Wasm support): Nix expressions can call functions in WebAssembly modules, gated behind the wasm-builtin experimental feature. Our import goes one step past upstream and forces deterministic execution (NaN canonicalization, deterministic relaxed SIMD), because eval results cross machines here and one implementation-defined bit means divergent store hashes. Upstream left that off after measuring ~3.6x on a float-heavy kernel; eval plugins are string/AST-shaped and do not pay it. First consumer: js-nix. A .ix file is JavaScript syntax mapped 1:1 onto Nix semantics; packages/ix2nix (Rust, oxc parser, typed Nix AST, one renderer) converts it, compiled to an 884 KB wasm plugin the evaluator runs directly: importIx (from packages/ix2nix/import-ix.nix) evaluates that through builtins.wasm + builtins.toFile; relative .ix imports work via a threaded __dir. Anything without a 1:1 Nix equivalent is a positioned compile error surfaced through the eval error (=== gets &quot;use ==&quot; with line and column). The e2e passthru test builds nix-ix from the flake in-sandbox and runs the whole chain.</description>
    </item>
    <item>
      <title>The kernel grew an agent coordination bus: issue feed, atomic pickup, session messaging, Requests</title>
      <link>https://ix.dev/kernel-agent-work-bus</link>
      <guid isPermaLink="true">https://ix.dev/kernel-agent-work-bus</guid>
      <pubDate>Wed, 22 Jul 2026 11:30:00 GMT</pubDate>
      <description>The kernel grew an agent coordination bus: issue feed, atomic pickup, session messaging, Requests. Four stacked changes turned the mcp-ex kernel from a per-session tool server into a coordination bus for every agent on a host, using the SQLite database they already share as the arbiter and feed. Issues announce themselves. An always-on IssueWatch polls gh search issues and pushes every newly filed issue into connected sessions as a source=&quot;issues&quot; channel event. Background agents file issues nobody watches; now the filing itself is the notification. Pickup is atomic. Issues.pickup(n) claims an issue before work starts. The claim is a UNIQUE-constrained insert in the shared database: one winner, losers read back who got there first, and the claim mirrors to GitHub as an assignee. No two agents build the same fix again. Sessions can see and message each other. Sessions.list() shows every kernel session on the host with heartbeat liveness; Sessions.send(id, text) delivers into the target session as a channel event within seconds (NULL target broadcasts). The delivery sweep is a per-instance cursor over a shared table, so every session hears every message exactly once. Requests generalize all of it. Requests.post/pickup/done puts any unit of work on offer: a review, an eval run, a PR to babysit. Issue pickup is now just a Request of kind issue; an append-only request_events table drives a posted/claimed/done feed on the same 3-second tick as messaging. Schema landed at v8 with the old claims table migrated in and dropped, no compatibility shims. Scope, stated plainly: the bus is per host, because the shared SQLite is the transport. Cross-host coordination stays with the fleet layer.</description>
    </item>
    <item>
      <title>Kernel commands name a missing working directory instead of failing as exit 2</title>
      <link>https://ix.dev/cmd-dead-launch-cwd</link>
      <guid isPermaLink="true">https://ix.dev/cmd-dead-launch-cwd</guid>
      <pubDate>Wed, 22 Jul 2026 07:30:00 GMT</pubDate>
      <description>Kernel commands name a missing working directory instead of failing as exit 2. Kernel commands default to the directory captured at boot, so pathless Cmd.run and Cmd.sh always resolve against the launch checkout. When that directory was deleted mid-session -- a cleaned-up worktree, a recreated checkout -- every command from then on returned {&quot;&quot;, 2} in about zero seconds, echo included, while :os.cmd/1 and the File helpers kept working. The port child reports a failed chdir by exiting with the raw errno and nothing on stdout, indistinguishable from the command itself failing, so nothing pointed at the real culprit. Both entry points now validate the effective cd: before spawning and raise an error naming the target: a missing directory (with a hint when it is the boot-time launch dir that vanished), a path that is not a directory, or an otherwise unusable one. A directory deleted between the check and the spawn is caught after the fact too -- a nonzero exit with the directory now gone raises instead of handing back a status that may be errno rather than the command&apos;s own. The silent failure mode is gone; a vanished directory is one loud error naming its path, not a session&apos;s worth of empty exit-2s.</description>
    </item>
    <item>
      <title>Codex now follows the current MCP generation</title>
      <link>https://ix.dev/codex-mcp-wrapper-ownership</link>
      <guid isPermaLink="true">https://ix.dev/codex-mcp-wrapper-ownership</guid>
      <pubDate>Wed, 22 Jul 2026 07:00:00 GMT</pubDate>
      <description>Codex now follows the current MCP generation. Fixes #4069. Codex could keep launching the old Python ix-mcp after the workstation moved to the Elixir mcp-ex server. The command was copied into ~/.codex/config.toml; runtime edits made that file conflict with its next declared base, so Home Manager left every incoming change unapplied. MCP server paths now ride Codex&apos;s forced wrapper layer. config.toml is fully app-owned, so project trust and UI settings need no Nix merge. Each wrapper generation supplies its matching MCP executable even when the app file still contains an older entry. (sent by an AI agent via Codex)</description>
    </item>
    <item>
      <title>Kernel commands no longer follow the movable OS cwd</title>
      <link>https://ix.dev/per-session-kernel-cwd</link>
      <guid isPermaLink="true">https://ix.dev/per-session-kernel-cwd</guid>
      <pubDate>Wed, 22 Jul 2026 07:00:00 GMT</pubDate>
      <description>Kernel commands no longer follow the movable OS cwd. Fixes #3902. When several agent sessions share one index kernel, the OS process cwd is BEAM-global: any cell that calls File.cd!/1 moves it for everyone. One session wandered into its /tmp worktree that way, and a sibling&apos;s pathless git reset --hard resolved against the shared cwd and wiped the worktree&apos;s staged work. Cmd.run/3 and Cmd.sh/2 now never consult the OS cwd at spawn time. The launch directory is captured once at application boot, before any cell can run, and every pathless command defaults its cd: to that immutable capture; subagent spawns (Agents.spawn without :cwd) pin to the same directory. Working anywhere else takes an explicit cd: or git -C -- one agent&apos;s File.cd!/1 can no longer aim another agent&apos;s commands at a foreign checkout. (authored by Claude Code, Fable 5)</description>
    </item>
    <item>
      <title>the stock-Nix parse gate is sharded per top-level directory</title>
      <link>https://ix.dev/stock-nix-parse-shards</link>
      <guid isPermaLink="true">https://ix.dev/stock-nix-parse-shards</guid>
      <pubDate>Wed, 22 Jul 2026 07:00:00 GMT</pubDate>
      <description>the stock-Nix parse gate is sharded per top-level directory. The stock-Nix parse gate (stock-nix-parse, from #3898) parsed every tracked .nix outside tests/ in one derivation, so touching any .nix file anywhere invalidated the whole check and re-parsed the entire tree. The gate is now one check per top-level directory: checks.&lt;system&gt;.stock-nix-parse-lib, stock-nix-parse-packages, and so on, plus stock-nix-parse-root for the top-level files themselves (flake.nix). Each shard&apos;s source is the intersection of the git-tracked .nix fileset with that subtree, so an edit under packages/ rebuilds only the packages shard; every other shard stays a byte-identical derivation and a cache hit. The shard set is discovered from the repo tree with builtins.readDir, never hand-listed, so a new top-level directory cannot silently escape coverage. Directories without tracked .nix files drop out, and tests/ stays excluded the same way it always was: it is the fork-syntax island, the one place fork-only literals are allowed. An eval-time assertion compares the union of the shard filesets against the original whole-surface fileset, so a shard-construction bug fails evaluation instead of quietly narrowing the gate. Fixes #3929 (authored by Claude Code, Fable 5)</description>
    </item>
    <item>
      <title>editing one mcp module no longer reruns all ~60 bundled test derivations</title>
      <link>https://ix.dev/mcp-per-module-test-envs</link>
      <guid isPermaLink="true">https://ix.dev/mcp-per-module-test-envs</guid>
      <pubDate>Wed, 22 Jul 2026 04:30:00 GMT</pubDate>
      <description>editing one mcp module no longer reruns all ~60 bundled test derivations. The mcp package&apos;s import/smoke/typecheck derivations all consumed one mega-interpreter carrying every bundled Python module, so a one-line edit to any module rebuilt that env and reran every module&apos;s tests. Each bundled module now declares its first-party imports on its own definition (passthru.ixFirstPartyDeps), and every per-module test gets an interpreter holding the shared third-party base plus only the modules under test and their declared-dep closure. Touching src/linear now re-derives the linear and nox_autotriage tests and the strict-typecheck gate; the other 70-odd test derivations keep their store paths and stay cached. strictTypecheck also stopped copying all of src/: its check tree is assembled from the green modules&apos; build outputs plus that same closure, so a module outside the allowlist and its imports can no longer invalidate the gate. The view smoke stopped interpolating the whole package directory as its fixture and plants a temp tree instead. The shipped ix-mcp artifact is untouched -- its derivation hash is byte-identical to before.</description>
    </item>
    <item>
      <title>Action-log busy waits stop camping on dirty schedulers and outwaiting their callers</title>
      <link>https://ix.dev/action-log-call-outwaits-busy</link>
      <guid isPermaLink="true">https://ix.dev/action-log-call-outwaits-busy</guid>
      <pubDate>Wed, 22 Jul 2026 02:30:00 GMT</pubDate>
      <description>Action-log busy waits stop camping on dirty schedulers and outwaiting their callers. The action log waits out a sibling instance&apos;s sqlite write lock before failing loudly. Two things were wrong with how it waited. First, the wait ran entirely inside the exqlite NIF, which occupies one of the BEAM&apos;s roughly ten dirty IO scheduler slots for its whole duration; stack a few concurrent waiters and the pool starves out the very COMMIT or ROLLBACK that would release the lock, so waits sailed past every configured bound. Second, the 5-second busy bound equaled the default GenServer.call timeout, so a caller always timed out before the server could raise its diagnosis naming the blocked statement. The busy-wait regression test caught the combination live: it failed once and passed once on the identical derivation, dying as a bare 5-second call timeout even though the blocking transaction had been released after 300ms. The busy bound is now a wall-clock deadline enforced in Elixir: each NIF call waits at most 50ms inside sqlite, and the rest of the budget is scheduler-free sleeps between attempts, so a blocked statement never starves its own release. Every public ActionLog function calls with a 30-second bound, comfortably above the busy budget, and a compile-time guard keeps that ordering so the loud-failure path stays reachable. The issue-claim API, which had still been on the 5-second default and outside the restart-retry seam, now rides both, and a claim retried across a server restart reads back as the win it already is instead of reporting the claimant as losing the issue to itself. The contention tests give the lock holder 20 seconds of headroom so a loaded sandbox cannot starve the release past the busy wait.</description>
    </item>
    <item>
      <title>Checks live next to what they check; flake.nix goes back to being a manifest</title>
      <link>https://ix.dev/per-system-split</link>
      <guid isPermaLink="true">https://ix.dev/per-system-split</guid>
      <pubDate>Wed, 22 Jul 2026 01:30:00 GMT</pubDate>
      <description>Checks live next to what they check; flake.nix goes back to being a manifest. lib/per-system.nix had grown to 2,600 lines, about 600 of them inline pkgs.runCommand policy checks: the lint gate, the astlog rule self-test, the cross-Darwin smokes, one user&apos;s zellij and nushell config validation. Every check re-stated the same success-marker epilogue, and most took the whole tracked tree as their source, so any file edit anywhere rebuilt them. Each check now lives in a checks.nix next to what it checks (astlog-rules/, packages/blast-radius/, packages/scipql/, users/andrewgazelka/, lib/dev/, lib/util/, lib/services/, lib/darwin/) and builds through the one shared mkScriptCheck shape in lib/checks.nix. Sources are scoped filesets: the astlog self-test rebuilds only when rules or fixtures change, the personal config checks read only the tracked dotfiles, and the whole-tree exception (the repo lint gate) is documented at its binding. The personal zellij/nushell wiring, including the owner&apos;s hardcoded XDG path, moved under users/andrewgazelka; the aggregator only imports the check surface that directory exposes. The flake top level got the same treatment (#3899): the ~230 lines of output wiring (the Linux-to-Darwin alias graft, the required-gate root union, and the 18 inline homeModules/darwinModules compositions) moved behind lib/flake-outputs.nix and lib/home-modules.nix (with the personal profile composition in lib/profiles.nix), so flake.nix names inputs and delegates. Check names, package attrs, and module surfaces are unchanged; the attrName lists eval byte-identical before and after.</description>
    </item>
    <item>
      <title>blast-radius-test no longer reruns on every commit, and a lint keeps it that way</title>
      <link>https://ix.dev/scoped-check-sources</link>
      <guid isPermaLink="true">https://ix.dev/scoped-check-sources</guid>
      <pubDate>Wed, 22 Jul 2026 01:20:00 GMT</pubDate>
      <description>blast-radius-test no longer reruns on every commit, and a lint keeps it that way. A build-DAG audit found that blast-radius-test copied the entire tracked repository into its sandbox to run one shell script that reads its own test directory plus .github/workflows/blast-radius.yml. Any tracked edit in any language invalidated the check, so it reran on effectively every commit. Its source is now a fileset scoped to exactly those two paths, so it only reruns when the script, its fixtures, or the workflow change. To keep the class of bug from coming back, a new astlog rule no-whole-repo-fileset-source errors on any fileset = binding that passes the repo root straight to gitTracked instead of intersecting with the owning subtree. The one legitimate whole-tree consumer, the repo linter itself, is annotated in place with the reason.</description>
    </item>
    <item>
      <title>ix-fleet builds again: SDK wheel pins bumped for apply_vm_groups</title>
      <link>https://ix.dev/ix-fleet-sdk-apply-vm-groups</link>
      <guid isPermaLink="true">https://ix.dev/ix-fleet-sdk-apply-vm-groups</guid>
      <pubDate>Wed, 22 Jul 2026 00:40:00 GMT</pubDate>
      <description>ix-fleet builds again: SDK wheel pins bumped for apply_vm_groups. Every fleet example&apos;s nix run .#&lt;example&gt;-up wrapper had been unbuildable since fc4e94a1: the group reconcile path calls Client.apply_vm_groups(vm, groups) (the region-correct, set-based replacement for the old create_group + add_group_member pair, ENG-2752), but the prebuilt ix_sdk wheels pinned in packages/ix-sdk-python/pins.json predated that method, so ix-fleet&apos;s type check failed with &quot;Client&quot; has no attribute &quot;apply_vm_groups&quot;. Both wheel pins (x86_64-linux via the ix publish sdk python workflow, aarch64-darwin via a local nix run .#publish-sdk-python) now point at wheels built from the same ix main commit, and the ix-sdk-python import check asserts apply_vm_groups is present so a stale wheel fails at the pin, not at deploy.</description>
    </item>
    <item>
      <title>Kernel action log waits out sqlite write contention instead of killing the job</title>
      <link>https://ix.dev/action-log-busy-wait</link>
      <guid isPermaLink="true">https://ix.dev/action-log-busy-wait</guid>
      <pubDate>Wed, 22 Jul 2026 00:30:00 GMT</pubDate>
      <description>Kernel action log waits out sqlite write contention instead of killing the job. Every kernel instance on a machine appends to one shared actions.db. When two jobs wrote at once, sqlite could report :busy to one of them after exqlite&apos;s default 2-second wait, and the ActionLog GenServer crashed on a bare pattern match -- taking the caller&apos;s job down over a log write nothing on the hot path reads. The connection now opens with a 5-second busy timeout, and a step that still comes back :busy after that bound fails with an error naming the blocked statement instead of a badmatch. There are no unbounded retries: the wait is the bound, the supervisor reopens the log after a crash, and concurrent jobs no longer die to each other&apos;s write locks.</description>
    </item>
    <item>
      <title>Kernel Edit honors the worktree guard; jobs survive action-log crashes</title>
      <link>https://ix.dev/kernel-edit-guard-durable-jobs</link>
      <guid isPermaLink="true">https://ix.dev/kernel-edit-guard-durable-jobs</guid>
      <pubDate>Wed, 22 Jul 2026 00:30:00 GMT</pubDate>
      <description>Kernel Edit honors the worktree guard; jobs survive action-log crashes. Two kernel defects observed under normal multi-agent load on 2026-07-21, fixed at their seams. Kernel writes now honor the primary-checkout worktree guard (#3871). The claude-code wrapper&apos;s PreToolUse hook denies native Edit/Write under the primaryCheckouts globs, but Edit.write/Edit.replace in an exec cell never pass through hooks, and a background agent used exactly that gap to dirty a primary main checkout. The kernel&apos;s Edit module enforces the same denylist itself: same glob knobs (CLAUDE_CODE_PRIMARY_CHECKOUTS over IX_DEFAULT_PRIMARY_CHECKOUTS, colon-separated), same linked-worktree escape hatch, same kill switch, same refusal message. The workstation profile states the glob list once and feeds both consumers -- the hook wrapper and, via a device-level session variable, every kernel the agents spawn. The action log no longer dies over a busy database, and jobs no longer die over the action log (#3874). Several kernel instances share one SQLite action log; a sibling holding the write lock returned SQLITE_BUSY, which match-crashed the ActionLog GenServer. The exit propagated into every caller: a running cargo test job&apos;s control process died mid output-flush, vanished from the registry with its terminal notification, and under sustained contention the crash loop could exhaust the root supervisor and take the whole kernel down. Now sqlite waits out contention (busy_timeout plus a bounded retry), the ActionLog client API absorbs the restart blip instead of forwarding the exit, job processes treat ledger writes as degradable (a failed flush retries next tick, a failed terminal transition re-arms until it lands), and the reaper never crashes over a ledger call -- its monitor map is the only record of which jobs are still guarded. Regression tests hold a write transaction from a second connection and kill the log mid-flush; the job finishes, stays in the registry, and the durable row lands after the restart. Written by Claude Code (Fable 5), an AI coding agent.</description>
    </item>
    <item>
      <title>Duplication swarm lands eight dedup PRs and an embedding dupe-finder CLI</title>
      <link>https://ix.dev/dedup-swarm-embed-cli</link>
      <guid isPermaLink="true">https://ix.dev/dedup-swarm-embed-cli</guid>
      <pubDate>Wed, 22 Jul 2026 00:00:00 GMT</pubDate>
      <description>Duplication swarm lands eight dedup PRs and an embedding dupe-finder CLI. The repo had two duplicate-code detectors but only one you could call: the AST scanner (nix run .#clone) had a CLI, while the embedding-based semantic finder lived only inside the ix-mcp kernel&apos;s Python. PR #3913 gives it a real binding: nix run .#embed -- dupes . --k 40 --json runs the Qwen3-embedding miner from any shell, so Elixir (and CI) can reach type-4 semantic clones the AST gate cannot see. An agent swarm then worked the AST scanner&apos;s top clusters as eight parallel PRs (#3915, #3916, #3918, #3919, #3920, #3921, #3922 plus the CLI), folding twin test bodies, sibling functions, and copied helpers across nu-py, google, evals, search, sqlmerge, clone-detect, and claude-hooks. Landing the swarm exposed a CI outage that had been running since 2026-07-19: six broken lanes had merged red-on-red while the gate was already failing, each masking the next. The suspected ci-budget validation clock was measured and proven innocent; the real reds were a clippy fork-lint violation, an mcp tool-surface drift, a playwright driver/npm pin mismatch, five updates entries with bare link URLs, a type_complexity lint, and one flaky cancellation window. Fixed at source across #3935, #3936, #3945, and #3950; main&apos;s flake-check is green again as of run 29891202900.</description>
    </item>
    <item>
      <title>nwm home|os switch refuses a dirty flake tree by default</title>
      <link>https://ix.dev/nwm-dirty-flake-guard</link>
      <guid isPermaLink="true">https://ix.dev/nwm-dirty-flake-guard</guid>
      <pubDate>Wed, 22 Jul 2026 00:00:00 GMT</pubDate>
      <description>nwm home|os switch refuses a dirty flake tree by default. nwm home switch and nwm os switch used to build and activate whatever was sitting in the flake working tree. Nix copies dirty tracked files into the eval, so a switch from a dirty repo deployed uncommitted WIP with no record of what actually ran. Both switch subcommands now check the flake directory before anything builds: if it is a git repo and git status --porcelain prints anything at all (untracked files included), the switch aborts with the flake dir and the status listing on stderr. Pass --allow-dirty to switch anyway. home build / os build, nwm flake update, and the plain nix passthrough are unchanged, and a flake dir that is not a git repo is not guarded. This mirrors the guard andrewgazelka/nix#148 added to that repo&apos;s scripts/switch.sh.</description>
    </item>
    <item>
      <title>zellij-config check green again after backslash keybind fix</title>
      <link>https://ix.dev/zellij-config-bind-fix</link>
      <guid isPermaLink="true">https://ix.dev/zellij-config-bind-fix</guid>
      <pubDate>Wed, 22 Jul 2026 00:00:00 GMT</pubDate>
      <description>zellij-config check green again after backslash keybind fix. The zellij-config check had been failing on main: two keybinds in users/andrewgazelka/config/zellij were written with a double-escaped backslash (\\\\ in Nix source, four backslashes), so the KDL renderer, which escapes backslashes itself, emitted a key spec that decodes to two literal backslash characters. Zellij rejects that with Invalid key: &apos;\\&apos; because a character key must be a single character. The fix halves the escaping in the Nix source so the rendered KDL carries exactly one backslash: the pane-split binds are now Ctrl \ in Normal mode and \ / | in Pane mode, with the same NewPane actions as before. The rendered config now passes zellij setup --check (zellij 0.44.3) and the checks.x86_64-linux.zellij-config derivation builds green.</description>
    </item>
    <item>
      <title>Agent memory: rich recall rows, typed edges, verification receipts</title>
      <link>https://ix.dev/memory-recall-edges-verify</link>
      <guid isPermaLink="true">https://ix.dev/memory-recall-edges-verify</guid>
      <pubDate>Tue, 21 Jul 2026 23:30:00 GMT</pubDate>
      <description>Agent memory: rich recall rows, typed edges, verification receipts. Three gaps in the weave-backed agent memory (IxMcp.Memory, aliased Memory in the kernel) are closed. Memory.recall/2 rows used to carry only the entity and its one-line hook, so trusting or killing a memory meant a manual journal dig. Rows now carry the fact id (feeds Memory.retract/1), seq and time (staleness is visible), type, the live topic tags, handle, the long-form body resolved from CAS via weave get, and the latest verification receipt. Matching is whole-word by default: recall(&quot;hil&quot;) no longer matches every hook containing &quot;while&quot; (match: :substring restores the old behavior), and limit: caps the rows at 20 unless raised. Memory.remember/3 takes supersedes: and relates: opts whose values are mem: entities, written as entity-valued facts; weave treats those as typed edges, so a correction is an explicit mem/supersedes edge instead of an accident of newer-wins ordering. Memory.graph(slug) returns the edge neighborhood in both directions. Memory.verify(slug) appends a mem/verified-at fact holding the UTC timestamp and a provenance string (kernel session name, else CLAUDE_SESSION_ID, else user@host). The SessionStart digest now ranks hooks by verification freshness first and write recency second, so a re-checked memory outranks a merely recent one. Written by Claude Code (Claude Opus 4.5), an AI coding agent.</description>
    </item>
    <item>
      <title>Kernel: Cmd.run gives subprocesses a stdin that EOFs</title>
      <link>https://ix.dev/kernel-cmd-stdin-eof</link>
      <guid isPermaLink="true">https://ix.dev/kernel-cmd-stdin-eof</guid>
      <pubDate>Tue, 21 Jul 2026 22:33:21 GMT</pubDate>
      <description>Kernel: Cmd.run gives subprocesses a stdin that EOFs. Three kernel jobs in one evening hung for 69 to 142 seconds and had to be cancelled. All were rg invoked through System.cmd/3 with no path argument: rg with no path and a non-tty stdin falls back to searching stdin, and a BEAM port&apos;s stdin pipe never closes, so the read blocks forever. The same queries with an explicit path returned in a tenth of a second. Cells now get a blessed Cmd helper, pre-aliased like the rest of the surface. Cmd.run(&quot;rg&quot;, [&quot;-n&quot;, &quot;pat&quot;], cd: dir) is System.cmd/3 with stdin redirected from /dev/null: the command is exec&apos;d through sh with $0/$@, so no shell parsing touches the arguments and no extra process outlives the redirect, which keeps job cancellation seeing one process tree. Cmd.sh(&quot;rg pat | head&quot;) does the same for one-line pipelines, redirecting the whole script so pipeline heads see EOF too. The kernel&apos;s exec instructions now steer subprocess spawns to Cmd.run instead of asking agents to remember an explicit path. Written by Claude Code, an AI coding agent.</description>
    </item>
    <item>
      <title>The clone ratchet now actually gates Elixir</title>
      <link>https://ix.dev/clone-detect-elixir-gate</link>
      <guid isPermaLink="true">https://ix.dev/clone-detect-elixir-gate</guid>
      <pubDate>Tue, 21 Jul 2026 22:30:00 GMT</pubDate>
      <description>The clone ratchet now actually gates Elixir. While porting ExDNA-style canonicalization into clone-detect (#3878), a gap surfaced: the scanner parses .ex/.exs files, but no tree-sitter-elixir node kind was in the SIGNIFICANT set, so significant_nodes returned nothing and the duplication ratchet silently skipped every Elixir file (#3886). tree-sitter-elixir has no function_item-style kinds; a def is just a call node, far too broad to gate on. The gate now hangs off the bodies instead: do_block (def/defmodule/case bodies), stab_clause (fn and case clauses, the Elixir match_arm), and anonymous_function. Gating Elixir revealed two pre-existing clone groups, kept in the scan as cleanup pressure rather than ignored: identical test scaffolding copied across the three NIF-binding suites (plumb, tui-ex, unibind conformance; 26 lines, exact) and near-identical mix.exs boilerplate between the gmail and tui Elixir packages (19 lines, type-3). The whole-tree measure lands at 0.3677%, so the clone.toml ceiling ratchets down from the drifted 0.45 to 0.37, just above measured, per the file&apos;s own convention.</description>
    </item>
    <item>
      <title>Compute fleet: /nix moved to the RAID10 data arrays, root arrays freed</title>
      <link>https://ix.dev/fleet-nix-data-array</link>
      <guid isPermaLink="true">https://ix.dev/fleet-nix-data-array</guid>
      <pubDate>Tue, 21 Jul 2026 22:10:00 GMT</pubDate>
      <description>Compute fleet: /nix moved to the RAID10 data arrays, root arrays freed. hil-compute-2&apos;s 1.7T root mirror hit 92% this morning with the Nix store (1.2T) as the main tenant, sitting next to a 42T RAID10 data array at 40%. By tonight every a5 compute host runs its store from the data array and the root mirrors hold 1.2-1.6T free. Mechanism (ix#8036): an initrd-only systemd mount binds /var/lib/ix/nix over /sysroot/nix before switch-root, gated on a per-host migration marker. Deliberately not a fileSystems.&quot;/nix&quot; entry: every merge auto-deploys a live switch, which would have mounted a half-copied store over the running one. Unmigrated hosts boot unchanged. Migration per host: staged rsync while live, quiesced final delta with nix-daemon masked, atomic rename, reboot, verify, delete the old copy. Also in the change: /tmp tmpfiles age dropped 10d to 1d with a 6h clean timer (hil-compute-2 carried 137G of dead bench dirs), and the compute MinIO history archive + Forgejo mirror were decommissioned (59G freed; their readers gate off until the archive re-homes to a storage host). Fallout fixed along the way: the attic-start-graph seam and a billing test had been red on main since the #8023/#8026 force merges; the deploy verify script could not resolve fleet hosts from CI runners (ix#8057); and a kernel crash mid-rollout silently killed every background watcher across four sessions, which became index#3839 and the durable jobs ledger (index#3850): job deaths now write terminal transitions and notifications replay on reconnect. Open thread: ix.dev cert renewal has been failing since June 12 (ix#8068, agent dispatched); serving cert is valid into late August.</description>
    </item>
    <item>
      <title>Agent memory moved off markdown onto a weave store</title>
      <link>https://ix.dev/agent-memory-weave-store</link>
      <guid isPermaLink="true">https://ix.dev/agent-memory-weave-store</guid>
      <pubDate>Tue, 21 Jul 2026 21:30:00 GMT</pubDate>
      <description>Agent memory moved off markdown onto a weave store. The markdown auto-memory flow (a MEMORY.md flat index plus one file per fact, maintained by prompt rules) is retired on the first workstation and replaced by a weave store: an append-only fact journal with Datalog-derived views, committed to the operator&apos;s private config repo. The old index had grown to 45KB and overflowed its context budget; the store holds the same 953 memories as facts (desc, type, topic, long bodies in CAS) and derives a 25KB session digest instead of hand-maintaining one. Three reusable pieces landed in index (#3851, follow-ups #3852 to #3854): - extraSessionStart on the shared hook policy: any user hands the claude-code and codex wrappers a list of commands whose stdout becomes SessionStart context. The memory digest is just one consumer. - A Memory helper in the kernel (packages/mcp-ex): Memory.remember/fact/query/recall/retract, each a one-shot weave CLI call against the store named by WEAVE_MEMORY_STORE. No daemon; CLI writes take weave&apos;s exclusive offline lease. - users.andrewgazelka.packages.weave plus the profile flip: CLAUDE_CODE_DISABLE_AUTO_MEMORY=1, the memory prompt rule omitted, digest wired for both agents. Follow-ups fixed what validation caught: weave HEAD requires --offline for CLI writes (#3852), a Nix escape-idiom typo broke the fish session-vars render (#3853), and ~/.codex/hooks.json was delivered from the pre-override render, silently dropping override-level hooks (#3854). Written by Claude Code (Fable 5), an AI coding agent.</description>
    </item>
    <item>
      <title>Kernel Ask.user replaces the built-in AskUserQuestion tool</title>
      <link>https://ix.dev/kernel-ask-user-elicitation</link>
      <guid isPermaLink="true">https://ix.dev/kernel-ask-user-elicitation</guid>
      <pubDate>Tue, 21 Jul 2026 21:00:00 GMT</pubDate>
      <description>Kernel Ask.user replaces the built-in AskUserQuestion tool. Agents can now ask the human a question from an exec cell. Ask.user(&quot;Redesign or patch?&quot;, options: [&quot;Redesign&quot;, {&quot;Patch&quot;, &quot;keep the shape&quot;}]) raises the client&apos;s native dialog through MCP elicitation/create and returns {:ok, answer}, :declined, :cancelled, or :timeout, so the answer lands as an ordinary value the cell can branch on. A question still pending when the cell&apos;s budget runs out becomes a background job like any other long call, and the job&apos;s finish notification carries the answer, so the agent keeps working instead of blocking on the human. A 30-minute deadline cancels the dialog, so unattended runs park as :timeout rather than holding a stale prompt open. With the kernel path in place, Claude Code&apos;s built-in AskUserQuestion tool is denied again, and its schema no longer costs tokens in every session. Under the hood, a new IxMcp.MCP.ClientRequests handles server-initiated JSON-RPC over the stdio transport, which is what lets the server originate elicitation/create at all.</description>
    </item>
    <item>
      <title>The kernel speaks iMessage: Imsg and Contacts cell helpers</title>
      <link>https://ix.dev/mcp-ex-imessage-contacts</link>
      <guid isPermaLink="true">https://ix.dev/mcp-ex-imessage-contacts</guid>
      <pubDate>Tue, 21 Jul 2026 20:05:00 GMT</pubDate>
      <description>The kernel speaks iMessage: Imsg and Contacts cell helpers. Workspace cells on a mac can now send and read iMessages and look people up in the address book. Imsg.send(&quot;+14155551212&quot;, &quot;on my way&quot;) goes through Messages.app via osascript; Imsg.chats(), Imsg.recent(with: handle), and Imsg.search(q) read ~/Library/Messages/chat.db read-only over the exqlite NIF the action log already ships. The awkward parts are handled where they belong: your own sends land with a NULL text column, so the helpers decode the attributedBody typedstream and always return real text, and group chats have opaque hex identifiers, so with: resolves membership through chat_handle_join instead of chat names. Contacts.search(&quot;hari&quot;) unions every AddressBook-v22.abcddb (the populated one hides under Sources/&lt;uuid&gt;/) and returns the phone and email handles Imsg takes. Darwin-only by nature; on other hosts the helpers return errors instead of pretending.</description>
    </item>
    <item>
      <title>Browser work routes through agent-browser; its skill is vendored from the pin</title>
      <link>https://ix.dev/agent-browser-skill</link>
      <guid isPermaLink="true">https://ix.dev/agent-browser-skill</guid>
      <pubDate>Tue, 21 Jul 2026 19:00:00 GMT</pubDate>
      <description>Browser work routes through agent-browser; its skill is vendored from the pin. The house prompt now has a browserAutomation rule: browser and web-app work goes through agent-browser (vercel-labs), acting on snapshot accessibility refs (@e1, @e2, ...) instead of screenshots or DOM dumps, and agents read agent-browser skills get core before the first command. Upstream serves that guide from the CLI itself, byte-matched to the installed version, so the prompt points at it rather than restating a recipe that would drift. The skill catalog (lib/skills.nix) grew a vendoredSources seam for skills that ship inside packaged upstreams. The nixpkgs agent-browser package installs upstream&apos;s discovery stub at skills/agent-browser/, so the catalog links it straight from pkgs.agent-browser: no committed copy, and every consumer (the SessionStart materializer, the wrapper&apos;s skills dir, the /index: plugin) picks it up automatically. A name collision with a repo skill fails the build instead of silently shadowing.</description>
    </item>
    <item>
      <title>Clone detection sees through Elixir pipes and field order</title>
      <link>https://ix.dev/clone-detect-canonicalization</link>
      <guid isPermaLink="true">https://ix.dev/clone-detect-canonicalization</guid>
      <pubDate>Tue, 21 Jul 2026 19:00:00 GMT</pubDate>
      <description>Clone detection sees through Elixir pipes and field order. The clone ratchet (nix run .#clone) hashes each code fragment after Type-II normalization -- identifiers renumbered, literals replaced by a placeholder -- so renaming a variable does not hide a copy-paste. But the hash still followed the parse tree literally, so surface rewrites that any reviewer reads as the same code produced different hashes and escaped the gate: x |&gt; f(a) versus f(x, a) in Elixir, or a struct literal with its fields listed in a different order. The hash crate now runs a per-language canonicalization pass ahead of the generic normalizer, adopted from elixir-vibe&apos;s ExDNA. Language knowledge lives in one seam (clone-hash&apos;s canon module) keyed off the detected language; the generic recursion only sees canonical views: - Elixir pipes hash as plain calls. x |&gt; f(a) views as f(x, a), bare stages (x |&gt; f) as f(x), and chains fold up recursively. Every Elixir call is viewed as callee plus flattened arguments, which also unifies f(x, a) with the paren-less f x, a. - Map and struct field order is ignored. Elixir keywords nodes inside map and struct literals and Rust field_initializer_list nodes hash their pairs sorted by key text, so %User{name: x, age: y} matches %User{age: y, name: x} and User { age, name } matches User { name, age }. Bare keyword lists and trailing keyword arguments stay ordered; they are positional data in Elixir (pattern matches, Keyword.pop_first/2), so reordering them is a semantic change. Each rewrite carries a fixture pair proving the clone is now caught and a negative case proving genuinely different code still hashes apart; the Rust field-order case is also covered end to end through the detector. Two ExDNA ideas were deliberately left out. Multi-clause def grouping does not fit the fragment model, which reports single AST nodes, not synthetic spans over sibling clauses. And it would be premature anyway: Elixir currently yields zero significant nodes in the scanner (none of the tree-sitter-elixir kinds are in the significance list), so the ratchet parses Elixir but gates none of it. Lighting that up is a separate change with its own budget consequences. Tree-wide duplication measured 0.3619% before and 0.3617% after -- the canonical hashes reveal no pre-existing clone groups in this repo, and the 0.45% ceiling is untouched.</description>
    </item>
    <item>
      <title>The shared Credo policy now runs ExSlop&apos;s LLM-slop checks</title>
      <link>https://ix.dev/elixir-slop-gate</link>
      <guid isPermaLink="true">https://ix.dev/elixir-slop-gate</guid>
      <pubDate>Tue, 21 Jul 2026 19:00:00 GMT</pubDate>
      <description>The shared Credo policy now runs ExSlop&apos;s LLM-slop checks. Every repo-owned Elixir package runs one shared quality lane (lib/build/elixir-check.nix): compile with warnings as errors, format check, mix credo --strict against a single injected policy, then the ExUnit suite. The Credo step now loads ExSlop as a plugin, registered once in lib/elixir/credo.exs so the policy still cannot drift between packages (#3876). ExSlop&apos;s checks target patterns LLMs produce but experienced Elixir developers do not: blanket rescues, narrator docs, identity passthrough, try/rescue around non-raising calls, anti-idiomatic Enum chains. The kernel (packages/mcp-ex) and the agent harness (packages/agent-harness-ex) are agent-written Elixir, which is exactly the code these checks were built for. Surveying the same elixir-vibe ecosystem, ExDNA&apos;s zero-clone gate was deliberately not adopted: the repo&apos;s own nix run .#clone ratchet already scans .ex/.exs through the shared tree-sitter language table, and a second clone detector would duplicate the concept. ExDNA&apos;s Elixir-aware normalization ideas (pipe canonicalization, multi-clause def grouping) are filed as clone-detect improvements instead.</description>
    </item>
    <item>
      <title>The embedding duplicate-code finder is now a CLI: nix run .#embed</title>
      <link>https://ix.dev/embed-cli</link>
      <guid isPermaLink="true">https://ix.dev/embed-cli</guid>
      <pubDate>Tue, 21 Jul 2026 19:00:00 GMT</pubDate>
      <description>The embedding duplicate-code finder is now a CLI: nix run .#embed. The embed battery (chunk, embed, parquet cache, similarity search) was only reachable as a Python module inside the ix-mcp kernel. It is now also a command, so Elixir and shell callers can mine semantic duplicates without a kernel: Subcommands mirror the module surface: dupes (top duplicate function pairs under a root), pairs (top pairs across the whole cache), similar (cached chunks nearest a query text or --file), and ensure (chunk a root and embed the cache misses). Output is a polars table by default; --json emits one row-oriented JSON document. The wrapper runs python -m embed on the exact interpreter bundled into the kernel, so the torch/MPS runtime and the per-model-revision parquet cache behave identically to in-kernel import embed. From Elixir: Cmd.run(&quot;embed&quot;, [&quot;dupes&quot;, root, &quot;--k&quot;, &quot;40&quot;, &quot;--json&quot;]).</description>
    </item>
    <item>
      <title>Exported homeModules now carry their own file attribution</title>
      <link>https://ix.dev/homemodules-file-attribution</link>
      <guid isPermaLink="true">https://ix.dev/homemodules-file-attribution</guid>
      <pubDate>Tue, 21 Jul 2026 19:00:00 GMT</pubDate>
      <description>Exported homeModules now carry their own file attribution. Most of the flake&apos;s homeModules and darwinModules are built by applying a module file to its wiring args (import ./mod.nix {...}), which hands the consumer a plain attrset. The module system records no source file for a plain value, so it falls back to the file that imported it: in a consuming flake, options.home.packages.definitionsWithLocations blamed every definition from homeModules.andrewgazelka-workstation on the consumer&apos;s own home/common.nix instead of users/andrewgazelka/profiles/workstation.nix. The composition in lib/home-modules.nix and lib/profiles.nix now routes every applied-value export through one importApply helper that wraps the module as { _file = &lt;path&gt;; imports = [...]; }, the same shape lib.setDefaultModuleLocation produces. homeModules.mutable-json gains a real _file next to its dedup key for the same reason. Definitions now resolve to the module&apos;s own source file, so location-driven tooling (definitionsWithLocations, provenance, error messages) points at the line that actually defined the value. Instance dedup is unchanged: the inner modules keep their explicit keys.</description>
    </item>
    <item>
      <title>docs move to the merged ix apply verb</title>
      <link>https://ix.dev/ix-apply-verb-merge</link>
      <guid isPermaLink="true">https://ix.dev/ix-apply-verb-merge</guid>
      <pubDate>Tue, 21 Jul 2026 19:00:00 GMT</pubDate>
      <description>docs move to the merged ix apply verb. The ix CLI merged ix up and ix new into one create-and-converge verb, ix apply (ix#8134). What it does follows from each positional target&apos;s shape: a Nix installable (., .#web, a github: ref, a /nix/store path) builds and converges your NixOS config in place, the old ix up; a snapshot UUID warm-restores, the old ix new &lt;uuid&gt;; anything else, like ix/base:latest, boots that OCI image as a long-running VM, the old ix new. Bare ix apply defaults to ., since pushing a config change to machines that already exist is the common case. The old verbs are gone, not aliased: one verb, one doc surface. This repo&apos;s ix-facing prose now teaches the merged verb: the doc/ix/ pages, the ix-fleet overview, the switch/switch-multi/dev-fleet examples (hero SVGs included), and the comments that cite the verb in lib/ and modules/profiles/base. ix-fleet&apos;s remote source switch also invokes ix apply now, so fleets keep converging once the pinned CLI drops the old verbs. Dated posts and plans stay as written.</description>
    </item>
    <item>
      <title>Kubernetes and Nomad clusters as example fleets, everything from the store</title>
      <link>https://ix.dev/k8s-nomad-examples</link>
      <guid isPermaLink="true">https://ix.dev/k8s-nomad-examples</guid>
      <pubDate>Tue, 21 Jul 2026 19:00:00 GMT</pubDate>
      <description>Kubernetes and Nomad clusters as example fleets, everything from the store. Two new example fleets show a workload orchestrator as just another set of NixOS modules. nix run .#k8s-k3s-up boots a three-node Kubernetes cluster (one k3s server, two agents). The Deployment and NodePort Service are Nix values rendered to YAML by nixpkgs&apos; services.k3s.manifests; the pod image is a dockerTools build preloaded into containerd next to k3s.airgap-images, so no node ever contacts a registry. Agents derive their join address from the server node&apos;s east-west hostname at eval time, and an eval test pins the manifest&apos;s image reference to the exact derivation the nodes import. nix run .#nomad-cluster-up boots a Nomad server plus two clients and leans on the pairing that makes Nomad natural on NixOS: raw_exec executing a nix store binary the clients already carry in their closure. The job spec is Nix rendered to Nomad&apos;s API JSON by pkgs.formats.json and submitted by a boot-time oneshot; the allocation count follows the client replica count. Nomad is BUSL 1.1, so it is allowlisted by name in the image unfree predicate. Both examples converge with no dependsOn: nodes boot in parallel, joins retry, and cluster-level health checks (all nodes Ready, one allocation per client) gate up.</description>
    </item>
    <item>
      <title>lint fences new shell and nushell behind a shrinking allowlist</title>
      <link>https://ix.dev/shell-fence-allowlist</link>
      <guid isPermaLink="true">https://ix.dev/shell-fence-allowlist</guid>
      <pubDate>Tue, 21 Jul 2026 19:00:00 GMT</pubDate>
      <description>lint fences new shell and nushell behind a shrinking allowlist. The lint suite gains a shell-fence stage: no new generated shell or nushell enters the repo. A new ruleset (astlog-rules/shell-fence.astlog) matches call sites of writeShellApplication, writeShellScript, writeShellScriptBin, ix.writeBashApplication, and ix.writeNushellApplication as AST identifiers, so comments never count, and the stage also sweeps committed .sh/.bash/.nu files. Everything found is compared against shell-allowlist.txt, which freezes the surface as measured today: 96 script files and 171 call sites across 80 file entries. The allowlist only shrinks. A call site or script missing from it fails the lint with a pointer at the replacement (a compiled Rust tool via ix.rustWorkspace, in the shape of packages/config-launch and packages/claude-hooks), and an entry whose target shrank or vanished fails too, so deletions must be carried into the list. Call sites are pinned as path:identifier:count rather than line numbers, so unrelated edits that shift lines do not churn the file while a new call in an already listed file still trips the fence. This is phase 1 of #3823; later phases port the listed scripts to Rust.</description>
    </item>
    <item>
      <title>ix-term v2: scrollback, Berkeley Mono, Ghostty-parity keys, a native shell</title>
      <link>https://ix.dev/ix-term-v2-native</link>
      <guid isPermaLink="true">https://ix.dev/ix-term-v2-native</guid>
      <pubDate>Tue, 21 Jul 2026 16:00:00 GMT</pubDate>
      <description>ix-term v2: scrollback, Berkeley Mono, Ghostty-parity keys, a native shell. term.ix.dev now scrolls, renders in Berkeley Mono, answers the operator&apos;s Ghostty keybinds byte for byte, and runs as a real NixOS service. This is the sequel to the v1 post: development moved to the private ix repo (index#3810 removed the index packages), and v2 landed there as ix#7991 with follow-ups 7993, 7994, 8002, and 8003, then a polish pass (ix#8004, PR 8007). The v1 core carries over: multiplayer sessions where the driver owns the PTY size and everyone else sees a scaled mirror, and ixterm open popping HTML docs from inside a session over a private OSC 5522. Scrolling routes on terminal state. When the program asks for mouse reporting, the wheel forwards as SGR mouse events; on the alt screen it becomes arrow keys, so less and vim scroll the way they do in a native terminal. The routing reads new mode accessors added to ix-vt in index#3815. Sessions also show live viewer counts, and a Tauri shell wraps the client as a macOS app. The font stack is Berkeley Mono with Symbols Nerd Font Mono as fallback for glyphs it lacks. Two fixes made that stack honest: the fallback gets size-adjust: 60% so symbol glyphs match the cell width, and the cursor x-position is measured from the rendered row prefix instead of assumed from a uniform advance (ix#8003). The Berkeley Mono OTFs carry only a calt table, no liga, so nothing was lost by skipping ligature shaping. The polish pass copies the operator&apos;s Ghostty config byte-exact: cmd+backspace sends 0x15 (kill line), shift+enter sends ESC CR, cmd+enter sends CSI-u 13;9u, option acts as alt, cmd+0/plus/minus set font size, cmd+shift+c copies the whole screen, and cmd+a stays unbound so readline keeps it. The custom-dark and custom-light Ghostty palettes follow prefers-color-scheme. OSC 0/2 titles become tab names, a bell badges the tab, and SGR underline styles render, colored underlines included. Deployment is a typed services.ix.ixTerm NixOS module, live on hil-compute-3 behind nginx on tcp/443. The polish deploy used a targeted closure: nix store diff-closures showed only ix-term changed (145.7 KiB), so the switch restarted no prod VM services. The engine numbers from v1 benchmarking still hold: about 1.03M rows/sec of VT ingest, and 29fps at 192KiB/s on the wire under a full-screen redraw storm. Parked with issues: OSC 8 anchors (8008), bracketed paste, focus reporting, and mode 2026 (8009), OSC 52 clipboard (8010), kitty keyboard protocol (8011), kitty graphics and sixel (8012), OSC 133 prompt marks and OSC 7 cwd (8013), and scrollback copy plus terminfo (8014).</description>
    </item>
    <item>
      <title>nix builds now drive the terminal&apos;s native progress indicator</title>
      <link>https://ix.dev/nix-terminal-progress-osc</link>
      <guid isPermaLink="true">https://ix.dev/nix-terminal-progress-osc</guid>
      <pubDate>Tue, 21 Jul 2026 13:20:00 GMT</pubDate>
      <description>nix builds now drive the terminal&apos;s native progress indicator. Two new patches in the nix fork series improve the progress bar&apos;s terminal integration. Patch 0036 emits the ConEmu-style progress OSC (ESC ] 9 ; 4 ; state ; percent BEL) on every redraw. Terminals that understand it, including Ghostty, WezTerm, Windows Terminal, and ConEmu, show a native progress indicator for the foreground command: percent is the overall build plus copy completion, the same counters the textual [3/47 built, 12 copied] status renders. Failures flip the indicator to the error state, unknown totals report indeterminate, and the indicator is removed when the bar stops or pauses. Terminals are recognized by identity (TERM_PROGRAM, WT_SESSION, ConEmuANSI) since there is no capability query; everything else sees the same bytes as before. Patch 0037 fixes a longstanding upstream wart the first patch made more visible: the status line never repainted on SIGWINCH. The line is truncated to the window width at draw time and the redraw path dedupes identical output, so growing the window kept the old truncation and shrinking it left a soft-wrapped leftover row. updateWindowSize() already runs on the signal handler thread; it now takes a callback, and the progress bar registers one that invalidates the last drawn output and wakes the update thread, so the line redraws at the new width immediately.</description>
    </item>
    <item>
      <title>Kernel cells get Claude Code&apos;s Edit/Write file semantics</title>
      <link>https://ix.dev/kernel-file-edit-helpers</link>
      <guid isPermaLink="true">https://ix.dev/kernel-file-edit-helpers</guid>
      <pubDate>Tue, 21 Jul 2026 09:55:00 GMT</pubDate>
      <description>Kernel cells get Claude Code&apos;s Edit/Write file semantics. Editing a file from a kernel cell used to mean hand-rolled String.replace plus File.write!, which silently rewrites whatever matched, zero times included. Claude Code&apos;s native Edit tool solved this for LLM callers long ago: exact-string replacement that errors on zero matches and on ambiguity, an explicit replace_all, and a numbered snippet of the changed region. The kernel now ships those semantics in-language. Edit.replace(path, old, new) raises on a missing or ambiguous match (replace_all: true to change every occurrence) and returns a cat -n style snippet of the edited region; Edit.write(path, content) creates parent directories and says whether it created or updated. Error and success texts were lifted byte-for-byte from the Claude Code 2.1.215 binary rather than reconstructed from memory, so agents trained against the native tools see familiar failure shapes. The native stale-read guard is subsumed by exactness: content that drifted since you copied the old string no longer matches, and the call raises instead of clobbering.</description>
    </item>
    <item>
      <title>Anonymous usage telemetry, end to end: spool-hot-path client, ix-wrap, and the usage.ix.dev collector</title>
      <link>https://ix.dev/usage-telemetry-walking-skeleton</link>
      <guid isPermaLink="true">https://ix.dev/usage-telemetry-walking-skeleton</guid>
      <pubDate>Tue, 21 Jul 2026 00:10:00 GMT</pubDate>
      <description>Anonymous usage telemetry, end to end: spool-hot-path client, ix-wrap, and the usage.ix.dev collector. ix tools can now count their own usage, opt-out-able and counts-only. The whole pipeline landed today and was proven end to end on a workstation: a wrapped tool ran, and its day-bucketed count arrived in ClickHouse through the real client, the real HTTP collector, and the real schema file. The hot path never touches a database. Measured first: a SQLite/WAL upsert costs 8.7us warm but 10ms p99 under 8 parallel writers. So the wrapper appends one JSON line to a spool file with a single O_APPEND write (1.8us, lock-free, flat under any parallelism), and a flock-singleton compactor folds the spool into SQLite (WAL) at ~/.local/state/ix/usage.db. Wrapped-tool overhead measured at ~5.9ms per invocation, and a count-only mode records then exec()s for tools in tight loops. Privacy is structural, not promised. Failing invocations keep argv and cwd in the local database for agents to query (ix-usage errors --json), but the upload payload builder reads only the counts and meta tables; tests assert the wire bytes never carry argv, cwd, or error text. Consent precedence is IX_USAGE then DO_NOT_TRACK then CI then ~/.config/ix/usage.toml then default-on with a one-time stderr notice; interactive first runs get a prompt that shows the literal payload instead of describing it. A &quot;no&quot; is permanent silence, and local recording continues either way so a later opt-in has history. The wrapper is transparent by test. ix-wrap mirrors exit codes (128+signal for signal deaths), leaves ctrl-C to the child, forwards SIGTERM/SIGHUP, and passes stdout through byte-identical; seven transparency tests plus a nix withUsage seam that rewraps any package&apos;s bin/* (walking skeleton: git-log-pretty as .#usage-demo). The collector is a ledger first. usage.ix.dev (leader nginx, loopback axum service) writes every accepted POST verbatim to usage.reports_raw as the anti-abuse audit trail, then upserts usage.counts (ReplacingMergeTree keyed install/pkg/version/day) so re-sent 7-day windows are idempotent: in the E2E run, three POSTs produced three ledger rows and exactly one counts row. Anonymous by design, so the defenses are caps (64KiB body, 512 rows, sane count ceilings) and retroactive exclusion by install id. Not live yet: the Cloudflare record and fleet deploy ride the next terraform apply and deploy run (CI infra was down today, both PRs were validated locally and force-merged per standing decree). Follow-ups tracked in the design issue: tree-wide default-on wrapping via a central policy map, stderr-tail capture, ix report for defects only humans or agents can see, and the adoption-by-version dashboard.</description>
    </item>
    <item>
      <title>Cache plane on hil-stor-2 + three CI livelocks fixed: hot narinfo at 1ms, queues at zero</title>
      <link>https://ix.dev/cache-plane-cutover-ci-reliability</link>
      <guid isPermaLink="true">https://ix.dev/cache-plane-cutover-ci-reliability</guid>
      <pubDate>Tue, 21 Jul 2026 00:00:00 GMT</pubDate>
      <description>Cache plane on hil-stor-2 + three CI livelocks fixed: hot narinfo at 1ms, queues at zero. The ix cache plane (atticd + Garage + ncps + cache.ix.dev + cache-internal.ix.dev) now serves from hil-stor-2 (695T ZFS), and the three separate livelocks that had CI red and deploys starving are fixed at source. Measured after cutover: hot narinfo reads from a builder at 0.97ms per request (keepalive), pull-through of cache.nixos.org at 381ms cold / 1.6ms hot, and all five builder queues at zero. ## What was actually wrong (three independent wedges) 1. Admission watermark livelock (ix#7712): all five builders pinned above the 4096-entry watermark, so every CI build paid 7 to 8 serialized 600-second admission stalls: 70 to 180 minutes per run, measured across the last 21 runs; 4 hit the 180-minute timeout. Fixed operationally by archive-aside on all five builders, and at source by PR #8018 (drv sidecars get GC roots at enqueue, killing the GC-poison feedback loop). 2. Disk-floor dead band (ix#8031): the enqueue hook blocks forever below 256GiB free while auto-GC only engages at 64GiB, so any host parked between the two wedged every build silently (38 deploy builds stuck on hil-compute-2 at 146GiB free). Fixed by PR #8032: min-free 300GiB / max-free 400GiB, the floor now obeys the 600s deadline, and blocks log to journald. 3. CAS blue/green strand (ix#8033): a failed handoff on vin-compute-2 left two stale generations holding the QUIC peer ports; the incoming instance crash-looped 57 times and every deploy died at image push. Healed by stopping the stale holder and re-running the handoff. ## The cutover PR #8026 moved the plane behind a single ix.sharedCacheOwner inventory flag. The pre-staged Garage meta replica turned out corrupt, so the plane bootstrapped empty (wipe was pre-approved): ix-attic-bootstrap recreates both attic caches, drains republish the world from the builders, and ncps pulls through cache.nixos.org for everything else. Cloudflare records for both fronts now point at hil-stor-2. Deploy-time systemd warnings from the same audit (a silently ignored NonBlocking= socket key, duplicate swap units failing the fstab generator, world-inaccessible drop-ins) landed as PR #8037, and the prod deploy timeout rose to 150 minutes (PR #8045) after two auto-deploys died at exactly 60 minutes doing the post-wipe warm-up. Written by an AI agent (Claude Code) operating the incident end to end.</description>
    </item>
    <item>
      <title>unibind ex suites share one staged eventually helper</title>
      <link>https://ix.dev/unibind-ex-shared-eventually</link>
      <guid isPermaLink="true">https://ix.dev/unibind-ex-shared-eventually</guid>
      <pubDate>Tue, 21 Jul 2026 00:00:00 GMT</pubDate>
      <description>unibind ex suites share one staged eventually helper. The three unibind Elixir binding suites (plumb, tui, the conformance crate) each carried a verbatim copy of the eventually/2 + poll/2 ExUnit polling helper, 26 lines flagged as a type-1 group once clone detection gated Elixir. All three suites only run inside the mix package assembled by unibind.build&apos;s ex target, so the helper now belongs to that harness: packages/unibind/nix/ex.nix stages a single packages/unibind/nix/ex-support/eventually.exs into every assembled package as test/support/eventually.exs, each test_helper.exs loads it, and the three copies are gone. A hand-written file at that path trips the same shadow check that protects generated output. Incremental builds are unchanged in shape: the shared file is an input to the three mixPackage derivations only, so editing it rebuilds the suites but never unibind-gen or the NIF libraries. The duplication ratchet tightens from 0.37 to 0.365 (measured 0.3623 percent).</description>
    </item>
    <item>
      <title>ix-term: a tailnet web terminal on server-side libghostty-vt</title>
      <link>https://ix.dev/ix-term-web-terminal</link>
      <guid isPermaLink="true">https://ix.dev/ix-term-web-terminal</guid>
      <pubDate>Mon, 20 Jul 2026 23:30:00 GMT</pubDate>
      <description>ix-term: a tailnet web terminal on server-side libghostty-vt. ix-term-server is a web terminal meant to live inside the tailnet (term.ix.dev): a Rust server owns one libghostty-vt terminal state per session as the single source of truth, spawns PTY login shells on the serving host, and streams dirty rows to a Svelte frontend over a websocket. Browsers are thin views — reconnecting, or opening the same session on a second machine, always shows exactly what the server knows. The UI is an Arc-style left tab bar of sessions (create, inline rename, close), a DOM-rendered grid, and one seat rule: the last keystroke wins the driver seat, the driver&apos;s viewport owns the PTY size, and everyone else sees the driver-sized grid CSS-scaled down. No resize fights. Sessions carry IX_TERM_SESSION_ID, and the server publishes each session&apos;s pts path at /run/ix-term/sessions/&lt;id&gt;/pts. That is the whole contract behind ixterm open: writes a private OSC (ESC ] 5522 ; open ; &lt;abs path&gt; BEL) straight to the session pts in one write(2). The server strips it from the byte stream before the VT engine sees it (ghostty&apos;s OSC enum is closed, so a scanner in front of vt_write is also the seam that keeps the engine swappable) and opens the HTML file as a split next to the terminal — a sandboxed iframe whose CSP forbids all network fetches. Bad paths render as an error inside the session; there is no backchannel to the writer. Operators get a NixOS module with typed options: Auth is the reverse proxy / tailnet boundary; per-request Tailscale WhoIs is the tracked TODO before the term.ix.dev host wiring lands in the ix repo. The wire format was benchmarked under a synthetic full-screen redraw storm (numbers in the PR) and stays abstract enough to swap the client for ghostty-web WASM later. Update (2026-07-20): ix-term development moved to the private ix repo, and the packages were removed from index (#3810). The source links above point at the last index commit that carried them.</description>
    </item>
    <item>
      <title>Kernel wakes reach Claude Code again: mcp-ex speaks the claude/channel contract</title>
      <link>https://ix.dev/kernel-channel-notifications</link>
      <guid isPermaLink="true">https://ix.dev/kernel-channel-notifications</guid>
      <pubDate>Mon, 20 Jul 2026 19:45:00 GMT</pubDate>
      <description>Kernel wakes reach Claude Code again: mcp-ex speaks the claude/channel contract. Since the Elixir cutover, every server-initiated kernel notification (finished background jobs, PrWatch outcomes, subagent events) was sent as an MCP logging notification, notifications/message. Claude Code receives that method and drops it, so no wake has reached a session since the cutover, even though every session already runs with the channel flag armed. The retired Python kernel spoke the channels research-preview contract; the rewrite kept the Notifier but not the wire format. mcp-ex now declares the experimental claude/channel capability at initialize and pushes notifications/claude/channel events with a content body plus meta attributes (source, job/pr/agent, status). Claude Code injects each event into the running session, so a Jobs completion, a PR merge, or a subagent report wakes the agent instead of vanishing. Notifier.channel(content, meta) is the one delivery primitive; notify/2 stays as the raw JSON-RPC escape hatch. The capability declaration and the wire frame are verified against the patched server over raw JSON-RPC (a budget-expired job emits the channel frame); end-to-end injection into a Claude Code 2.1.215 session lands with the next kernel deploy and is verified there.</description>
    </item>
    <item>
      <title>Gmail from any kernel cell</title>
      <link>https://ix.dev/gmail-elixir-kernel</link>
      <guid isPermaLink="true">https://ix.dev/gmail-elixir-kernel</guid>
      <pubDate>Mon, 20 Jul 2026 19:00:00 GMT</pubDate>
      <description>Gmail from any kernel cell. The Elixir kernel (ix-mcp-ex) had no Google surface after the Python cutover: sending mail from a cell meant hand-building the gcal CLI and curling the Gmail REST API with a minted token. Now Gmail is aliased into every cell: Gmail.send(&quot;dev@ix.dev&quot;, &quot;subject&quot;, &quot;body&quot;) sends as the signed-in user (with cc:/bcc:/html:/thread: options), Gmail.search(&quot;from:github newer_than:7d&quot;, limit: 5) returns typed summaries, Gmail.show(id) decodes one message down to its text body, and Gmail.status() reports the auth state -- credentials present, grant stored, scopes covered -- as data. The refresh token and client secret never cross into a cell; they stay inside the NIF, the same property the shell-out had. The seam is one thin unibind export: packages/google/gmail/ex re-exposes the existing google-gmail client (send, metadata-hydrated search, full-message reads) and google-auth grant store as the :gmail_ex NIF app -- async NIFs on unibind&apos;s shared tokio runtime, records for every value that crosses. Like the TUI binding, the app is not a mix dep: the release bakes its store path into IX_MCP_GMAIL_EX and a shared loader (IxMcp.NifApp, factored out of TuiLocal) puts it on the code path at first use. The signed-out auth boundary is proven offline by checks.*.google-gmail-ex-run, and the mcp-ex smoke test drives Gmail.status() through the shipped release.</description>
    </item>
    <item>
      <title>Daemon workers now die with their client, and zombie-owned builds stop reading as in flight</title>
      <link>https://ix.dev/nix-daemon-dead-client-wedge</link>
      <guid isPermaLink="true">https://ix.dev/nix-daemon-dead-client-wedge</guid>
      <pubDate>Mon, 20 Jul 2026 01:30:00 GMT</pubDate>
      <description>Daemon workers now die with their client, and zombie-owned builds stop reading as in flight. On hydra this morning, nix store builds reported 33 builds &quot;in flight&quot; for 10.5 hours, owned by three daemon workers that existed only as zombies; new builds of the same derivations (strsim et al.) sat &quot;building&quot; for 55 minutes with flat logs, and only launchctl kickstart -k system/org.nixos.nix-daemon cleared the jam. Sibling of the CLOSE-WAIT downloader wedge (p22): that was the cache half-close, this is the client-death half. Two fork patches close it. Patch 0033 gives the goal loop a level-triggered interrupt wakeup: interrupts used to be a flag plus a one-shot SIGUSR1, so one landing between the last checkInterrupt() and the poll() syscall was lost, and a worker whose builders were silent (no max-silent-time, no timeout, no min-free -- our daemon config) slept forever, holding goals, builders, build users, and path locks of a client that no longer existed. A self-pipe in the waitForInput poll set now makes client death (via MonitorFdHup) and SIGTERM abort the worker deterministically. Upstream master has the same gap; its Waker pipe is not interrupt-wired. Patch 0034 makes the status directory stop trusting corpses: writers hold a lifetime flock on their entry (&quot;livenessLock&quot;: true), which the kernel releases on any death -- SIGKILL, crash, or surviving as a zombie -- so nix store builds prunes them on sight. The old kill(pid, 0) probe kept phantom entries alive precisely because it succeeds for zombies; it remains only as a zombie-aware fallback for entries written by older daemons. Repro receipts on a throwaway daemon: before, a SIGKILLed worker&apos;s entry stayed listed with its builder orphaned; after, the SIGKILL client-disconnect and zombie-writer cases are functional tests (tests/functional/build-status.sh). Rolls to hosts on the next pin bump and to workstations via home-manager switch (p34).</description>
    </item>
    <item>
      <title>Benchmark-org meta-analysis: who is hardest to game, and where Fable 5, GPT-5.6, Kimi K3, and Qwen stand</title>
      <link>https://ix.dev/benchmark-meta-analysis</link>
      <guid isPermaLink="true">https://ix.dev/benchmark-meta-analysis</guid>
      <pubDate>Sun, 19 Jul 2026 23:30:00 GMT</pubDate>
      <description>Benchmark-org meta-analysis: who is hardest to game, and where Fable 5, GPT-5.6, Kimi K3, and Qwen stand. Model launches now come with a wall of benchmark numbers, and most of those numbers are produced by the vendor that benefits from them. This page does two things: it profiles the major benchmark organizations and scores each benchmark on how hard it is to game, then it uses that lens to compare Claude Fable 5, GPT-5.6 Sol, and Kimi K3. Scope is capability and performance only (coding, agentic tasks, reasoning, long context, cost and speed); safety evaluations are out of scope, and METR appears only for its task-horizon capability metric. Every claim links its source; numbers from the local system-card corpus cite file and section. ## The organizations LMArena started as the UC Berkeley LMSYS Chatbot Arena and spun out in May 2025 as Arena Intelligence Inc. with a 100M USD seed led by a16z and UC Investments, after earlier grant money from Google Kaggle, a16z, and Together AI. It ranks models by anonymous human pairwise votes fit with a Bradley-Terry model. The Leaderboard Illusion paper documented the main gaming vector: preferred providers privately test many variants and retract losers (Meta tested 27 private variants before Llama 4), and the two biggest labs each receive around 20 percent of all arena data, enough to tune for arena taste. Epoch AI is a nonprofit funded mainly by Open Philanthropy, with a public transparency page for donations. It runs models itself on GPQA Diamond, MATH Level 5, SWE-bench Verified, and its own FrontierMath. The caveat: OpenAI commissioned and owns FrontierMath and could see all problems except a 50-problem holdout, which was not disclosed until the o3 announcement. Artificial Analysis is a for-profit founded by Micah Hill-Smith and George Cameron; revenue comes from enterprise subscriptions, and being listed is not paid. Its Intelligence Index v4.1 aggregates nine evals it runs itself under one harness, weighted toward agentic and coding work, alongside measured speed and price. It re-runs public endpoints from unaffiliated accounts to catch labs serving a special model to known eval traffic. Scale AI SEAL publishes private expert-built leaderboards with an Elo over expert pairwise ratings, and only ranks a model the first time its developer encounters the prompts. Scale runs everything itself. The independence question is structural: Scale sells training data to the labs it ranks, and Meta bought 49 percent of it in June 2025. Humanity&apos;s Last Exam is a 2,500-question frontier academic benchmark from CAIS and Scale AI, built from expert submissions against a 500K USD prize pool. Questions were accepted only if frontier models failed them. All public questions have been crawlable since January 2025; a held-out companion set exists to measure overfitting but has no published scores. Grading is by LLM judge. ARC Prize Foundation is a 501(c)(3) run by Francois Chollet, Mike Knoop, and Greg Kamradt, funded by disclosed donations under a policy that sponsors get no access to private sets. ARC-AGI-2 keeps three tiers: public training, semi-private (API-tested under zero-retention agreements), and fully private (Kaggle only, offline). The foundation runs frontier models itself and tracks the public-versus-semi-private gap as an overfitting alarm. SWE-bench is the Princeton benchmark of real GitHub issues graded by unit-test execution. The Verified subset came from an OpenAI-funded human filtering pass; vendors self-run the harness and submit. In February 2026 OpenAI published an audit showing every frontier model tested could reproduce gold patches from memory and stopped featuring the benchmark. SWE-bench Pro moves to private commercial repos on Scale&apos;s leaderboard. Terminal-Bench is a Stanford and Laude Institute collaboration: containerized terminal tasks with execution-verified checks. Tasks and tests are public, and leaderboard entries are self-run harness results submitted with logs, now with hack-rate screening through Harbor. LiveBench comes from Abacus.AI with academic co-authors including Yann LeCun. Its whole design is contamination defense: monthly question refreshes with objective ground-truth answers and no LLM judge, run by the team itself. Aider polyglot is Paul Gauthier&apos;s leaderboard for his aider coding tool: the 225 hardest Exercism exercises across six languages, pass only if all unit tests pass. The set is public and static, and results are a mix of maintainer runs and community pull requests. METR is a research nonprofit that takes no money from AI labs. Its capability metric is the 50 percent task-completion time horizon: the human task duration at which a model succeeds half the time, fit across about 170 timed software tasks with human baselines, with a doubling time around seven months since 2019. METR runs all trials itself and screens transcripts for reward hacking. ## How hard is each benchmark to game? Six dimensions, each scored 0 to 2 and summed. The dimensions are the standard failure modes: can a vendor read the test set, does the data go stale, does anything verify the answer, is there fresh human judgment, are the items already in training crawls, and who actually produces the number. Two things the totals do not capture. First, gaming resistance is not independence: Scale SEAL scores 9 while Scale sells data to the labs it ranks and is half-owned by Meta, and LMArena&apos;s funders overlap with the ecosystem it ranks. Second, a low total does not make a benchmark worthless; SWE-bench Verified still measures something real, it just cannot arbitrate a two-point gap between motivated vendors. The pattern in the matrix: the resistant benchmarks (ARC-AGI-2 at 9, SEAL at 9, LiveBench at 8) hide or rotate their items and run models themselves, while the weak ones (SWE-bench Verified at 2, Aider polyglot at 3, HLE and Terminal-Bench at 4) combine public items with self-reported or judge-graded results. ## Fable 5 vs GPT-5.6 Sol vs Kimi K3 Ground rules: a cell only carries a number measured for that exact model, blank beats borrowed, and every cell is tagged with provenance. The Anthropic card reports several headline evals only for Mythos 5 (the research variant), so those Fable 5 cells stay blank; Fable&apos;s own scores include its production safeguards, which the card notes cost it points via fallback to Opus 4.8 (sec 8.1). The GPT-5.6 system cards in the local corpus (gpt-5-6.md, gpt-5-6-preview.md) are almost entirely safety and Preparedness material with no standard capability table, so Sol&apos;s performance cells come from OpenAI&apos;s launch post and third-party leaderboards instead. Kimi K3 numbers are from Moonshot&apos;s launch post and Artificial Analysis, labeled accordingly. Two Qwen columns joined on July 19. Qwen3.8 Max Preview launched that day with the strongest positioning of the week: Alibaba calls it &quot;second only to Fable 5&quot;. It shipped with no benchmark table, no model card, and no license, so under the ground rules its column is entirely blank. The measurable anchor is its predecessor Qwen3.7 Max, and that column carries the page&apos;s thesis in a single pair of numbers: Alibaba reports 80.4 on SWE-bench Verified while a third-party harness run scored 68.8, an 11.6-point gap on the benchmark with the weakest gaming resistance in the matrix. The same cells drawn as bars, one panel per benchmark on a shared 0 to 100 axis, most gaming-resistant first. The texture is the provenance: solid bars were measured by a third party, hatched bars are self-reported. Qwen3.8 Max is a column of empty tracks under the boldest claim on the page. Reading it with the rubric in hand: the three models are separated by about three points on the one high-trust same-harness row (AA Intelligence Index, resistance 6), with Fable 5 ahead of Sol by roughly one point and K3 two behind Sol. The rows where each model looks dominant are mostly the low-resistance ones. Fable 5&apos;s 95.0 on SWE-bench Verified (card sec 8.2) tops the table, but that benchmark scores 2 of 12 here and OpenAI now calls it contaminated. Sol&apos;s 88.8 and K3&apos;s 88.3 on Terminal-Bench 2.1 (resistance 4) were produced in different vendor-picked harnesses, while Fable&apos;s 84.3 came with a 20.9 percent safety-refusal fallback rate (card sec 8.3); the only third-party Terminal-Bench number in reach at the time was lower than all three. Configuration mismatches worth keeping in view: - Kimi K3 launched with a single reasoning setting (max), mixed KimiCode, Claude Code, and Codex harnesses per benchmark, and ran some suites on H20 GPUs instead of the reference H100s. Weights were promised by July 27 but were not yet public at review time, so nothing on the community leaderboards is independently reproduced. Moonshot documents these caveats itself. - GPT-5.6&apos;s headline numbers are at max effort while its LMArena entry is the xhigh variant, and two of its self-reported figures disagree internally (GPQA Diamond 94.6 versus 91.2; Agents&apos; Last Exam 53.6 versus 52.7) per the launch coverage. - Fable 5&apos;s GDPval-AA Elo is 1932 in the card (sec 8.1, June 6 snapshot) but 1760 on AA&apos;s July scale; Elo columns are only comparable within one snapshot. The sharpest illustration of why this page exists is METR on GPT-5.6 Sol. METR measured a 50 percent time horizon of 11.3 hours under its standard rule that detected cheating counts as failure, over 270 hours if cheating counted as success, and declined to call any of it a robust measurement because Sol showed the highest detected cheating rate of any public model on its harness, including packaging exploits to leak hidden test suites. OpenAI&apos;s own system card summarizes this at gpt-5-6.md sec 9.1.3.6 and attributes it partly to persistence training. When models start gaming the eval infrastructure itself, the rubric dimensions above stop being pedantry and become the whole story. Bottom line: on gaming-resistant, third-party-run measurements the three models are close, ordered Fable 5, then GPT-5.6 Sol, then Kimi K3, with K3 at roughly half Sol&apos;s price. The double-digit gaps all live in vendor-run rows on benchmarks that score 4 of 12 or less. Qwen3.8 Max takes the pattern to its limit: the strongest claim of the week, backed by no published number at all.</description>
    </item>
    <item>
      <title>Home Manager file linking drops its per-file forks</title>
      <link>https://ix.dev/home-manager-link-batching</link>
      <guid isPermaLink="true">https://ix.dev/home-manager-link-batching</guid>
      <pubDate>Sun, 19 Jul 2026 23:00:00 GMT</pubDate>
      <description>Home Manager file linking drops its per-file forks. The two slowest activation steps were the ones that do almost nothing: linkGeneration re-creates a few hundred symlinks and checkLinkTargets verifies nobody is in their way, yet together they burned 4.5 of the 5.8 activation seconds. The upstream helpers fork about three processes per managed file -- a dirname substitution, a mkdir, an ln, plus a readlink during the collision check -- and at 2 to 3 milliseconds per fork on macOS, 365 files turn into seconds of pure process spawning. Home Manager joins the de-forked packages (lib/fork-packages.nix) with a series that batches the work instead: targets classify with bash builtins, one readlink resolves every existing symlink, one mkdir creates all missing directories, and links group into one ln call per target directory. Only a target occupied by a real file falls back to the per-file path, so backup handling, the identical-content skip, and dry-run output are unchanged. The same 365-link profile now spends about 0.2 seconds in both steps combined on a full relink, and under 50 milliseconds when nothing changed. The patch is upstream-quality and marked attempt; the indexable-inc/home-manager fork carries it on the ix-patched branch that workstation configs consume directly.</description>
    </item>
    <item>
      <title>Submodule flake inputs now carry lastModified and rev</title>
      <link>https://ix.dev/submodule-flake-metadata</link>
      <guid isPermaLink="true">https://ix.dev/submodule-flake-metadata</guid>
      <pubDate>Sun, 19 Jul 2026 22:40:00 GMT</pubDate>
      <description>Submodule flake inputs now carry lastModified and rev. A path:./submodule flake input used to be a metadata black hole: no lastModified, no rev. Anything consuming the pin&apos;s age got epoch 0, which is how a starship prompt segment came to report a two-hour-old index pin as 20653 days stale. Nix fork patch 0030 stamps the gitlink rev and its commit time onto the locked ref during lock computation. Both now appear in flake.lock (path:./index?lastModified=...&amp;rev=...) and in inputs.index.lastModified, .rev, and .shortRev, exactly like a git input. Plain subdirectory subflakes stay unstamped on purpose: their history is the parent&apos;s, and stamping them would rewrite the lock on every parent commit. The metadata rides on source accessors: the git fetcher records commit time and rev on each accessor it builds, submodule mounts included, and the mounted accessor answers path-aware queries from the nearest mount. Lock computation reuses the fetch&apos;s accessor through the input cache (now also keyed by the locked input), and falls back to the previous lock&apos;s values when a flattened store copy knows nothing, so stamps never flap between fetch modes. Verified with a new relative-paths-lastmodified functional test (33/33 in the flakes suite), a full-battery differential against the unpatched base, and the workstation config itself, where inputs.index.lastModified now equals git -C index log -1 --format=%ct byte for byte. Takes effect per host once its system nix includes p30.</description>
    </item>
    <item>
      <title>Two activation steps stop wasting 2.5 seconds</title>
      <link>https://ix.dev/activation-step-speedups</link>
      <guid isPermaLink="true">https://ix.dev/activation-step-speedups</guid>
      <pubDate>Sun, 19 Jul 2026 21:00:00 GMT</pubDate>
      <description>Two activation steps stop wasting 2.5 seconds. The new per-step activation timings immediately paid for themselves. clearBlockedCookies spent 2 of its 2.1 seconds inside Python&apos;s import machinery: the step runs a bare .py file that lives at the store root, so CPython prepended its dirname, /nix/store itself, to sys.path, and every stdlib import miss re-listed the entire store. The actual cookie work costs 30 milliseconds. One interpreter flag (-P, never prepend the script dir) removes the whole pathology. lintClaudeFiles re-ran skillsaw over the Claude config tree on every switch, but its argument is a content-addressed store path: unchanged content means an unchanged path and a guaranteed-identical verdict. The step now stamps the last clean path and skips while it matches; a failed lint writes no stamp, so a broken tree still fails every switch until fixed.</description>
    </item>
    <item>
      <title>The README flywheel cycles the tools this repo actually sharpens</title>
      <link>https://ix.dev/readme-flywheel-animation</link>
      <guid isPermaLink="true">https://ix.dev/readme-flywheel-animation</guid>
      <pubDate>Sun, 19 Jul 2026 21:00:00 GMT</pubDate>
      <description>The README flywheel cycles the tools this repo actually sharpens. The flywheel hero animates its own thesis: a pulse circles the loop and each lap is shorter than the last. Under &quot;a change lands once&quot;, a slot names the tool that just landed, primed to mark the new version, and swaps once per full loop. It cycled four generic entries, one of them a literal placeholder; it now draws from twelve tools this repo actually improves: nix&apos;, clippy&apos;, fmt&apos;, mgrep&apos;, astlog&apos;, cargo&apos;, kernel&apos;, tui&apos;, sdk&apos;, vt&apos;, ci&apos;, site&apos;. Reduced-motion readers get a static nix&apos;.</description>
    </item>
    <item>
      <title>Every package README hero gets a dark twin, and a lint keeps it that way</title>
      <link>https://ix.dev/svg-dark-twin-sweep</link>
      <guid isPermaLink="true">https://ix.dev/svg-dark-twin-sweep</guid>
      <pubDate>Sun, 19 Jul 2026 21:00:00 GMT</pubDate>
      <description>Every package README hero gets a dark twin, and a lint keeps it that way. The Safari dark-mode fix that landed on the root README now covers the whole tree: all 77 markdown-embedded assets/hero.svg files gained a committed hero-dark.svg twin (the same file with the dark palette as its base defaults), and every embed switched to the &lt;picture&gt; pattern with a prefers-color-scheme: dark source. The mirror README generator emits the same pattern and synthesizes both files for packages without a curated README. A new svg-dark stage in nix run .#lint fails any markdown file that embeds a self-adapting SVG without the dark &lt;picture&gt; source, so the pattern cannot regress.</description>
    </item>
    <item>
      <title>Agents.*: depth-1 CLI subagents land in the kernel</title>
      <link>https://ix.dev/kernel-depth-1-subagents</link>
      <guid isPermaLink="true">https://ix.dev/kernel-depth-1-subagents</guid>
      <pubDate>Sun, 19 Jul 2026 20:25:00 GMT</pubDate>
      <description>Agents.*: depth-1 CLI subagents land in the kernel. The kernel can now spawn real agent CLIs as async, long-lived subagents: Agents.spawn(brief, backend: :claude | :codex | :kimi) returns an id immediately, the child streams its work, and its final response lands in the lead session as a mailbox message plus an agent_finished notification. This is the Fable 5 system card&apos;s top-scoring multi-agent harness (sec 8.15.3, async subagents: BrowseComp 93.3 vs 88.0 single-agent) running on the agent-harness-ex OTP library from index#3700, with the model seam filled by a Port-based runner that speaks claude -p stream-json and codex exec --json. The topology is a depth-1 star and it is structural, not prompted: children spawn with no MCP servers (--strict-mcp-config and an empty config), the built-in Agent/Task tools denied, agents.max_depth=1 for codex, and IX_AGENT_CHILD=1 in their environment, which Agents.spawn refuses under. There is no child-to-child call in the surface at all. Kimi K3 rides the claude harness pointed at Moonshot&apos;s Anthropic-compatible endpoint. Verified live before landing: two claude children ran in parallel and answered correctly; a message sent to the idle child woke it into the same CLI session (--resume, same session id across both init events) and produced a second final. Messages queue in the harness and inject after the recipient&apos;s next tool result, the card&apos;s delivery rule. The workstation profile drops its Agent = true override in the same change: delegation on hydra now routes through Agents.* instead of the harness Task tool, which is what the card&apos;s data says to do (the async-subagent hierarchy beat the peer mesh on score and the blocking orchestrator on everything).</description>
    </item>
    <item>
      <title>the 159-hour flake bump lands, nixpkgs moves Jul 5 to Jul 18</title>
      <link>https://ix.dev/flake-bump-unstuck</link>
      <guid isPermaLink="true">https://ix.dev/flake-bump-unstuck</guid>
      <pubDate>Sun, 19 Jul 2026 19:40:00 GMT</pubDate>
      <description>the 159-hour flake bump lands, nixpkgs moves Jul 5 to Jul 18. Eight flake inputs move forward at once: nixpkgs (Jul 5 to Jul 18), home-manager, rust-overlay, btop-src, nushell-src, snix-src, and both zed inputs. The updater had produced this bump hourly for 159 hours, but its PR (#3021) was bot-authored, and bot PRs never trigger CI, so it sat with skipped checks while the escalation issue (#3438) reopened every hour. The staleness was not free: ix follows index&apos;s nixpkgs, so the old Jul 5 pin downgraded TigerBeetle under the billing ledger&apos;s one-way-upgraded data file when an unrelated pin bump relocked it (ix#7844). The human-authored copy (#3683) fared little better at first: its flake-check waited 13096 seconds for a self-hosted runner slot, past the 7200-second admission budget (ix#7625), and failed without building anything. The bump itself was pushed to main directly (93ba54768); the nushell-src move needed one follow-up for reedline&apos;s git outputHashes (3fce70541). The remaining root causes stay open: bot PRs need a CI trigger or the auto-merge lane (#2728) needs to land.</description>
    </item>
    <item>
      <title>Claude Code bumps to 2.1.215</title>
      <link>https://ix.dev/claude-code-2-1-215</link>
      <guid isPermaLink="true">https://ix.dev/claude-code-2-1-215</guid>
      <pubDate>Sun, 19 Jul 2026 19:30:00 GMT</pubDate>
      <description>Claude Code bumps to 2.1.215. The vendored Claude Code moves from 2.1.206 to 2.1.215 via the signed updater: nix run .#claude-code.updateScript fetched Anthropic&apos;s per-version manifest.json, verified its detached GPG signature against the pinned release key, and rewrote the per-platform SRI hashes in packages/claude-code/manifest.json. The stock system-prompt snapshots (fable, opus, sonnet, haiku) were recaptured from the new binary in the same run. The wrapper replaces the stock prompt wholesale, so the snapshots are reference material, not a behavior gate.</description>
    </item>
    <item>
      <title>nix fork: incremental per-compilation-unit builds via nix-ninja</title>
      <link>https://ix.dev/nix-ninja-incremental-fork-lane</link>
      <guid isPermaLink="true">https://ix.dev/nix-ninja-incremental-fork-lane</guid>
      <pubDate>Sun, 19 Jul 2026 19:30:00 GMT</pubDate>
      <description>nix fork: incremental per-compilation-unit builds via nix-ninja. Iterating on the nix fork&apos;s patch series used to mean rebuilding the whole modular package on every change. The new lane hands the same patched tree to nix-ninja, which parses the ninja graph meson emits and turns every compilation unit into its own content-addressed derivation, so a one-file change recompiles one derivation and relinks its dependents. nix run .#nix-ninja-build-nix (x86_64-linux) materializes the patched source (the identical ix.patchedSrc tree packages/nix ships, with the fork version written into .version), configures it with meson inside upstream&apos;s own dev shell with $NINJA pointed at nix-ninja, and builds src/nix/nix. The result runs and identifies itself as the fork (nix (Nix) 2.34.7+ix.p&lt;count&gt;.h&lt;digest&gt;, the same version string the packaged fork reports). Edit files under the workdir it prints and rerun; --fresh rebases the workdir after the patch series moves. Measured on vin-compute-1 (x86_64-linux, 128 cores), same one-line change to src/libstore/gc.cc in both lanes: | lane | wall time | work done | | --- | --- | --- | | whole-package rebuild (nix-ix derivation) | 5m23s | every component rebuilt | | nix-ninja, cold (empty per-TU cache) | 1m41s | 301 compile drvs + 8 links | | nix-ninja, warm after the one-line change | 48s | 1 compile drv + 7 links | | nix-ninja, no-op rebuild | 28s | drv regeneration only | The numbers above were captured at series p23; after the lazy-trees patches landed (p29) the same runner revalidated end to end: 143s cold, 301 compilation units, and the binary reports the p29 fork version. The lane uses nix-ninja&apos;s local mode: the client generates dynamic derivations and calls nix build itself, which needs the dynamic-derivations and ca-derivations experimental features on the daemon (the Linux fleet builders already enable both; recursive-nix is only needed for the in-derivation mode this lane does not use). nix-ninja is pre-alpha, so it is pinned by rev (nix-ninja-src, excluded from the hourly lock bump) and the lane is additive and non-gating: no fork check consumes it, and the patch series and existing whole-package lanes are untouched. One quirk worth knowing: the upstream dev shell exports CC_LD=mold, and a mold link baked into the ninja graph dies inside the nix-ninja task sandbox (the linker never appears as a command word, so nix-ninja cannot discover it for the sandbox PATH); the runner unsets it and links through the stock wrapped bintools.</description>
    </item>
    <item>
      <title>nix fork: relative path flake inputs defer to the child&apos;s own flake.lock</title>
      <link>https://ix.dev/nix-sparse-locks-relative-inputs</link>
      <guid isPermaLink="true">https://ix.dev/nix-sparse-locks-relative-inputs</guid>
      <pubDate>Sun, 19 Jul 2026 19:30:00 GMT</pubDate>
      <description>nix fork: relative path flake inputs defer to the child&apos;s own flake.lock. Nix patch 0024 makes the child&apos;s own flake.lock authoritative for the subtree of a relative path flake input (path:./index style, including submodule-backed ones). Upstream copies the child&apos;s entire lock subtree into the parent&apos;s lock and, because a relative flakeref never changes, reuses the copy forever: after a submodule bump the parent silently evaluates with stale transitive pins, and a child that gained a new input fails with function &apos;outputs&apos; called without required argument &apos;&lt;name&gt;&apos; until a manual nix flake update &lt;input&gt;. Both modes hit the workstation config (which consumes index at ./index) on 2026-07-19. The patched computeLocks() re-locks relative path inputs from the child on every lock computation, exactly as nix flake update &lt;input&gt; would. The child lives in the parent&apos;s tree, so the read is free and its content is already pinned; its non-relative transitive inputs are still taken lazily from its lock file. An unchanged child reproduces byte-identical parent locks, so in-sync repositories see no churn; a changed child refreshes the parent lock on the next lock-writing operation, and read-only operations use the refreshed lock in memory. Non-path inputs are untouched. This is the scoped first step of roberth&apos;s approved sparseNodes migration plan on NixOS/nix#7730, which has no upstream implementation yet. The lock file format is unchanged (no sparseNodes field), so the patch drops cleanly when upstream ships the real migration. The change also carries the kept-flake refetch fix from the open upstream PR NixOS/nix#15982 so nested relative inputs resolve against the right source tree. Written by Claude Code, an AI coding agent.</description>
    </item>
    <item>
      <title>the bundled dataviz skill is gone, not just blocked</title>
      <link>https://ix.dev/remove-dataviz-skill</link>
      <guid isPermaLink="true">https://ix.dev/remove-dataviz-skill</guid>
      <pubDate>Sun, 19 Jul 2026 19:30:00 GMT</pubDate>
      <description>the bundled dataviz skill is gone, not just blocked. Claude Code ships a bundled dataviz skill that injects Anthropic&apos;s own chart-style guidance. We never wanted it steering output, so #3607 denied it with Skill(dataviz) -- which blocked invocation but left the skill in every session&apos;s listing, spending context on a skill that could never run. At the time that was the only lever: the CLI had no way to delist a bundled skill. It does now. skillOverrides (CLI v2.1.129+, our pin is 2.1.206) accepts &quot;dataviz&quot;: &quot;off&quot;, which removes the skill from the listing and refuses invocation outright, and holds under --dangerously-skip-permissions. Probed headlessly against 2.1.206 before switching: the earlier release where user- and project-level skillOverrides were silently ignored no longer reproduces. The override now rides the claude-code house settings defaults, and the redundant Skill(dataviz) deny is gone from agent policy. Wrapped sessions simply never see the skill. artifact-design, the other bundled design skill, is unchanged: denying the bare Artifact tool already delists it.</description>
    </item>
    <item>
      <title>Fork patches can now be derived by Nix; clippy&apos;s two mechanical diffs migrated</title>
      <link>https://ix.dev/derived-fork-patches</link>
      <guid isPermaLink="true">https://ix.dev/derived-fork-patches</guid>
      <pubDate>Sun, 19 Jul 2026 19:00:00 GMT</pubDate>
      <description>Fork patches can now be derived by Nix; clippy&apos;s two mechanical diffs migrated. A fork in lib/fork-packages.nix can now declare derivedPatches: mechanical deltas generated by Nix from the pinned upstream tree at build time instead of being stored as line diffs. Each entry names a generator file evaluating to a function from the package set and the source tree to a derivation whose output is a single unified-diff file; patchedSrc applies the outputs after the static series, handing every generator the tree as the series left it. Because the diff regenerates against whatever the pinned base is, a derived patch can never conflict on rebase, and rebase-patches, dag.json, and the commit-body reason check ignore it by construction (it is not a *.patch file; the registry reason field is its reason of record). Generators must fail loudly behind structural guards -- counts derived from the tree, never baked-in magic totals. The two clippy offenders are migrated. The 724-line 0011-Add-ix-metadata-to-Cargo-manifests.patch becomes a generator that stamps [package.metadata.ix.inputs] into every [package] manifest (with build = [&quot;.&quot;] exactly where a build.rs exists) -- and immediately caught a manifest upstream added after the stored diff was authored, which the old patch silently missed. The 1886-line 0013-track-Cargo.lock patch becomes a committed plain Cargo.lock next to a generator that copies it in and carves the !/Cargo.lock exception out of upstream&apos;s ignore rule; the lockfile still moves only with the nightly bump. The static series shrinks to twelve patches and the patched tree is byte-identical to the old one apart from seven normalized blank lines and the newly covered manifest.</description>
    </item>
    <item>
      <title>every fork patch must now declare its upstreaming stance</title>
      <link>https://ix.dev/fork-intent-registry-complete</link>
      <guid isPermaLink="true">https://ix.dev/fork-intent-registry-complete</guid>
      <pubDate>Sun, 19 Jul 2026 19:00:00 GMT</pubDate>
      <description>every fork patch must now declare its upstreaming stance. The fork registry (lib/fork-packages.nix) keys per-patch upstreaming intent by exact patch file name, and a patch with no entry falls back to hold with an &quot;unclassified&quot; reason. Fail-safe for the outward act, but silent: two series files had quietly accumulated no entry at all -- the nix fork&apos;s 0022-libstore-fail-paused-downloads-on-peer-half-close-or.patch (index#3637) and zed&apos;s 0003-editor-navigate-directly-to-a-single-reference.patch -- so their stances were never written down anywhere a human would review. Both entries exist now, and the patch-dag-&lt;name&gt; check enforces the invariant in the direction it was missing. It already failed intent keys naming nonexistent patch files (the rebase-rename case); it now also fails any series patch with no intent entry, whenever the fork declares intent at all. Forks with no patches attrset keep the empty-record default, so downstream consumers of mkForkChecks are unaffected.</description>
    </item>
    <item>
      <title>Every production patch now lives in the fork-packages registry</title>
      <link>https://ix.dev/fork-registry-stragglers</link>
      <guid isPermaLink="true">https://ix.dev/fork-registry-stragglers</guid>
      <pubDate>Sun, 19 Jul 2026 19:00:00 GMT</pubDate>
      <description>Every production patch now lives in the fork-packages registry. Six patches used to live outside lib/fork-packages.nix with none of the fork machinery: nix-fast-build&apos;s skip-local and liveness patches (the 715-line liveness patch was the riskiest in the repo with the least tooling), nix-output-monitor&apos;s nix-derivation CA parser fix, and the three version-flavored rnix tokenizer patches applied to vendored cargo crates at build time. They are now four registry forks with pinned flake = false inputs, canonical git format-patch series, and the standard gates: patched-src-&lt;name&gt; proves each series still applies against its pinned base in seconds, and patch-dag-&lt;name&gt; enforces the dependency DAG and a stated reason in every patch&apos;s commit body. - nix-fast-build pins Mic92/nix-fast-build at tag 1.6.0 (the nixpkgs version; an eval assertion fires if nixpkgs moves first) and overrides the nixpkgs package&apos;s src with ix.patchedSrc. - nix-derivation pins Gabriella439/Haskell-Nix-Derivation-Library at the 1.1.3 head and feeds the patched source through haskell.lib.compose.overrideSrc instead of appendPatch. - rnix-0-12 / rnix-0-14 pin the rnix-parser tags that alejandra/deadnix (0.12) and statix (0.14) actually vendor; the build-time vendor patcher reads the same series files, so tokenizer drift now fails a flake check instead of a consumer build. The dead 0.10 flavor (no consumer since statix moved to rnix 0.14) is deleted; an unknown vendored version still fails the build loudly. Existing smoke tests (nom usage output, formatter underscore-literal round-trip, nix-fast-build --help flags) are unchanged.</description>
    </item>
    <item>
      <title>Home Manager activation gets per-step timings; claude stays nix-pinned</title>
      <link>https://ix.dev/hm-activation-timings</link>
      <guid isPermaLink="true">https://ix.dev/hm-activation-timings</guid>
      <pubDate>Sun, 19 Jul 2026 19:00:00 GMT</pubDate>
      <description>Home Manager activation gets per-step timings; claude stays nix-pinned. Running home-manager switch used to print a bare &quot;Activating step&quot; line per activation entry with no hint of where the time went. A new homeModules.activation-timing module (imported by the workstation profile) interposes on the header each entry emits, so every line is followed by the wall time the step took, and activation ends with a slowest-steps summary plus the total. Nothing new builds: it is two activation entries that wrap one bash function inside the existing script. Separately, the claude-code home module now force-links .local/bin/claude back to the nix-managed wrapper on every switch. Claude Code&apos;s stock auto-updater installs native builds under .local/share/claude/versions and re-points that symlink at them, shadowing the wrapper on PATH with an unpinned build. The re-pin is a deliberate tactical guard and carries a TODO to remove it once the updater fight is fixed at the source.</description>
    </item>
    <item>
      <title>Kernel units ride the fleet cache, and plan reruns cut off</title>
      <link>https://ix.dev/kbuild-unit-fleet-cache</link>
      <guid isPermaLink="true">https://ix.dev/kbuild-unit-fleet-cache</guid>
      <pubDate>Sun, 19 Jul 2026 19:00:00 GMT</pubDate>
      <description>Kernel units ride the fleet cache, and plan reruns cut off. kbuild-unit&apos;s per-TU kernel builds now share over the ordinary Nix cache. Each buildKernel lane exposes cachePushRoot, one link farm whose runtime closure spans every unit output plus the eval-time IFD artifacts (the plan, the rendered units.nix, the generated snapshot, the skeleton tree). Building that root on a CI dispatcher enqueues a single cache-push obligation and the drainer publishes the whole closure -- NARs and the CA realisations another host needs to substitute a unit instead of rebuilding it. This replaces the per-derivation post-build-hook path for mass unit builds, which enqueued each of defconfig&apos;s 3.6k units synchronously and serialized the build to a crawl under queue backpressure; run those with the hook disabled and push the root once at the end. Plan reruns also now reproduce the snapshot bit-identically, so a rerun (after a header or Makefile edit) re-executes only the units whose inputs actually changed instead of all of them. Three nondeterminism carriers are pinned: the build tree always unpacks to a fixed kbuild-tree directory name (DWARF comp_dir and the vdso build-id record the absolute build path, and the name used to shift with the src derivation -- which also made the skeleton plan&apos;s embedded vdso diverge from the reference kernel&apos;s), snapshotted host tools under tools/ are stripped of debug info (objtool differed only in .debug_line_str across identical-source plan builds), and the link-env dump is emitted by a dedicated bash helper with sorted names and printf %q quoting instead of whichever shell CONFIG_SHELL resolves to. Finally, unit dep lists shrank to what a replay actually reads: only thin-archive consumers carry the archive&apos;s member expansion, everything else stops at direct deps. A one-module body edit now rebuilds that module&apos;s TU, its .ko link, and the modpost pass -- the other modules no longer re-link at all.</description>
    </item>
    <item>
      <title>mcp-ex instructions now steer agents to the fleet for the work it wins</title>
      <link>https://ix.dev/mcp-ex-fleet-guidance</link>
      <guid isPermaLink="true">https://ix.dev/mcp-ex-fleet-guidance</guid>
      <pubDate>Sun, 19 Jul 2026 19:00:00 GMT</pubDate>
      <description>mcp-ex instructions now steer agents to the fleet for the work it wins. IxMcp.Fleet reaches beamd on the fleet hosts (700+ idle server cores, root, OTP 28) from the laptop that runs the MCP server, but nothing in the server&apos;s instructions mentioned it, so agents hand-rolled ssh for work that should fan out. The exec tool description, the server instructions, and the in-cell Api.help(IxMcp.Fleet) guide now name Fleet and, in two honest sentences each, say when to prefer it and when not to. Prefer Fleet for CPU-heavy or parallelizable pure-Elixir compute (Fleet.multicall/3, Fleet.exec_least_loaded/2), linux-only behavior checks, and work wanting many cores or hosts. Keep work local when it touches the workstation&apos;s files, repos, or bindings, is a small low-latency eval, is stateful (remote bindings do not persist -- code ships as source strings), or is darwin-specific. Fleet.nodes() == [] means no fleet is configured and the helpers return {:error, :no_nodes}; the remote env is minimal.</description>
    </item>
    <item>
      <title>nix fork: lazy trees vendored as an off-by-default lazy-trees setting</title>
      <link>https://ix.dev/nix-fork-lazy-trees-setting</link>
      <guid isPermaLink="true">https://ix.dev/nix-fork-lazy-trees-setting</guid>
      <pubDate>Sun, 19 Jul 2026 19:00:00 GMT</pubDate>
      <description>nix fork: lazy trees vendored as an off-by-default lazy-trees setting. The fork&apos;s nix now carries lazy trees: with lazy-trees = true, flake inputs are no longer copied into /nix/store at evaluation time. mountInput() computes each input&apos;s store path and NAR hash with a dry run, mounts the input&apos;s source tree at that path inside the evaluator, and only materializes the store object when something forces it (instantiating a derivation that references the path, builtins.storePath, IFD, nix eval output that leaks the path). For flake-heavy work the win is skipping the repeated copy of large source trees; Determinate reported 3x wall / 20x disk on such workloads for their variant. What got vendored is the variant upstream actually merged to master (NixOS/nix#15711 plus its two post-merge fixes, backported as patches 0025-0028), not the better-known sources named in the plan. Both were examined: - edolstra&apos;s lazy-trees-v2 (NixOS/nix#13225) mounts inputs at random virtual store paths and devirtualizes on demand. roberth blocked it: &quot;Using randomness or even fingerprints for placeholders makes the language non-deterministic and impure.&quot; Path equality and ordering must remain observably identical to eager evaluation; his preferred alternative, rope-structured strings with lazily computed fixed-length substrings (lazy hashing), has not been built by anyone. The PR was closed on 2026-07-16 pointing at #15711 as the mergeable subset, &quot;modulo the StorePath::random() bits&quot;. - Determinate&apos;s fork shipped lazy trees to their users via staged rollouts since Determinate Nix 3.8.0, and was the preferred source going in. But their current tree (v2.35.1) no longer contains the random-virtual-path implementation at all; they ride the same upstream mechanism the fork now backports. #15711 describes itself as &quot;a slightly less lazy (but also more deterministic) approach than determinate nix has taken&quot;: it still hashes every input once, so the mounted path is the real content-addressed store path and paths, hashes, and lock files are byte-identical to eager mode by construction. The known wrinkle survives determinism: a derivation built from a store-path string whose context was discarded (builtins.toString plus unsafeDiscardStringContext tricks) was always unsound, but lazy mounting changes when it surfaces, since the path may not exist on disk until something forces the copy. #13225 handled this with a devirtualize-without-reference context type plus a new warning; #15711 forces the copy at every escape point instead and adds a builtins.getFlake compatibility lookup for discarded-context store paths. Because of that history the setting defaults to off (patch 0029, our only divergence from upstream master, which enables the behavior unconditionally). Off means off: with the setting unset, mountInput() copies eagerly exactly like the 2.34.7 base, and the drvPath of index&apos;s own attrs is byte-identical to the previous fork build. Turning the fleet on is a separate decision that needs its own drv-hash and eval-result equivalence sweeps.</description>
    </item>
    <item>
      <title>The Plans map now pans and zooms like a real map</title>
      <link>https://ix.dev/plans-map-pan-zoom</link>
      <guid isPermaLink="true">https://ix.dev/plans-map-pan-zoom</guid>
      <pubDate>Sun, 19 Jul 2026 19:00:00 GMT</pubDate>
      <description>The Plans map now pans and zooms like a real map. The scatter on the Plans page used to be a static picture. It is now a direct-manipulation map. Drag to pan and the point under the cursor stays under the cursor, one to one, with no easing while you drag. Pinch on a trackpad or touchscreen, or hold Control and scroll, to zoom about the cursor; a plain two-finger scroll pans, the way a map does. Double-click zooms in on the spot you clicked. Zoom is semantic: the marks, their labels and the grid keep a constant size on screen while only their positions spread apart, so a cluster of plans that scored the same becomes legible instead of a single overlapping blob. The view is clamped so a corner of the plot is always on screen, and a Reset control appears once you have moved it. An Expand button grows the map to fill the window; Escape brings it back. Clicking a dot still opens its Plan: a press that does not travel past a few pixels is a click, anything further is a pan, so the two never fight.</description>
    </item>
    <item>
      <title>House prompt: subagent tools are never a superset of yours</title>
      <link>https://ix.dev/prompt-subagent-tool-subset</link>
      <guid isPermaLink="true">https://ix.dev/prompt-subagent-tool-subset</guid>
      <pubDate>Sun, 19 Jul 2026 19:00:00 GMT</pubDate>
      <description>House prompt: subagent tools are never a superset of yours. The house prompt gains a subagentToolSubset rule: treat a subagent&apos;s toolset as never a superset of your own. If you cannot run shell commands, assume your children cannot either; when you lack the tools for a task, fail fast and name the missing tool instead of delegating. The incident behind it: a parent agent without exec tools delegated to a subagent hoping the child had them. The child inherited the same limits, reasoned the same way, and delegated again. The relay chain burned roughly 30k tokens of pure coordination and produced no work. Nothing tells an agent what tools its children will get, so the only safe default is the subset assumption. It is not formally guaranteed (some agent types do attach extra tools), but assuming it breaks the recursion.</description>
    </item>
    <item>
      <title>README hero SVGs get committed dark twins so Safari readers can see them</title>
      <link>https://ix.dev/readme-hero-safari-dark</link>
      <guid isPermaLink="true">https://ix.dev/readme-hero-safari-dark</guid>
      <pubDate>Sun, 19 Jul 2026 19:00:00 GMT</pubDate>
      <description>README hero SVGs get committed dark twins so Safari readers can see them. Safari never evaluates prefers-color-scheme inside an SVG loaded through an &lt;img&gt; tag (WebKit bug 199134, fixed only in Safari Technology Preview 234), so every README hero rendered its light palette on GitHub&apos;s dark theme: near-black text on a near-black page. Each hero under .github/readme/ now has a committed -dark.svg twin with the dark palette as its defaults, and the README embeds both through a &lt;picture&gt; element whose page-level media query works in every engine. The readme-globe generator emits both files, and the creating-a-readme skill now documents the pattern instead of forbidding it.</description>
    </item>
    <item>
      <title>The Elixir kernel can drive local TUIs</title>
      <link>https://ix.dev/tui-local-elixir-kernel</link>
      <guid isPermaLink="true">https://ix.dev/tui-local-elixir-kernel</guid>
      <pubDate>Sun, 19 Jul 2026 19:00:00 GMT</pubDate>
      <description>The Elixir kernel can drive local TUIs. The Elixir MCP kernel (ix-mcp-ex) lost the Python kernel&apos;s local TUI driver in the cutover: Tui.act/2 only reaches federated resources on other hosts, so a cell could not spawn vim, less, or a REPL on its own machine. Now it can. TuiLocal is aliased into every cell: TuiLocal.spawn(&quot;vim&quot;, []) puts a real PTY child under a VT100 emulator and returns a terminal id; send/2 types literal text (arrows stay DECCKM-aware in the driver), send_key/2 takes named keys (enter, esc, f1..f12, ctrl+c chords), snapshot/1 reads the rendered screen as plain text, and wait_for/3, alive?/1, exit_code/1, kill/1, close/1 cover the poll-and-teardown loop. The plumbing is the second production consumer of unibind&apos;s Elixir backend: packages/tui/ex exports the Rust tui PTY manager as the :tui_ex NIF app (id-keyed free functions on dirty IO schedulers, since PTY I/O legitimately blocks). The app is not a mix dep of the kernel -- the release bakes its store path into IX_MCP_TUI_EX (the IX_MCP_GH pattern) and IxMcp.TuiLocal loads it onto the code path on first use, so the kernel and the binding ship independently. A conformance-style ExUnit suite (checks.*.tui-ex-run) drives real children -- cat, stty, an interactive read loop -- through the generated bindings, and the mcp-ex smoke test now proves the embedded-mode runtime load inside the shipped release itself.</description>
    </item>
    <item>
      <title>visual-explainers skill: interactive, explanation-first visualization</title>
      <link>https://ix.dev/visual-explainers-skill</link>
      <guid isPermaLink="true">https://ix.dev/visual-explainers-skill</guid>
      <pubDate>Sun, 19 Jul 2026 19:00:00 GMT</pubDate>
      <description>visual-explainers skill: interactive, explanation-first visualization. Agents now carry a house skill for building visualizations. The Claude-bundled dataviz skill was invocation-blocked in #3607 because it injected Anthropic&apos;s own chart styling; visual-explainers fills the gap with a different philosophy. The visualization is the explanation: the reader learns by doing, every moving pixel is either caused by the reader or encodes causality in the system being explained, and decorative motion gets deleted. The skill also fixes the artifact shape: overview first with details on demand (click a stage to reveal its explanation in place), a counterexample when it deepens understanding, one accent color on a dark-friendly page, real payloads instead of lorem ipsum, and a single self-contained HTML file in vanilla JS that opens from file:// with the network off. The name is not dataviz because Skill(dataviz) stays hard-denied in agent policy, and a scoped deny blocks invocation by name no matter which skill carries it.</description>
    </item>
    <item>
      <title>Kernel edit loops stop re-paying the whole tree</title>
      <link>https://ix.dev/kbuild-unit-edit-constant</link>
      <guid isPermaLink="true">https://ix.dev/kbuild-unit-edit-constant</guid>
      <pubDate>Sun, 19 Jul 2026 16:00:00 GMT</pubDate>
      <description>Kernel edit loops stop re-paying the whole tree. A one-line kernel body edit rebuilds a handful of derivations in seconds, but the measured defconfig loop was 1m51s: the rest was a flat per-edit tax -- whole-tree re-skeletonization, whole-tree source re-ingestion, and flake eval -- paid regardless of what changed. Three changes flatten it. Skeletonization is now sharded. Instead of one derivation that re-reduces all ~80k files after any edit, each top-level source directory (drivers/ one level deeper, since it alone is most of the tree) reduces in its own content-addressed derivation fed by a builtins.path slice of just that directory, and a compose step reassembles the tree byte-identically -- same store path as the old whole-tree reduction, so the plan contract does not move. A body edit re-reduces exactly the subtree it touched; every other shard, the composed skeleton, and the plan behind it resolve to already-realised outputs. The shards ride cachePushRoot (as skeleton-shards), so fleet hosts substitute them instead of re-reducing. Everything downstream now keys on those slices: the per-file source farm and the link unit&apos;s directory scopes resolve srctree paths through them, so Nix&apos;s source-ingestion memoization holds across evals for whatever an edit did not touch. buildKernel grew two entry points to feed it: srcTree (a pre-unpacked tree -- derivation output, or a plain path read in place under --impure), and srcSlices -- a driver pre-ingests each directory once (nix store add) and re-adds only the directory an edit touched, so the driver-side cost scales with the edit (also --impure: pure eval refuses store paths it was not handed context for; outputs stay fully content-addressed either way). What remains of the loop constant is Nix itself re-ingesting eval-time sources once per evaluation; measured numbers live on the issue. .git is excluded from automatic slicing so a checkout slices identically to the tarball it came from. Lane attributes were untangled at the same time: plan-side attrs (plan, skeletonTree, srcTree) no longer force the 3.6k-derivation units import just to be looked at.</description>
    </item>
    <item>
      <title>nix fmt drops Alejandra&apos;s promo output</title>
      <link>https://ix.dev/nix-fmt-quiet</link>
      <guid isPermaLink="true">https://ix.dev/nix-fmt-quiet</guid>
      <pubDate>Sun, 19 Jul 2026 16:00:00 GMT</pubDate>
      <description>nix fmt drops Alejandra&apos;s promo output. Running nix fmt used to end with Alejandra&apos;s marketing: a &quot;Congratulations! Your code complies with the Alejandra style.&quot; line and a rotating &quot;Special thanks to &amp;lt;sponsor&amp;gt; for being a sponsor of Alejandra&quot; plug on every invocation. That noise is gone. The formatter flake output now wraps pkgs.alejandra with a single -q (--quiet) flag, which Alejandra treats as &quot;hide informational messages&quot;. Real output survives. A -q flag hides only the chatter, so parse and formatting errors still print and still exit non-zero, and files are still rewritten in place. A second -q would also swallow errors, so the wrapper stops at one. The lint-fix nix lane already ran alejandra --quiet; this brings the interactive entrypoint in line with it. The wrapper is a makeWrapper shim over the cached Alejandra store path, so nothing extra builds.</description>
    </item>
    <item>
      <title>github: flake inputs can fetch submodules</title>
      <link>https://ix.dev/nix-github-submodule-inputs</link>
      <guid isPermaLink="true">https://ix.dev/nix-github-submodule-inputs</guid>
      <pubDate>Sun, 19 Jul 2026 16:00:00 GMT</pubDate>
      <description>github: flake inputs can fetch submodules. The fleet&apos;s patched nix now honors submodules (and lfs) on github:, gitlab: and sourcehut: flake inputs. Forge tarballs are git archive output and never contain submodule content, so upstream nix either rejects the attribute outright (NixOS/nix#13571) or silently produces a tree with empty submodule directories (NixOS/nix#14982). Patch 0023 constructs such an input as the equivalent git+https input instead, with the same revision, the mapping the in-tree clone() path already used. The redirect happens at input construction, so the lock file records a plain git node that stock nix consumes without complaint; this also covers flakes that set inputs.self.submodules = true, which previously broke every github: consumer of that flake. Plain forge inputs keep tarball semantics and byte-identical hashes. Two caveats ride along, both scoped to inputs that request submodules or LFS: git checkouts do not apply export-ignore/export-subst attributes the way archives do, and private repositories authenticate through git&apos;s credential machinery rather than the forge access-tokens setting.</description>
    </item>
    <item>
      <title>commits stop running the whole lint suite locally</title>
      <link>https://ix.dev/drop-precommit-lint</link>
      <guid isPermaLink="true">https://ix.dev/drop-precommit-lint</guid>
      <pubDate>Sun, 19 Jul 2026 15:20:00 GMT</pubDate>
      <description>commits stop running the whole lint suite locally. .githooks/pre-commit was exec nix run .#lint: every commit re-ran the entire lint suite on the committer&apos;s machine, minutes per commit on a laptop, serially, before CI ran the identical suite again on fleet hardware. Tonight it added multi-minute stalls to a rebase whose lint CI was going to re-run anyway. The hook is gone; lint runs once, in CI, where it gates merges with the same strictness on much faster machines. nix run .#lint remains available for anyone who wants the check before pushing. The commit-msg hook (every commit references a site page) stays: it is instant and is policy, not duplicated validation.</description>
    </item>
    <item>
      <title>the repo drops direnv and the last git hook</title>
      <link>https://ix.dev/drop-direnv-and-hooks</link>
      <guid isPermaLink="true">https://ix.dev/drop-direnv-and-hooks</guid>
      <pubDate>Sun, 19 Jul 2026 13:20:00 GMT</pubDate>
      <description>the repo drops direnv and the last git hook. .envrc existed to export core.hooksPath so git ran the tracked .githooks/commit-msg hook, and to load the flake devShell. The devShell load re-evaluated the flake on every cold start (minutes after a lock bump), and the sole remaining hook checked one commit-message convention. That is a lot of machinery for one regex. Both are gone: no .envrc, no .githooks/, nothing to direnv allow. Cloning the repo is the whole setup. The site-page convention stays as written policy in the workflow skill; commits should still reference the page that explains them, it is just no longer enforced by a hook.</description>
    </item>
    <item>
      <title>one frozen pin, five root causes: a morning of CI archaeology</title>
      <link>https://ix.dev/pin-freeze-postmortem</link>
      <guid isPermaLink="true">https://ix.dev/pin-freeze-postmortem</guid>
      <pubDate>Sun, 19 Jul 2026 13:15:00 GMT</pubDate>
      <description>one frozen pin, five root causes: a morning of CI archaeology. The fleet pin (cache-ready) stopped moving at 00:43 and stayed frozen all morning. Pulling that one thread surfaced five independent root causes, each masking the next. Two green PRs made a red main. A plan landed as number 0031 at 00:15; two hours later another PR renumbered a second plan onto 0031. Required checks are non-strict, so nothing ever built the union, and the site build (which enforces uniqueness) went red for 8.5 hours. The publish-hole gate correctly froze the pin over the failed root. Fixed by renumbering (#3668, #3675); the class is #3669, and main now validates every commit to completion in both repos (#3670, ix#7846) instead of cancelling its own runs on each merge. The mac lane starved the linux lane. The 2h hosted-darwin cache lane shared one concurrency group with everything: 24 consecutive cache-push runs cancelled in one morning, pin cycle once per 2h. The darwin jobs now live in their own workflow (#3672) pending deletion once nom cross-compiles. A pin bump downgraded a database. ix&apos;s nixpkgs follows index&apos;s, and index&apos;s nixpkgs was 14 days stale, so an unrelated index-pin bump moved ix&apos;s nixpkgs backwards 9 days, shipping TigerBeetle 0.17.8 under a data file already hot-upgraded to 0.17.9. TigerBeetle upgrades are one-way: 1446 crash-loops, billing dispatch down, VM heartbeats unpersisted, golden snapshot capture dead, deploys red (ix#7844). The ledger is now an explicit per-platform pin (ix#7845), verified healthy on the host within 20 minutes of the fix deploy. Why was nixpkgs stale? The hourly updater had produced the correct bump for 159 hours; its rolling PR is bot-authored, bot PRs never trigger CI, and the auto-merge lane never landed. A human re-authoring the identical bump got CI instantly (#3683). The closed-loop design is #3692. Why did the unblocked lane still die? A wedge reaper added two days earlier killed any CI job under 5 CPU-seconds per 15 minutes. A cache-push realise phase is pure network at 3.8, indistinguishable from the reaper&apos;s own measured wedge floor of 2.3: five healthy jobs reaped in one morning, three in a row on the pin&apos;s lane (ix#7851). The wedge class it hunted was fixed at the source by the p22 nix fork, fleet-deployed the same morning, so the reaper is retired by default (ix#7852). It got one last kill in when a deploy silently re-armed its timer; the deploy-proof bridge is a runtime mask, a lesson now in the incident notes. The pattern across all five: each guard was correct in isolation, and every failure hid inside a layer that reported success. The fixes that matter are the ones that make red visible at the moment it happens: per-commit verdicts on main, one workflow per concurrency domain, explicit pins for one-way state, and CI that runs on the robot&apos;s PRs too.</description>
    </item>
    <item>
      <title>Cache watchdog covers both publish lanes, with lane-correct wedge ceilings</title>
      <link>https://ix.dev/watchdog-split-coverage</link>
      <guid isPermaLink="true">https://ix.dev/watchdog-split-coverage</guid>
      <pubDate>Sun, 19 Jul 2026 13:11:00 GMT</pubDate>
      <description>Cache watchdog covers both publish lanes, with lane-correct wedge ceilings. A wedged linux cache push is now flagged after 190 minutes instead of 520, and the darwin publish lane is watched at all. cache-push-watchdog.yml still modeled the world before the darwin split (#3672): its zombie ceiling was the sum of job timeouts that no longer share a workflow, so linux zombie detection ran ~3.4x slower than the lane&apos;s real budget, and the darwin lane had no watchdog whatsoever -- no zombie detection, no stall detection, and no drift signal for cache-ready-darwin, a pin ref that has never been created and so could drift unboundedly without an alert. The watchdog&apos;s one cron now sweeps a two-row matrix of (workflow, pin ref, ceiling): cache-push.yml / cache-ready at 190 min (push 150 + advance-cache-ready 10, plus 30 slack) and cache-push-darwin.yml / cache-ready-darwin at 420 min (darwin-build 330 + darwin-push 60, plus 30 slack) -- the same sum-of-serial-timeouts-plus-slack construction the old 520 used, applied per lane. The three signals and the tracking-issue mechanics are unchanged, evaluated per lane: each lane files its own tracking issue naming the workflow that wedged (linux keeps the pre-split thread), and the missing cache-ready-darwin ref reports &quot;does not exist yet; nothing to compare&quot; until the first darwin publish creates it. When #3587 deletes the darwin lane, its matrix row goes with it.</description>
    </item>
    <item>
      <title>Every frontier system card, committed as markdown with a one-command regen</title>
      <link>https://ix.dev/system-cards-corpus</link>
      <guid isPermaLink="true">https://ix.dev/system-cards-corpus</guid>
      <pubDate>Sun, 19 Jul 2026 12:43:18 GMT</pubDate>
      <description>Every frontier system card, committed as markdown with a one-command regen. packages/system-cards commits the frontier-model system cards as searchable markdown: all 17 Anthropic cards listed at anthropic.com/system-cards, Claude 2 (July 2023) through Claude Sonnet 5 and the Fable 5 / Mythos 5 card (June 2026), plus OpenAI&apos;s GPT-5.6 preview and final cards. 19 documents, 4.2 MiB of markdown, each with provenance frontmatter carrying the source URL, its pinned SRI hash, and the publication date. The pipeline is pure: catalog.json pins every PDF by hash, a derivation fetches them and converts with pymupdf4llm, and nix run .#system-cards copies the build product over cards/. The committed markdown is byte-for-byte the derivation output, so drift is a diff. Adding a card is one catalog entry plus a regen. docling was the intended converter (better table structure), but nixpkgs marks docling-parse broken on every platform, in our pin and on current unstable, from a nixpkgs-side nlohmann_json patch. The swap is tracked as issue 3690.</description>
    </item>
    <item>
      <title>visual explainers render human time in the reader&apos;s timezone</title>
      <link>https://ix.dev/visual-explainers-human-time</link>
      <guid isPermaLink="true">https://ix.dev/visual-explainers-human-time</guid>
      <pubDate>Sun, 19 Jul 2026 12:20:00 GMT</pubDate>
      <description>visual explainers render human time in the reader&apos;s timezone. The visual-explainers skill now has a Time section: pages keep machine ISO-8601 instants in the data layer and render only human forms (&quot;38m elapsed&quot;, &quot;eta 5:10-5:25 pm&quot;), formatted for the reader&apos;s local timezone via Intl.DateTimeFormat rather than hardcoded into copy. A live CI status page shipped today with raw 11:45Z timestamps throughout its prose; the reader (in Los Angeles) had to subtract seven hours per glance and asked for the page to be redone in local time. Checking the local timezone first, and letting the page do the formatting, removes that whole round trip.</description>
    </item>
    <item>
      <title>House prompt gains a calibrated-wording rule: no absolutes without a measurement</title>
      <link>https://ix.dev/prompt-calibrated-claims</link>
      <guid isPermaLink="true">https://ix.dev/prompt-calibrated-claims</guid>
      <pubDate>Sun, 19 Jul 2026 12:18:05 GMT</pubDate>
      <description>House prompt gains a calibrated-wording rule: no absolutes without a measurement. Agent reports drifted into absolutes: &quot;works&quot;, &quot;never happens&quot;, &quot;impossible&quot;. The house prompt now carries a calibratedClaims rule that words each claim at the strength of its evidence: &quot;passed 40 of 41&quot; over &quot;works&quot;, &quot;did not reproduce in 20 runs&quot; over &quot;impossible&quot;, and an absolute only as a measurement report. Where the measurement is missing, the rule prescribes the estimative ladder (unlikely, roughly even, likely, almost certainly, plus cannot-rule-out for severe tails) with the check&apos;s coverage named, and a regression disclosed next to the win it accompanies. The register is borrowed from the Claude Fable 5 system card, which the faithfulReporting rule already cites: findings there read &quot;extremely difficult (though not impossible)&quot; and &quot;a much less clear judgement than for previous models&quot;, with safety regressions listed beside headline results. The same practice traces back to the intelligence-community estimative lexicon (likely, cannot rule out, low-moderate-high confidence). Rendered prompts pick the rule up on the next wrapper build; omitRules = [ &quot;calibratedClaims&quot; ] drops it.</description>
    </item>
    <item>
      <title>plan and update id collisions now fail the merge gate</title>
      <link>https://ix.dev/site-id-lint</link>
      <guid isPermaLink="true">https://ix.dev/site-id-lint</guid>
      <pubDate>Sun, 19 Jul 2026 11:45:00 GMT</pubDate>
      <description>plan and update id collisions now fail the merge gate. The lint suite now validates plan, update, and story identity directly: within each of packages/site/src/lib/{plans,updates,stories}, a four-digit filename prefix may be claimed only once, frontmatter id must equal the filename stem, and a frontmatter number must agree with the prefix. Check runs the lint on every PR and every main commit, so a collision now fails the merge gate in minutes instead of after it lands. These invariants used to live only inside the SvelteKit site build, which the Check gate never runs, so this class of breakage was invisible until the Pages deploy failed. Two individually-green PRs merged into a duplicate plan number 0031 and main&apos;s site build stayed red for 8.5 hours (#3669); the renumber that fixed the collision (#3668) itself left the old number: &apos;0031&apos; frontmatter behind, which the new stage caught on its first local run, and this change repairs.</description>
    </item>
    <item>
      <title>every main commit now gets a completed Check verdict</title>
      <link>https://ix.dev/check-main-per-commit</link>
      <guid isPermaLink="true">https://ix.dev/check-main-per-commit</guid>
      <pubDate>Sun, 19 Jul 2026 11:35:00 GMT</pubDate>
      <description>every main commit now gets a completed Check verdict. Main&apos;s Check runs used to share one concurrency slot: each push cancelled the previous commit&apos;s run, so during a merge storm no main commit finished validation (every run from 10:56Z to 11:08Z on 2026-07-19 was cancelled). Required checks are non-strict, so two individually-green PRs can rebase into a red union that no PR run ever saw; today that combination let a duplicate plan number sit on main for 8.5 hours, surfacing as a frozen cache-ready pin instead of a named commit. Check now groups main runs per-commit (github.sha) and never cancels them, while PR refs keep the old supersede-and-cancel behavior. A red union now shows up within one Check duration, attributed to the exact merge that caused it.</description>
    </item>
    <item>
      <title>Cache pin advances every merge: the darwin lane moves to its own workflow</title>
      <link>https://ix.dev/cache-push-darwin-split</link>
      <guid isPermaLink="true">https://ix.dev/cache-push-darwin-split</guid>
      <pubDate>Sun, 19 Jul 2026 11:30:00 GMT</pubDate>
      <description>Cache pin advances every merge: the darwin lane moves to its own workflow. The fleet&apos;s cache-ready pin now advances minutes after every merge instead of once per ~2 hours: the native darwin cache lane (darwin-build and darwin-push) moved out of cache-push.yml into its own cache-push-darwin.yml with its own concurrency group. GitHub keeps one pending run per concurrency group, so each 2h+ hosted-mac run held the shared cache-push group while every merge behind it was superseded-cancelled: 24 consecutive cancelled runs between 08:51Z and 11:08Z on 2026-07-19, while the linux publish the pin actually waits on needs ~30 minutes worst case. The cache-ready-darwin gate changed shape with the move. The darwin workflow has no linux push job whose failed-roots output it can read, so the linux half of the publish-hole gate now reads the cache-ready ref itself: the pin only advances to a commit that cache-ready already contains, because the linux lane fast-forwards that ref exclusively over hole-free runs. Linux lag logs a notice and exits clean; the next darwin run catches up. All of this is interim plumbing: once the last darwin roots are linux-cross-built (#3586), the whole darwin workflow is dead code (#3587).</description>
    </item>
    <item>
      <title>claude-code-rainbow can no longer self-install over the wrapped claude</title>
      <link>https://ix.dev/claude-rainbow-no-self-install</link>
      <guid isPermaLink="true">https://ix.dev/claude-rainbow-no-self-install</guid>
      <pubDate>Sun, 19 Jul 2026 10:30:00 GMT</pubDate>
      <description>claude-code-rainbow can no longer self-install over the wrapped claude. The rainbow POC shipped the raw byte-patched binary at bin/claude, without the DISABLE_UPDATES=1 guard the stock claude-code launch spec sets. Running it directly let Claude Code&apos;s self-installer go to work: with installMethod: native recorded in ~/.claude.json, it downloaded a stock native build into ~/.local/share/claude/versions and wrote a ~/.local/bin/claude launcher. On a machine whose PATH puts ~/.local/bin before ~/.nix-profile/bin, that launcher shadows the wrapped claude, so every new session silently starts the stock binary: no house MCP config (the ix-mcp-ex kernel), no system prompt, no injected flags. The package now installs the patched binary under libexec behind a makeBinaryWrapper shim that pins DISABLE_UPDATES=1, matching the stock package&apos;s guard, so running the rainbow build can never mutate the machine&apos;s claude resolution. The change also clears the lint debt in this package family that made any fresh-worktree commit fail: the byte-swap mappings are typed Nix rules passed to the patcher via passAsFile instead of committed JSON, the manifest import goes through ix.paths, and the unused lambda args in claude-code-debug and claude-code-rainbow-live are gone.</description>
    </item>
    <item>
      <title>The README opens with a spinning ASCII globe, generated by a Nix pipeline</title>
      <link>https://ix.dev/readme-ascii-globe</link>
      <guid isPermaLink="true">https://ix.dev/readme-ascii-globe</guid>
      <pubDate>Sun, 19 Jul 2026 08:30:00 GMT</pubDate>
      <description>The README opens with a spinning ASCII globe, generated by a Nix pipeline. The README hero is now a spinning globe (.github/readme/globe.svg): the whole world rendered as braille text, land in the foreground color, ocean muted, adapting to light and dark mode like every other README graphic. The flywheel diagram moves back to lead the Why section. The globe is not hand-drawn. packages/readme-globe is a Nix derivation that fetches a hash-pinned NASA Blue Marble equirectangular texture from Wikimedia Commons, thresholds it into a land mask, orthographically projects 96 rotation frames onto a braille dot grid (2x4 subdots per cell, so an 80x40 grid renders 160x160 effective dots) where Lambert shading survives as ordered-dither dot density, and emits one animated SVG whose frames cycle with CSS steps(). Regenerate with nix build .#readme-globe and copy the output over the committed file. Why SVG instead of a GIF or webp: text stays crisp at any scale, prefers-color-scheme works (raster formats pick one background), reduced-motion users get a static frame, and 96 frames of braille compress to a fraction of any raster animation.</description>
    </item>
    <item>
      <title>stock Nix now gets an install message instead of a parse error, and the bootstrap seam is enforced</title>
      <link>https://ix.dev/evaluator-gate-stock-parse</link>
      <guid isPermaLink="true">https://ix.dev/evaluator-gate-stock-parse</guid>
      <pubDate>Sun, 19 Jul 2026 08:05:06 GMT</pubDate>
      <description>stock Nix now gets an install message instead of a parse error, and the bootstrap seam is enforced. The repo pins its own Nix evaluator: the fork source lives in-tree (lib/fork-packages.nix, built as nix-ix) and carries language patches such as underscore digit separators (25_576). tests/default.nix already uses that syntax, so anyone evaluating a tests-derived check with stock upstream Nix got a raw syntax error, unexpected invalid token with no hint about the fix. Two additions make the boundary explicit: - ix.evaluatorGate.require wraps the import of each fork-syntax island (today: tests/). Syntax errors happen at parse time, before any version check can run, so the gate sits at the import seam and stays frozen-syntax itself, the same trick as nixpkgs&apos; lib/minver.nix. Stock Nix now gets an actionable error ending in the one command that fixes it: nix profile install github:indexable-inc/index#nix-ix. - checks.&lt;system&gt;.stock-nix-parse runs the nixpkgs-pinned stock nix-instantiate --parse over every tracked .nix outside tests/. External flakes evaluate index with their own evaluator, and the nix-ix bootstrap only works while its import closure parses on stock Nix; CI runs the fork, so nothing else would notice a stray fork-only literal on the shared surface. The check turns &quot;stock Nix can always build the fork&quot; from luck into law. The gate detects the fork through the +ix. suffix in builtins.nixVersion rather than a fork-added builtin: a new builtin would reject every currently deployed evaluator until a nix upgrade reaches the fleet.</description>
    </item>
    <item>
      <title>nom on Macs: cross-compiled from Linux by a patched cross-GHC</title>
      <link>https://ix.dev/nom-cross-ghc</link>
      <guid isPermaLink="true">https://ix.dev/nom-cross-ghc</guid>
      <pubDate>Sun, 19 Jul 2026 06:30:00 GMT</pubDate>
      <description>nom on Macs: cross-compiled from Linux by a patched cross-GHC. packages.aarch64-darwin.nix-output-monitor is now a Linux-cross-built artifact: a Mac substitutes a prebuilt Mach-O arm64 nom from the cache instead of building GHC and the 62-package Haskell closure natively. The verdict in #3584 (&quot;Haskell cross to darwin is infeasible&quot;) only condemned nixpkgs&apos; pkgsCross road, which dies bootstrapping the Apple SDK; building GHC itself as a cross compiler the way upstream&apos;s wasm and mingw lanes do -- ./configure --target=aarch64-apple-darwin + hadrian, with the RFC 0009 clang/ld64.lld/SDK toolchain as target tools -- works. It took three small GHC patches (two host-vs-target CPP confusions in the aarch64 NCG and x18 register reservation, one Cabal osx spelling case in GHC_CONVERT_OS) and a bundled-libffi bump to 3.5.2 for a Mach-O CFI atom rule. Template Haskell splice execution is impossible in this lane (no darwin iserv on Linux); an audit showed nom&apos;s closure runs zero splices, so nothing had to be faked. A cross-darwin-nom-smoke gate asserts the output stays Mach-O arm64 with the by-name alias symlinks intact. Written by an AI agent via Claude Code (Claude Fable 5).</description>
    </item>
    <item>
      <title>writing-style skill learns the AI tells</title>
      <link>https://ix.dev/writing-style-ai-tells</link>
      <guid isPermaLink="true">https://ix.dev/writing-style-ai-tells</guid>
      <pubDate>Sun, 19 Jul 2026 06:00:00 GMT</pubDate>
      <description>writing-style skill learns the AI tells. The agent&apos;s writing-style skill now carries a catalog of the patterns readers use to spot AI prose, with an instruction to never produce them: the rule of three, &quot;not X, but Y&quot; parallelisms, em dashes, the delve/robust/seamless lexicon, &quot;serves as&quot; where the sentence means &quot;is&quot;, synonym cycling, significance inflation (&quot;plays a vital role&quot;), hedging filler (&quot;it&apos;s worth noting&quot;), assistant chirp, bolded-phrase bullets, and closing summaries. The catalog is drawn from Wikipedia&apos;s Signs of AI writing field guide and the vocabulary studies it cites (Juzek and Ward on RLHF word overuse, Kobak on academic abstracts). The skill also ships a paste-ready prompt block for generating or reviewing prose, so the same bans apply when the agent writes for other contexts. The README intro was rewritten under the new rules and repositioned: it now leads with what index is (one shared software universe, nixpkgs-style) and its relation to ix.dev (ix is the runtime, index is the world it runs), after a reader mistook the old intro for a per-team agent product.</description>
    </item>
    <item>
      <title>Patched git: linked worktrees stop re-cloning submodules</title>
      <link>https://ix.dev/git-worktree-submodule-alternates</link>
      <guid isPermaLink="true">https://ix.dev/git-worktree-submodule-alternates</guid>
      <pubDate>Sun, 19 Jul 2026 05:30:00 GMT</pubDate>
      <description>Patched git: linked worktrees stop re-cloning submodules. git joins the de-forked packages (lib/fork-packages.nix): upstream v2.54.0 plus one patch that fixes a twelve-year-old gap in git&apos;s worktree support. Stock git resolves submodule gitdirs to a per-worktree path, so the first git submodule update --init in every new worktree re-clones each submodule from its remote even though the main checkout already holds a complete object store under .git/modules/. Upstream called the reuse &quot;logical&quot; in the 2014 commit that shipped the split (df56607dff2) and never implemented it. The patch teaches prepare_possible_alternates() to add $GIT_COMMON_DIR/modules/&lt;name&gt;/objects as a clone reference with --reference-if-able semantics whenever the resolved gitdir is per-worktree. Immutable objects are shared through alternates; per-checkout state (HEAD, index) stays private. Measured on a 5 MB submodule: a new worktree&apos;s submodule init drops from 5.0 MB of duplicated store plus a full network refetch to 112 KB and no fetch, and it scales linearly with submodule size and count. With the worktree-per-task agent workflow this removes a full submodule re-download from every task start in superprojects that use submodules. The base is pinned by rev to nixpkgs&apos; git version tag (autoUpdate = false) because the package overlays the nixpkgs recipe onto the patched tree; an eval-time assert fails loudly when nixpkgs&apos; git moves so the pin gets advanced deliberately. Upstream policy is recorded as mailing-list-only (prsWelcome = false): git/git on GitHub is a read-only mirror, so upstreaming this series is a manual git send-email submission, held for now.</description>
    </item>
    <item>
      <title>plumb: a shell where every run is a value</title>
      <link>https://ix.dev/plumb-shell</link>
      <guid isPermaLink="true">https://ix.dev/plumb-shell</guid>
      <pubDate>Sun, 19 Jul 2026 02:45:00 GMT</pubDate>
      <description>plumb: a shell where every run is a value. packages/plumb is a bash-subset shell built library-first for LLM agents. Run cargo clippy 2&gt;&amp;1 | tail -n 5 and the run comes back as a value: per-stage argv, status, timing, and bounded captures of every stream, including what each pipe stage fed the next. Every stream stays addressable afterwards (${o[1][0]} is everything tail consumed, negative indexes count from the latest), so a later command can reuse any earlier stream without re-running it. Three crates: plumb-syntax (spanned parser; unsupported bash is a loud error, never a misparse), plumb-core (cloneable Shell over shared state, tee&apos;d capture engine with backpressure, strict semantics: pipefail, unset-var error, failglob, no word splitting), and the plumb CLI (reedline REPL with per-stage summaries and :runs / :json / :out inspection, -c, script and stdin modes, --json). A public read-only mirror is generated at indexable-inc/plumb. The Elixir binding (plumb-ex, via unibind) exposes Plumb.Shell.eval/2 returning the report as JSON, with error variants as atoms and detached runs pollable by id, so the workstation kernel can drive shells in-process. Two boundary gotchas surfaced and are now recorded: the BEAM ignores SIGCHLD (auto-reaping NIF-spawned children into ECHILD; plumb-ex restores the default disposition once), and cargo check on any unibind ex crate false-fails on a duplicate nif_init.</description>
    </item>
    <item>
      <title>The site renders on a TUI character grid and ships as @indexable/site</title>
      <link>https://ix.dev/site-tui-grid-library</link>
      <guid isPermaLink="true">https://ix.dev/site-tui-grid-library</guid>
      <pubDate>Sat, 18 Jul 2026 21:35:00 GMT</pubDate>
      <description>The site renders on a TUI character grid and ships as @indexable/site. This page now renders on a strict TUI character grid, matching the ix.dev design language: one 14px monospace cell with an exact 21px line height, every vertical distance a whole multiple of that cell and every horizontal distance in ch units. There is exactly one font size; hierarchy comes from weight, UPPERCASE, letter-spacing, and inverse-video blocks. Panels draw as box frames with their titles inline in the top rule, chips read as [ label ], and the plan score meters render as block-character gauges. The filter bar on this page is now a Todoist-style search box. Words that match a known tag, or a prefix of one (rus for rust), highlight inline as subtle pills and AND together as tag filters; everything else full-text searches titles and bodies, never an error. A box-drawn autocomplete under the caret lists matching tags with entry counts (arrows to move, Tab or Enter to accept, Esc to close), and &amp;, |, ! survive as power syntax. A pixel QA pass tightened both surfaces: the autocomplete wears the same crisp 1px frame as the focused search panel, and plan flow diagrams no longer clip their last column or push the page off the 21px grid. Wide DAGs now break out of the column, centered, and their scroll pane hides scrollbar chrome so its height stays a whole number of cells in every browser. The content also ships as a raw-source Svelte library, @indexable/site, built by nix build .#site-lib. It exports the updates, plans, stories, and philosophy collections with their loaders, the page-level components, the RSS feed builder (site URL as a parameter), and styles/tokens.css as the single source of design tokens. Each themeable token is one light-dark() declaration under color-scheme: light dark, so a color is defined once rather than in a :root block shadowed by a @media (prefers-color-scheme: dark) override. ix.dev composes its site from this package, so the same entries and components serve both deployments; this site&apos;s routes are thin wrappers over the same sources.</description>
    </item>
    <item>
      <title>Every commit now references the site page that explains it</title>
      <link>https://ix.dev/commit-site-page-links</link>
      <guid isPermaLink="true">https://ix.dev/commit-site-page-links</guid>
      <pubDate>Sat, 18 Jul 2026 19:00:00 GMT</pubDate>
      <description>Every commit now references the site page that explains it. Commit subjects alone rarely explain a change, and long commit bodies are hard to find again. Commits in this repo now point at the site instead: every commit message must include a repo path to a page under packages/site/src/lib/updates/, plans/, or stories/, and the long-form description lives on that page. A new .githooks/commit-msg hook enforces this through the same core.hooksPath activation as the existing pre-commit lint hook. The referenced page must exist in the commit&apos;s staged tree, so adding the page in the same commit satisfies the check. Merge, fixup, squash, and revert commits are exempt because they carry their own context. For now the reference is a plain repo path, not an https link, and there is no dedicated commits section on the site; a commit either adds an update entry like this one or points at the existing page it extends.</description>
    </item>
    <item>
      <title>README leads with the thesis: writing code got cheap, landing it is the bottleneck</title>
      <link>https://ix.dev/readme-thesis</link>
      <guid isPermaLink="true">https://ix.dev/readme-thesis</guid>
      <pubDate>Sat, 18 Jul 2026 19:00:00 GMT</pubDate>
      <description>README leads with the thesis: writing code got cheap, landing it is the bottleneck. The README now opens with why the repo exists instead of what it contains. Agents made writing code cheap; shipping it is still expensive (review queues, maintainer bus factors, projects that refuse AI-written patches), so the bottleneck moved from writing a change to landing it. index is the bet on the other path: every change lands here now, in one graph everyone consumes the same way, and upstream can take it someday. A new animated hero (.github/readme/unblocked.svg) draws the two paths in the house terminal style: the upstream lane queues blocks in front of a review gate, the index lane streams them through main and fans them out to every consumer. All wires are axis-aligned so the diagram reads as a text grid, matching the other README graphics.</description>
    </item>
    <item>
      <title>The prompt rule that survived its own opt-out: omitRules shadowed by an explicit package</title>
      <link>https://ix.dev/prompt-omit-shadowing-and-kernel-hardening</link>
      <guid isPermaLink="true">https://ix.dev/prompt-omit-shadowing-and-kernel-hardening</guid>
      <pubDate>Sat, 18 Jul 2026 04:49:59 GMT</pubDate>
      <description>The prompt rule that survived its own opt-out: omitRules shadowed by an explicit package. A workstation Claude session was still carrying the force-merge prohibition its owner had opted out of. The opt-out was configured correctly: programs.claude-code.systemPrompt.omitRules listed the rule. But the module folded omitRules only into its derived default package, via cfg.basePackage.override, so an explicit package = silently discarded the whole omission list. Permissions said one thing; the rendered system prompt said another (#3537). #3545 fixes it by threading omitRules into the override arguments, and adds a loud eval assertion so the shadowing can never be silent again: it checks options.&lt;ns&gt;.package.highestPrio against (lib.mkDefault null).priority and fails evaluation when omissions are configured on an explicitly set package the module cannot rewrite. The general lesson, that an option folded only into a derived default dies quietly the moment someone sets the option it derives, is now a shared prompt rule, moduleOptionShadowing (#3552). The same debugging session surfaced two kernel bugs in the Elixir MCP server. Binary bytes in a cell&apos;s output killed the MCP connection outright and orphaned the job record (#3538); #3542 sanitizes output to valid UTF-8 at the boundary, makes the transport reply-always so a cell can no longer die without answering, and monitors job history so finished jobs always land a record. And a schema-drifted actions.db crashed the whole server at startup (#3539); #3543 adds PRAGMA user_version migrations and routes crash dumps into the state dir instead of the current working directory. #3550 rounds it out: after a checks-only watcher idled for 35 minutes on a PR that had already merged, the kernel instructions now bake in the two-signal pattern, PrWatch for merge state plus gh pr checks --watch --fail-fast for CI, so neither signal alone can strand a watch. Deployed and verified on the workstation: the rendered system prompt file now drops both stale rules and carries the new one. Written by Claude Code, an AI coding agent.</description>
    </item>
    <item>
      <title>A raw NVMe partition loses to a plain APFS image file for the builder VM</title>
      <link>https://ix.dev/vm-disk-apfs-bypass</link>
      <guid isPermaLink="true">https://ix.dev/vm-disk-apfs-bypass</guid>
      <pubDate>Sat, 18 Jul 2026 04:23:36 GMT</pubDate>
      <description>A raw NVMe partition loses to a plain APFS image file for the builder VM. The aarch64 NixOS builder VM on hydra (a Mac Studio) does all of its disk I/O through an image file sitting on APFS, so every guest write crosses vfkit, the host page cache, and a copy-on-write filesystem. Virtualization.framework can instead attach a raw block device via VZDiskBlockDeviceStorageDeviceAttachment (vfkit type=dev), skipping APFS entirely. The question: does a dedicated raw internal-NVMe partition beat the image file for nix-builder workloads? The A/B ran on hydra&apos;s builder VM under vfkit 0.6.3, a GPT/repart appliance image with an xfs root. A 256 GiB partition was carved out of the live internal NVMe with diskutil apfs resizeContainer and the image was dd&apos;d onto /dev/rdisk0s5 at 5.9 GB/s. In-guest benchmarks: fio 4k randwrite QD8 O_DIRECT, plain and with fsync=1, plus a cold nix copy of the 3.3 GiB ghc closure. | backing | randwrite IOPS | fsync IOPS | fsync p99 | nix copy | | --------------------------- | -------------- | ---------- | --------- | ---------- | | image file (sparse, noatime) | 34,123 | 14,651 | 1.7 ms | 131.6 MiB/s | | partition, sync Full | 12,048 | 1,024 | 16.7 ms | 116.0 MiB/s | | partition, raw node | 14,581 | 524 | 32.4 ms | 70.8 MiB/s | File backing wins every axis on this stack. The builder stays file-backed, and the partition machinery landed dormant behind a UUID-pinned spec knob (nix#143). The mechanism is the synchronization mode, not APFS. VZ block-device attachments offer only synchronizationMode Full or None: Full turns every guest write barrier into F_FULLFSYNC on the physical disk, 13 to 28x slower on registration-shaped fsync paths, while file-backed attachments get the host page cache plus plain-fsync semantics. Next to that, APFS overhead is noise. Two side findings along the way: vfkit rejects macOS raw /dev/rdiskN character nodes for type=dev (an unaligned header read plus an S_IFBLK stat check; a 2-line patch boots fine off rdisk), and VZ propagates the internal SSD&apos;s 4Kn sector geometry into the guest, so images need sectorSize 4096. Prior art is thin: VZDiskBlockDeviceStorageDeviceAttachment shipped in macOS 14, lima tracks the same idea in lima-vm/lima#4866, and vfkit exposes it as type=dev. The open cell in the matrix is partition plus sync None, which the vmkit work in index#3554 makes testable. Written by an AI agent via Claude Code (Claude Opus 4.5).</description>
    </item>
    <item>
      <title>vmkit attaches raw host block devices as VM disks, bypassing APFS</title>
      <link>https://ix.dev/vmkit-raw-block-devices</link>
      <guid isPermaLink="true">https://ix.dev/vmkit-raw-block-devices</guid>
      <pubDate>Sat, 18 Jul 2026 02:10:00 GMT</pubDate>
      <description>vmkit attaches raw host block devices as VM disks, bypassing APFS. vmkit&apos;s Virtualization.framework guests can now use a real disk instead of an image file: any --disk on boot-linux-gui / drive-linux (and a macOS bundle&apos;s disk.img symlinked at a device node) may name /dev/diskN[sM] or /dev/rdiskN[sM], attached via VZDiskBlockDeviceStorageDeviceAttachment (macOS 14+). Guest I/O goes straight to the device, skipping APFS sparse-file fragmentation, COW metadata, and host-side double caching — the costs persistent high-churn guests pay most. Safety first: a device that hosts mounted filesystems — including through APFS physical-store linkage, where the volumes mount under a synthesized container disk — is refused unless --force, and the guest-flush mode is an explicit --disk-sync full|none (default full). A root-owned /dev node is opened by a sudo&apos;d re-exec of vmkit that hands the fd back over a private unix socket, so the VM never runs as root and no sudoers entry is ever installed (the pitfall that got lima&apos;s equivalent feature reverted). The libkrun backend (boot-linux) rejects device paths loudly; it only takes image files.</description>
    </item>
    <item>
      <title>Weave agent memory at scale: all 23 plan tracks executed in one day</title>
      <link>https://ix.dev/weave-agent-memory-executed</link>
      <guid isPermaLink="true">https://ix.dev/weave-agent-memory-executed</guid>
      <pubDate>Sat, 18 Jul 2026 00:11:27 GMT</pubDate>
      <description>Weave agent memory at scale: all 23 plan tracks executed in one day. weave#281 laid out the agent memory plan in docs/weave-agent-memory.html: 23 sub-issues (#284 to #306) spanning storage, query, claims, recall, client surface, and evals. All 23 landed on 2026-07-17, executed by a fleet of parallel AI agents working the tracks concurrently. The measured baseline that motivated the work (linked above) is stark. Cold start scaled roughly x4.4 per x2 facts (40 s at 125k facts, 150 s at 250k, 693 s at 500k, 2484 s at 1m), watch deltas tracked cold start almost 1:1 because every tick re-derived state from the journal, resident memory ran about 470x the journal bytes (18.6 GB RSS over a 39.8 MB journal at 500k facts), and a 1e9 fact ingest was OOM-killed near 4e8 facts with the journal fully resident. The 10m and 100m rungs could not be measured at all under a 2 hour budget. What landed, by track: - Storage and query: EAV/AEV/AVE covering indexes on fjall (weave#315), a tiered T0/T1 query planner with demand transformation (weave#321), incremental view maintenance for watches (weave#329), as-of checkpoints (weave#289), and journal compaction with cold-segment archival (weave#312). - Claims and provenance: claim and observation schema facts plus the epistemic prelude rules (weave#313), provenance envelope stamping at the write path (weave#322), a basis DAG with derived staleness (weave#320), and trust-as-lens example RulePacks (weave#324). - Recall: an embedding sidecar ANN index (weave#314), ANN entry points feeding Datalog graph expansion (weave#323), and auto-surfaced hints of roughly 40 tokens on tool results (weave#331). - Client surface: thin Rust, Python, and TypeScript clients (weave#317), a headless lean server build (weave#310), and weave.claim and weave.recall client verbs (weave#319). - Evals and gates: a nix-built eval sandbox (weave#328), memory-use eval suites on local models (weave#334), and enforced scale perf gates in CI (weave#332). Written by Claude Code, an AI coding agent.</description>
    </item>
    <item>
      <title>Fleet SSH sessions are now reaped at logout</title>
      <link>https://ix.dev/fleet-logind-reap-on-logout</link>
      <guid isPermaLink="true">https://ix.dev/fleet-logind-reap-on-logout</guid>
      <pubDate>Fri, 17 Jul 2026 15:51:34 GMT</pubDate>
      <description>Fleet SSH sessions are now reaped at logout. Three orphaned root du diagnostic probes ground hil-stor-2 at roughly 58% full IO pressure (PSI) for more than 30 hours. Killing them dropped the pressure to 5% within seconds (ix#7615). The probes had been launched over SSH long before, and nothing ever cleaned them up. The root cause is a pair of defaults. sshd does not reap a command&apos;s children after the connection drops, and logind&apos;s default policy leaves session processes running forever; root is even exempt from KillUserProcesses out of the box. Any process forked from an SSH session could outlive it indefinitely, invisibly. ix#7616 changes that on every fleet host: KillUserProcesses=true with KillExcludeUsers= cleared, so everything still inside a session scope dies at logout. This is a behavior change for everyone. nohup, tmux, and ssh host &quot;cmd &amp;&quot; no longer survive disconnect. Intentional survivors must escape the session scope explicitly: systemd-run --unit=&lt;name&gt; --property=RuntimeMaxSec=&lt;s&gt; as root, or loginctl enable-linger plus systemd-run --user for regular users. The same PR removed weave and weave-pack from prod hosts; they are not stable enough yet, and the modules stay in the tree for a later re-enable.</description>
    </item>
    <item>
      <title>The green CI that wasn&apos;t: an embed typecheck hidden behind a truncated log</title>
      <link>https://ix.dev/mcp-embed-typecheck-hunt</link>
      <guid isPermaLink="true">https://ix.dev/mcp-embed-typecheck-hunt</guid>
      <pubDate>Thu, 16 Jul 2026 08:51:49 GMT</pubDate>
      <description>The green CI that wasn&apos;t: an embed typecheck hidden behind a truncated log. Bundling in-process code embeddings into the MCP server (torch/MPS, #3417) should have been a clean package addition. Instead PR #3427 sat red on the required flake-check gate for two sessions, behind three separate red herrings. The durable lesson is not about embeddings: it is that a &quot;mystery&quot; CI failure is usually a real error you have not read yet. ## Symptom flake-check failed on #3427 with no obvious cause. The GitHub Actions step log ended mid-stream in a nix-fast-build progress dump, with no error line at the tail. Three plausible stories competed for the blame. ## The three red herrings - Billing lock. During a GitHub org billing-lock window the required gate had genuinely returned false-green: jobs failed in 2 to 3 seconds with empty steps. Real, but already over by the time #3427 was failing in hundreds of seconds with real work in the log. - Validation clock. The embed deps balloon the base-image OCI closure, and the ci-budget worker enforces a 300s routine deadline. Adding the ci/big-change label (which raises the ceiling to 3h and, via the labeled trigger, starts a fresh run) was the obvious fix. The re-run then failed at 261s, under 300s, disproving the clock theory outright. - The red-merge chain. A sibling commit 0a394ab7 had landed a naive digit-underscore text pass that mangled identifiers (isx86_64 to isx8664) during the same billing window. That broke eval on main, but it was a parallel fire, not #3427&apos;s failure. Each was a coherent narrative. None was the cause. ## Root cause The full error only existed in the runner-side log, not the GitHub step log. index CI writes complete stdout per test-merge SHA at /var/log/ix-ci/runs/&lt;merge_sha&gt;/Check/&lt;runid&gt;-&lt;attempt&gt;.stdout on whichever fleet host ran the ephemeral runner (runners are distributed: run 1 on vin-compute-2, run 2 on hil-compute-3). The GitHub step log had truncated the nix-fast-build failure mid-stream; the runner log carried it in full: embed&apos;s inference runtime (torch, sentence-transformers, huggingface_hub) is darwin-only (MPS). On x86_64-linux, which is exactly what the required gate builds, those modules are genuinely absent, so the strict typecheck fails to resolve the imports. The module was already written correctly for runtime: lazy imports inside try/except ImportError, a TYPE_CHECKING guard, a typed EmbedError. The failure was purely the type checker resolving the imports on a platform where they do not exist. ## The zuban trap The intuitive fix was per-module config in the zuban ini: It did nothing. zuban (0.8.0, mypy-compatible) in zuban check default mode (PyRight-like) does not honor per-module ignore_missing_imports sections. Neither does zuban mypy mode nor a TOML [[tool.mypy.overrides]] block in this setup. Only the global [mypy] ignore_missing_imports = true takes effect, and that is far too broad: it guts strict checking for the whole package. What zuban does honor is an inline suppression on the import line: zuban also does not flag an unused # type: ignore, so the same line is harmless on darwin where the module resolves. The fix was three inline ignores plus deleting the dead per-module config that never worked. ## Verification Guessing was the whole problem, so the fix was validated against the real artifact: the actual x86_64-linux derivation, built on the fleet builder vc1-nix: #3427 then merged on green. The base-image closure growth that first looked like a clock problem is real and tracked separately in #3448: a static costly_paths list cannot detect closure growth from a heavy dependency, so big_change was not auto-detected. ## What to carry forward - A truncated CI log is not a mysterious failure. Read the runner-side log at /var/log/ix-ci/runs/&lt;merge_sha&gt;/Check/ on the fleet host that ran it. - zuban check ignores per-module ignore_missing_imports. For a genuinely platform-only optional import, suppress inline with # type: ignore[import-not-found], not config. - Validate a typecheck fix by building the real target-platform derivation, not by trusting a darwin-local pass. - Three coherent narratives competed for the blame and all three were wrong. The evidence that settled it was one log file no one had opened. Written by Claude Code, an AI coding agent.</description>
    </item>
    <item>
      <title>Postmortem: the codex darwin cross closure that merged green and broke every switch</title>
      <link>https://ix.dev/codex-cross-closure-postmortem</link>
      <guid isPermaLink="true">https://ix.dev/codex-cross-closure-postmortem</guid>
      <pubDate>Tue, 14 Jul 2026 07:55:00 GMT</pubDate>
      <description>Postmortem: the codex darwin cross closure that merged green and broke every switch. PR #2690 put codex-rs on the cargo-unit cross DAG while lib/darwin/apple-sdk-toolchain.nix exported unsuffixed darwin CFLAGS, cc-rs concatenates flags for every target, and linux gcc build-script units died on -iframework. The drv hashed identically at every flake pin, so the cache hole was permanent, and every darwin home-manager switch broke while CI stayed green: flake-check only evaluates, and the post-merge cache push recorded the failed roots yet concluded success. Three fixes, one per lying layer: the apple toolchain env is now target-suffixed only (#3197), a cache-push run that leaves publish holes fails before the consumer pin moves (#3200), and a required pre-merge closure-gate realises .#cachePushRoots.x86_64-linux so a closure that cannot build cannot merge (#3202). Full postmortem in the playbook. Written by an AI agent via Claude Code (Claude Opus 4.5).</description>
    </item>
    <item>
      <title>CVE scans now follow exact identities through the Nix runtime DAG</title>
      <link>https://ix.dev/nix-runtime-cve-dag</link>
      <guid isPermaLink="true">https://ix.dev/nix-runtime-cve-dag</guid>
      <pubDate>Fri, 10 Jul 2026 02:05:00 GMT</pubDate>
      <description>CVE scans now follow exact identities through the Nix runtime DAG. The CVE gate now starts from typed shipped roots, realizes their /nix/store closures, and requires an evidenced PURL or canonical CPE before matching an advisory. It emits deterministic JSON, CycloneDX, SARIF, HTML, and summary artifacts with raw-source hashes, scanner provenance, root-scoped VEX, and exact dependency paths. Pull requests ratchet against the base revision so new actionable findings or identity debt block without hiding existing coverage work. Scheduled scans refresh OSV, NVD, CISA KEV, and EPSS, attest the evidence bundle, upload SARIF, and keep findings separate from scanner-health alerts. Written by Codex, an AI coding agent.</description>
    </item>
    <item>
      <title>HumanLayer remote daemon in an ix VM</title>
      <link>https://ix.dev/humanlayer-daemon</link>
      <guid isPermaLink="true">https://ix.dev/humanlayer-daemon</guid>
      <pubDate>Thu, 18 Jun 2026 16:00:00 GMT</pubDate>
      <description>HumanLayer remote daemon in an ix VM. The humanlayer-daemon image boots an ix VM that runs the HumanLayer (riptide) remote daemon, so a guest can act as a remote HumanLayer host driving coding sessions. It builds on the dev base image, so the agent CLIs and build toolchain are already present. The daemon is the services.humanlayer NixOS module: a systemd unit that runs humanlayer daemon launch. The humanlayer package repackages the published @humanlayer/cli binary (pinned per-platform hashes in manifest.json, refreshed by nix run .#humanlayer.updateScript). Auth follows the runtime-secret idiom: mint a launch token on an authenticated host with humanlayer api auth daemon launch-token create (or copy the launch command from app.humanlayer.com) and drop it at /run/secrets/humanlayer/launch-token. The token is read at service start and is never baked into the image; the daemon exchanges it for its own credentials.</description>
    </item>
    <item>
      <title>fleet.spark() adds big-data SQL to the cluster, alongside Ray</title>
      <link>https://ix.dev/fleet-spark</link>
      <guid isPermaLink="true">https://ix.dev/fleet-spark</guid>
      <pubDate>Sat, 13 Jun 2026 23:30:00 GMT</pubDate>
      <description>fleet.spark() adds big-data SQL to the cluster, alongside Ray. The fleet module now spans two complementary cluster engines: Ray for distributed Python (fleet.run/submit) and Spark for big-data SQL/DataFrames (fleet.spark) — handy for querying logs gathered across every node. fleet.spark() opens a SparkSession over Spark Connect (sc://&lt;master&gt;:15002): the client in the kernel is pure gRPC (no local JVM), heavy work runs on the cluster with the Gluten/Velox native engine, and results stream back as Arrow. The bundled client is pinned to the cluster&apos;s Spark 3.5 with its JVM jars stripped, so only the Connect path&apos;s deps ride along. Deploy with services.ix-spark (a Gluten-accelerated master + Spark Connect server, plus workers over Tailscale), mirroring services.ix-ray. The Ray cluster service was also renamed from services.ix-fleet to services.ix-ray and hardened (pinned inter-node ports, a short /run/ray socket dir, a mappable shared-memory object store).</description>
    </item>
    <item>
      <title>fleet turns the tailnet into one Ray cluster you can compute on</title>
      <link>https://ix.dev/fleet-cluster</link>
      <guid isPermaLink="true">https://ix.dev/fleet-cluster</guid>
      <pubDate>Sat, 13 Jun 2026 20:30:00 GMT</pubDate>
      <description>fleet turns the tailnet into one Ray cluster you can compute on. The fleet module bundled in every ix-mcp kernel now sees and computes on the whole tailnet, not just the local machine. await fleet.nodes() returns one polars frame of every node, its tailscale address, and the Ray resources it advertises. Ship a Python callable to the cluster and the Ray object store carries the data: Ray (bundled in the interpreter) provides the distributed object store, peer-to-peer transfer between nodes, spill-to-disk under memory pressure, and true multi-process, multi-node parallelism, so a CPU-bound job is no longer stuck behind one kernel&apos;s GIL. fleet.in_kernel(node, code) runs a line in another node&apos;s live ix-mcp session (tailnet-gated) to read what it is actually doing, and the original fleet.scan still fans a shell command over SSH for nodes with no Python. Deploy with the services.ix-ray NixOS module: one node is the Ray head, the rest are workers on the tailnet. The trust boundary is the tailnet, the same one Ray&apos;s own data plane relies on, so in_kernel works across the fleet with no secret; set services.ix-ray.tokenFile to additionally require a shared IX_MCP_EXEC_TOKEN. An off-cluster box drives the cluster by pointing IX_FLEET_RAY_ADDRESS at ray://&lt;head&gt;:10001.</description>
    </item>
    <item>
      <title>gcal: Google Calendar as a CLI and as ix-mcp tools, over one Rust crate</title>
      <link>https://ix.dev/google-calendar-gcal</link>
      <guid isPermaLink="true">https://ix.dev/google-calendar-gcal</guid>
      <pubDate>Fri, 05 Jun 2026 05:25:16 GMT</pubDate>
      <description>gcal: Google Calendar as a CLI and as ix-mcp tools, over one Rust crate. nix run .#gcal lists, shows, creates, and cancels Google Calendar events from the shell, and the ix-mcp server now exposes the same capability as calendar_events / calendar_event_create / calendar_event_cancel. Both surfaces are thin: a single google-calendar Rust crate owns the Calendar v3 client, the wire types, and the OAuth flow, the contract RFC 0003 asks of new integrations. Auth is per person and broker-free: a team OAuth client from rbw, one gcal auth consent in a browser (PKCE, loopback redirect, with a paste-the-URL path for SSH/VM sessions), and an offline refresh token in ~/.config/gcal/token.json. Creating or cancelling an event emails attendees by default, matching the Calendar UI; pass --notify none while experimenting.</description>
    </item>
    <item>
      <title>Dashboard shows every Python run and replays the whole session</title>
      <link>https://ix.dev/dashboard-exec-panes-replay</link>
      <guid isPermaLink="true">https://ix.dev/dashboard-exec-panes-replay</guid>
      <pubDate>Tue, 02 Jun 2026 16:00:00 GMT</pubDate>
      <description>Dashboard shows every Python run and replays the whole session. Every python_exec and python_eval the ix MCP runs now appears on the dashboard as its own card: a box of the run&apos;s captured stdout and stderr, with the source behind it (a show source toggle, or the focus view). The worker captures output at the file-descriptor level, so a spawned subprocess counts too: subprocess.run([&quot;echo&quot;, &quot;hi&quot;]) shows hi, not nothing. Moving the JSON-RPC channel off fd 1 in the same change fixes a latent bug where that subprocess output could corrupt the protocol stream. The resource model is now general. A view tells the hub its shape as a set of scalar fields and a set of named text fields, so a new kind (the typed exec view here, and future user-defined resources) is one projection rather than an edit to the reconcile loop. Terminals still render exactly as before; a user-defined resource still rides html or data with no aggregator change. Every card, of any kind, shows when it was created in human time, stamped once by the aggregator with no producer opt-in. The board is also a recording. The hub timestamps every change, the browser keeps the full history, and a timeline bar scrubs to any past moment, plays it back at 1×–8×, or follows the live tail like a livestream. The aggregator persists the recording to disk ($XDG_STATE_HOME/ix-dash/recordings), serves saved sessions at /recordings and /recording/&lt;id&gt;, and the share button copies a deep link to the exact moment. A focus button on any card opens that one resource fullscreen with the same time controls. Verified end to end with Playwright: live ticks, a scrub back into history, playback, and a jump back to live.</description>
    </item>
    <item>
      <title>Agent instructions load progressively as skills</title>
      <link>https://ix.dev/agent-context-progressive</link>
      <guid isPermaLink="true">https://ix.dev/agent-context-progressive</guid>
      <pubDate>Sun, 31 May 2026 04:40:00 GMT</pubDate>
      <description>Agent instructions load progressively as skills. The contributor guide is no longer one always-on file. Each fragment under agent-context/sections/ declares a disclosure tier in its frontmatter: always fragments join a small core every session reads in full, and progressive fragments each become a Claude Code skill whose body loads on demand from its description. There are no committed AGENTS.md / CLAUDE.md files anymore; a SessionStart hook renders the core live and points .claude/skills at a generated link farm. The win is context budget: the always-on core dropped from ~48 KB to ~7 KB, and depth (Nix style, image conventions, Rust rules) loads only when the work calls for it. The size of the always-on tier is a build-time invariant. Marking too much always fails the build with a clear message instead of silently overflowing Claude Code&apos;s per-value context cap. Sibling repos consume index.lib.agentContext for the parsed sections, the asserted always-on document, and mkProgressiveSkills to merge generated section-skills into their own .claude/skills.</description>
    </item>
    <item>
      <title>tui dashboard follows the browser&apos;s light/dark scheme</title>
      <link>https://ix.dev/tui-dashboard-color-scheme</link>
      <guid isPermaLink="true">https://ix.dev/tui-dashboard-color-scheme</guid>
      <pubDate>Sat, 30 May 2026 19:00:00 GMT</pubDate>
      <description>tui dashboard follows the browser&apos;s light/dark scheme. The tui dashboard now tracks the browser or OS color scheme. Each themeable token is a single light-dark(lightValue, darkValue) declaration under color-scheme: light dark, so the ghostty light and dark palettes live in one source of truth per token instead of a :root block shadowed by a @media (prefers-color-scheme: dark) override. A &lt;meta name=&quot;color-scheme&quot; content=&quot;light dark&quot;&gt; in &lt;head&gt; lets the user agent settle on the scheme before CSS parses. That tag is the actual fix for webviews that rendered the light theme even in dark mode: the CSS already flipped correctly under an emulated dark scheme, so the bug was the host not advertising its scheme early enough. Browsers without light-dark() (roughly 15% as of 2026) fall back to the classic dark override inside @supports not (color: light-dark(#000, #fff)). Verified with Playwright: emulated dark resolves --bg to #151515, light to #ececec.</description>
    </item>
    <item>
      <title>ix-mcp playwright instructions default to reusing a browser</title>
      <link>https://ix.dev/ix-mcp-playwright-reuse</link>
      <guid isPermaLink="true">https://ix.dev/ix-mcp-playwright-reuse</guid>
      <pubDate>Sat, 30 May 2026 06:30:00 GMT</pubDate>
      <description>ix-mcp playwright instructions default to reusing a browser. The ix-mcp session instructions now tell the model to reuse a browser rather than launch a fresh Chromium every call. A new launch per turn wastes startup time and discards cookies and logins. Within one session the persistent event loop keeps a launched browser and its state alive, so the recipe launches once and keeps the handle in a module global: To share one browser across separate sessions, the instructions connect over CDP (Chromium only). The connect-vs-launch decision rides on a recorded endpoint file, not a swallowed exception: the first session launches a persistent Chromium on a fixed --remote-debugging-port, records the endpoint, and later sessions read it and connect_over_cdp to the running browser via browser.contexts[0]. A stale endpoint raises on connect rather than silently relaunching, so the failure is visible.</description>
    </item>
    <item>
      <title>ix-mcp Python sessions ship playwright with browsers wired</title>
      <link>https://ix.dev/ix-mcp-playwright</link>
      <guid isPermaLink="true">https://ix.dev/ix-mcp-playwright</guid>
      <pubDate>Sat, 30 May 2026 05:55:00 GMT</pubDate>
      <description>ix-mcp Python sessions ship playwright with browsers wired. playwright is now preinstalled in every ix-mcp Python session, alongside numpy, polars, matplotlib, and asyncssh. The wrapper sets PLAYWRIGHT_BROWSERS_PATH to the playwright-driver browser bundle that matches the patched node driver, so a browser launches with no playwright install step and no network download. Use the async API so it shares the session event loop: Chromium and Firefox are the reliable engines under Nix; WebKit is known to be flaky. The browser bundle is a few hundred MB, so it adds real closure weight to the MCP package.</description>
    </item>
    <item>
      <title>elevenlabs-say speaks a pipe as it streams</title>
      <link>https://ix.dev/elevenlabs-say-streaming-input</link>
      <guid isPermaLink="true">https://ix.dev/elevenlabs-say-streaming-input</guid>
      <pubDate>Sat, 30 May 2026 04:00:00 GMT</pubDate>
      <description>elevenlabs-say speaks a pipe as it streams. elevenlabs-say now sits at the end of a pipe whose producer emits text over time and speaks it as it arrives. Piped stdin streams by default, no flag needed: Piped input goes over the ElevenLabs WebSocket input-streaming endpoint. The CLI reads stdin with os.read rather than line iteration, so a token stream that never emits a newline is still spoken instead of buffering until EOF, and a multi-byte character split across two reads is rejoined. Audio chunks pipe into ffplay as they arrive, so playback starts before synthesis finishes. A positional TEXT argument or --file stays on the batch convert endpoint, where the whole text is in hand and prosody is slightly better. Override either way with --stream or --no-stream: reach for --no-stream when piping a complete document and wanting the batch quality. It honors --voice, --model, --format, and --output (which writes the file as audio streams in). The default eleven_flash_v2_5 model is the low-latency choice and supports the endpoint; eleven_v3 does not.</description>
    </item>
    <item>
      <title>tui-dashboard aggregates many tui producers into one live web grid</title>
      <link>https://ix.dev/tui-multiprocess-dashboard</link>
      <guid isPermaLink="true">https://ix.dev/tui-multiprocess-dashboard</guid>
      <pubDate>Fri, 29 May 2026 22:45:00 GMT</pubDate>
      <description>tui-dashboard aggregates many tui producers into one live web grid. Several processes can now share one tui web dashboard instead of each starting its own server. A producing process calls tui::publish (or tui.publish() in Python) to expose its terminals over a unix socket in a shared discovery directory, holding no HTTP or CRDT dependency of its own. Run the standalone aggregator once to watch all of them: It scans the discovery directory, connects to every producer socket, folds each producer&apos;s stream into one Loro document under its own scope, and serves the grid over Server-Sent Events. No producer owns the server and exactly one process binds a TCP port, so agents come and go behind a single stable URL; a producer&apos;s terminals leave the grid when it disconnects. The in-process tui::serve and the aggregator share one serve_hub, page, and SSE stream, so both render through the same code. The discovery directory resolves to $IX_TUI_DIR, then $XDG_RUNTIME_DIR/ix-tui, then /tmp/ix-tui-&lt;user&gt;, kept short for the macOS 104-byte socket-path limit.</description>
    </item>
    <item>
      <title>ix-mcp Python sessions run on one pinned interpreter</title>
      <link>https://ix.dev/ix-mcp-pinned-interpreter</link>
      <guid isPermaLink="true">https://ix.dev/ix-mcp-pinned-interpreter</guid>
      <pubDate>Fri, 29 May 2026 21:05:00 GMT</pubDate>
      <description>ix-mcp Python sessions run on one pinned interpreter. python_session_create no longer takes a command, and the CLI drops --python. Every session runs on the interpreter the server pins (IX_MCP_PYTHON, set by the Nix wrapper to a fixed python3), inside its own writable venv so pip install still works. Bring-your-own interpreter sounded flexible but bought little. It made each session depend on the host&apos;s ambient Python, and its failure modes (missing interpreter, wrong version, no pip) all read as &quot;your environment is weird.&quot; One pinned interpreter makes every session reproducible and deletes that whole class of failure. cwd stays, so a session can still root itself in a project directory: What you give up: a session can&apos;t use a project&apos;s own resolved dependencies (a uv or poetry env). If that need comes back, the right shape is a typed &quot;project env&quot; door: the caller names a project and the server resolves the interpreter, no raw command vector.</description>
    </item>
    <item>
      <title>ix-tui drops its sync methods for one async surface</title>
      <link>https://ix.dev/tui-py-async-only</link>
      <guid isPermaLink="true">https://ix.dev/tui-py-async-only</guid>
      <pubDate>Fri, 29 May 2026 21:00:00 GMT</pubDate>
      <description>ix-tui drops its sync methods for one async surface. The ix-tui Python bindings are now async-only. Every I/O method is a coroutine with the plain name, no a-prefixed twin and no synchronous variant: Before, each method shipped a blocking version plus an a-prefixed coroutine (enter / aenter, wait_for / await_for, kill / akill). Carrying both doubled the surface for one behavior, and the prefix only existed to dodge the name clash. With the sync twins gone the coroutines take the real names. The dashboard follows the same rule: await serve(...) and await dash.stop(). This is a breaking change. Replace with Tui(...) with async with Tui(...), drop the a/await_ prefixes, and await the calls. Construction and the pure accessors (id, command, size, is_alive, exit_code, Tui.list_all()) stay synchronous, since they read cached state and never touch the PTY. Coroutines bridge real Rust futures into asyncio through pyo3-async-runtimes with no thread-pool hop, so they run on whatever loop is already driving the caller. That includes the ix-mcp Python session, whose single persistent loop now lets you await t.enter(...) straight inside a python_exec call.</description>
    </item>
    <item>
      <title>ix-mcp Python sessions accept top-level await</title>
      <link>https://ix.dev/ix-mcp-top-level-await</link>
      <guid isPermaLink="true">https://ix.dev/ix-mcp-top-level-await</guid>
      <pubDate>Fri, 29 May 2026 20:30:00 GMT</pubDate>
      <description>ix-mcp Python sessions accept top-level await. python_eval and python_exec now run await at the top level, no asyncio.run wrapper. Each session keeps one event loop for its whole life, so an async resource created in one call is still live in the next: This is the loop that asyncio.run cannot give you: it opens and closes a fresh loop per call, so a client or pool created on one turn is bound to a loop that is already closed by the next. The worker compiles every snippet with ast.PyCF_ALLOW_TOP_LEVEL_AWAIT and drives the resulting coroutine on the kept loop, the same mechanism as CPython&apos;s python -m asyncio REPL. Known limitation: a coroutine that never resolves blocks the worker, exactly the way a synchronous while True already does. Bound long awaits yourself with asyncio.wait_for when you need a ceiling.</description>
    </item>
    <item>
      <title>nix-web-monitor shows the whole build tree from the start</title>
      <link>https://ix.dev/nix-web-monitor-full-tree</link>
      <guid isPermaLink="true">https://ix.dev/nix-web-monitor-full-tree</guid>
      <pubDate>Thu, 28 May 2026 22:45:00 GMT</pubDate>
      <description>nix-web-monitor shows the whole build tree from the start. The builds panel now seeds the entire tree the moment Nix prints its plan, so nix build .#thing shows thing at the root with every derivation nested under it before any leaf starts. Nodes appear as planned and light up as builds run, instead of the tree growing bottom-up from whatever happens to be building. Two changes make this work. The parser reads Nix&apos;s &quot;these N derivations will be built:&quot; announcement and seeds one planned row per derivation up front. The edge resolver queries nix-store --query --requisites for each derivation&apos;s full transitive .drv closure rather than only its direct references, so the reduced DAG bridges through cached intermediates: a dependency several hops down still nests under its target even when the derivations between them were substituted and never built. This supersedes the earlier behaviour, where a link bridged entirely through a cached derivation was dropped from the tree.</description>
    </item>
    <item>
      <title>nix-web-monitor builds panel can nest derivations by dependency</title>
      <link>https://ix.dev/nix-web-monitor-dependency-tree</link>
      <guid isPermaLink="true">https://ix.dev/nix-web-monitor-dependency-tree</guid>
      <pubDate>Thu, 28 May 2026 18:30:00 GMT</pubDate>
      <description>nix-web-monitor builds panel can nest derivations by dependency. The builds panel now has a flat / tree toggle. Tree mode nests each built derivation under the derivation that depends on it, so you can read which build is waiting on which instead of scanning a flat list. Nix&apos;s internal-json log stream names every derivation it builds but carries no edges between them, so the monitor learns the structure out of band: for each built derivation it queries nix-store --query --references for the direct input .drv paths, keeps the edges between derivations that actually built this run, and transitively reduces them so a chain a → b → c never also draws the redundant a → c. A dependency bridged entirely through a cached, never-built derivation is intentionally absent from the tree, because the cached node has no build row to attach to. The flat view still lists every build with its host, phase, duration, and log count.</description>
    </item>
    <item>
      <title>file-search ships a Tantivy-backed BM25 indexer with a CLI, library, and bench</title>
      <link>https://ix.dev/file-search</link>
      <guid isPermaLink="true">https://ix.dev/file-search</guid>
      <pubDate>Wed, 27 May 2026 08:30:00 GMT</pubDate>
      <description>file-search ships a Tantivy-backed BM25 indexer with a CLI, library, and bench. file-search indexes a directory tree into a Tantivy index and serves BM25 ranked search over it. The CLI exposes index and search subcommands; the same surface is reachable through the file_search library for callers that want to embed the indexer directly. An in-memory EphemeralSearch variant uses Tantivy&apos;s RamDirectory to rerank a batch of texts without touching disk. The tokenizer lives in its own code-tokenizer crate so other consumers can reuse the same camelCase / snake_case / kebab-case split plus optional English stemming, with ANSI escape sequences stripped before tokenization. A tango-bench suite covers the ephemeral path so changes to the tokenizer or query plan show up as a regression instead of a vibe shift.</description>
    </item>
    <item>
      <title>cargo-unit shares clippy work across repo Rust packages</title>
      <link>https://ix.dev/cargo-unit-clippy-sharing</link>
      <guid isPermaLink="true">https://ix.dev/cargo-unit-clippy-sharing</guid>
      <pubDate>Tue, 26 May 2026 11:22:46 GMT</pubDate>
      <description>cargo-unit shares clippy work across repo Rust packages. The Rust flake checks look busier than the builder really is: most package-level cargoClippy entries are labels for one shared workspace derivation. rust-dag-runner-cargoClippy, rust-mcp-cargoClippy, and the other repo-owned package checks resolve to the same ix-rust-workspace-cargo-clippy.drv. The derivation runs one Cargo command with one target directory: That means a shared dependency such as e in an a, b, c, d workspace rides Cargo&apos;s normal unit sharing inside the single clippy run. The generated unit graph has the same shape for package builds: a fixture where scope-alpha and scope-bravo both depend on itoa and ryu produces one itoa unit and one ryu unit for the exact compile context. Splitting clippy into per-package derivations would make the check list feel more literal, but it would also give each package a separate CARGO_TARGET_DIR and make shared dependencies more likely to rebuild. The useful invariant is the current one: many package labels can fail the same shared policy check, while Nix realizes the workspace clippy derivation once.</description>
    </item>
    <item>
      <title>CAS defaults need a cache plan before they can carry ix builds</title>
      <link>https://ix.dev/cas-default-blocked</link>
      <guid isPermaLink="true">https://ix.dev/cas-default-blocked</guid>
      <pubDate>Tue, 26 May 2026 11:21:18 GMT</pubDate>
      <description>CAS defaults need a cache plan before they can carry ix builds. ix VM images already enable the ca-derivations experimental feature for in-VM Nix commands. The harder default is the nixpkgs switch, config.contentAddressedByDefault = true, which makes stdenv derivations emit floating content-addressed outputs by default. That switch is not ready to carry this repo&apos;s default checks. Against the current pinned nixpkgs, a dry run for a one-line stdenv derivation under contentAddressedByDefault reported 736 local derivations before the leaf package. A live smoke test started rebuilding bootstrap and stdenv objects immediately. The issue is cache shape. Normal nixpkgs substitutes do not satisfy the changed content-addressed stdenv graph, so nix run .#lint and CI would begin by seeding a separate package universe instead of testing the ix change. That cost belongs in a cache rollout, not in every contributor&apos;s first validation run. The useful near-term path is narrower. Keep ca-derivations enabled where ix controls the Nix config, then opt in leaf repo builders whose dependencies stay on the normal cache graph. A repo-wide default can become boring after CI publishes enough content-addressed stdenv closure to make the first check substitute again.</description>
    </item>
    <item>
      <title>ix.buildSvelteSite packages Svelte builds, previews, and dev servers</title>
      <link>https://ix.dev/svelte-site-builder</link>
      <guid isPermaLink="true">https://ix.dev/svelte-site-builder</guid>
      <pubDate>Tue, 26 May 2026 10:50:00 GMT</pubDate>
      <description>ix.buildSvelteSite packages Svelte builds, previews, and dev servers. ix.buildSvelteSite is now the shared builder for the repo&apos;s Svelte/Vite surfaces: the docs site and resource monitor site. It owns the locked production build, a static preview command, and a checkout dev-server command from one Nix call. The build still runs from a filtered, tracked source closure. The preview command serves the immutable output with miniserve, so nix run .#site exercises the same files the package embeds. The dev command runs from the mutable checkout instead: That split is deliberate. Vite&apos;s dev server writes dependency installs, caches, and HMR state, while Nix builds should stay pure and reproducible. The -dev wrappers install node_modules when missing, pass Vite&apos;s --host and --port flags, and leave ignored files in the checkout where editors and file watchers expect them. For OrbStack and VM workflows, the builder stops at the command boundary. OrbStack already provides macOS-to-Linux bind mounts, port forwarding, and faster Linux-side volumes for heavier dependency trees. When an ix shell workspace needs ignored files copied after a tracked source sync, nix run .#ix-shell-sync-ignored -- &lt;vm&gt; &lt;path&gt; streams only the git-ignored paths into /work/ix.</description>
    </item>
    <item>
      <title>Reusable Codex AI review gate for any repo</title>
      <link>https://ix.dev/ai-review-gate</link>
      <guid isPermaLink="true">https://ix.dev/ai-review-gate</guid>
      <pubDate>Tue, 26 May 2026 09:55:00 GMT</pubDate>
      <description>Reusable Codex AI review gate for any repo. indexable-inc/index/.github/workflows/ai-review-gate.yml@main is a workflow_call reviewer that any repo can mount as the default AI review on main. The current implementation runs openai/codex-action against a JSON schema, posts each finding as a GitHub review suggestion with a suggested_replacement, and reports an overall_correctness verdict that becomes the final gate. Same-repo PRs from regular users run the secret-backed reviewer. Fork and Dependabot PRs stay on a no-secret path until a trusted maintainer approves the current head SHA, so a forked PR cannot edit the prompt and reach the OpenAI key. The reviewer instruction files (AGENTS.md and CLAUDE.md) are restored from the trusted base commit before Codex runs, so a PR that rewrites them cannot bend the reviewer&apos;s rules. The structured output keeps each finding mechanically actionable: title, body, confidence_score, priority, code_location.{relative_file_path, line_range}, and a suggested_replacement GitHub renders as a one-click apply. AI_REVIEW_MODEL and AI_REVIEW_EFFORT are repo variables, with xhigh reasoning effort as the default. Direct users can require the ai review approved check. Reusable-workflow callers should require GitHub&apos;s called-job check name, such as ai-review-gate / ai review approved for the example above. The gate only passes when the reviewer returns a well-formed JSON document and votes patch is correct; missing or malformed output fails closed.</description>
    </item>
    <item>
      <title>ix-mcp serves a persistent Python session over MCP</title>
      <link>https://ix.dev/ix-mcp-python</link>
      <guid isPermaLink="true">https://ix.dev/ix-mcp-python</guid>
      <pubDate>Tue, 26 May 2026 09:50:00 GMT</pubDate>
      <description>ix-mcp serves a persistent Python session over MCP. nix run .#mcp speaks MCP over stdio and exposes a Python interpreter as a small set of tools. Sessions live across calls, so an agent can load a dataset once with python_exec and ask python_eval questions against the cached globals on later turns. No notebook server, no kernel manager, just stdin / stdout. Each session boots its own writable venv under a temp dir on one pinned interpreter, so pip install works without mutating the read-only store python. The interpreter is fixed: there is no per-session interpreter choice. Pass cwd to root the session in a project directory: This pairs naturally with nix run .#run. After recording a slow build, the agent can ask the MCP session to load .ix/run/latest/lines.jsonl into polars and rank the longest gaps, without paging tens of MB of log through its own context window: The follow-up python_eval calls reuse slow, so the agent can ask several questions of the same dataframe instead of re-parsing the log each turn. python_reset clears the globals, python_session_close ends the worker. The same binary is also a normal CLI: nix run .#mcp -- repl opens an interactive Python session, nix run .#mcp -- exec &apos;&lt;source&gt;&apos; and nix run .#mcp -- eval &apos;&lt;expression&gt;&apos; run a one-shot through the same worker. The MCP tools and the CLI subcommands share one implementation, so what works at the shell works the same when an agent drives it.</description>
    </item>
    <item>
      <title>nix run .#run records command sessions</title>
      <link>https://ix.dev/run-recorder</link>
      <guid isPermaLink="true">https://ix.dev/run-recorder</guid>
      <pubDate>Tue, 26 May 2026 09:49:00 GMT</pubDate>
      <description>nix run .#run records command sessions. nix run .#run -- &lt;command&gt; ... executes the command in a PTY, prints a bounded head/tail summary to the terminal, and writes the full live stream plus per-line timing under ./.ix/run/latest/. A second shell can tail -f .ix/run/latest/output.log while the original command is still running, useful for slow Nix builds and long test suites. ./.ix/run/latest/replay [divisor] reads timing.log and typescript through scriptreplay to play the session back, and session.cast is a valid asciinema v2 file. lines.jsonl carries one JSON object per completed output line with started_elapsed_ns, ended_elapsed_ns, delta_since_previous_line_ns, and byte_count. Polars reads it with no schema work and keeps the elapsed-time columns as integer nanoseconds, so ranking the lines that gapped longest before printing is one expression: The same shape works for any structured event stream you choose to record. nix build .#someBadAttr --log-format internal-json emits one @nix {...} line per phase, with start / stop pairs keyed on id: Record that through run, drop the @nix prefix, decode the rest, and join starts to stops to surface the slowest phases: pl.read_ndjson replaces the older pandas.read_json(path, lines=True) recipe in the README. The columnar layout keeps the elapsed-time columns as Int64 nanoseconds without coercion, and swapping to pl.scan_ndjson lets the same expression run as a streaming query when a build session grows past memory.</description>
    </item>
    <item>
      <title>ix.buildZigPackage builds build.zig.zon projects with a cached package store</title>
      <link>https://ix.dev/zig-package-builder</link>
      <guid isPermaLink="true">https://ix.dev/zig-package-builder</guid>
      <pubDate>Tue, 26 May 2026 09:27:07 GMT</pubDate>
      <description>ix.buildZigPackage builds build.zig.zon projects with a cached package store. ix.buildZigPackage wraps a build.zig / build.zig.zon project as a normal Nix derivation. Remote build.zig.zon dependencies are materialized once through nixpkgs.zigPackages.&lt;minor&gt;.fetchDeps as a fixed-output derivation, then copied into a writable ZIG_GLOBAL_CACHE_DIR for the package build and every test step, so product builds stay offline and reproducible. Named Zig test steps are exposed as separate passthru.tests.&lt;name&gt; derivations rather than a single rolled-up step, so the flake check scheduler runs them in parallel and a failing case names the step that actually broke. The cache materialization is the one place an __impure updater is acceptable in this repo, because the result is converted to a checked hash-bearing artifact before any product build consumes it. The boundary lives next to the helper and is documented in AGENTS.md.</description>
    </item>
    <item>
      <title>nix run .#site previews the page locally</title>
      <link>https://ix.dev/nix-run-site</link>
      <guid isPermaLink="true">https://ix.dev/nix-run-site</guid>
      <pubDate>Tue, 26 May 2026 08:22:16 GMT</pubDate>
      <description>nix run .#site previews the page locally. The site package now includes the deploy artifact and a small miniserve preview command, so nix build .#site emits the GitHub Pages tree and nix run .#site boots a preview at http://127.0.0.1:8080/index/. The wrapper serves the same SvelteKit build that Pages deploys. ix.buildSvelteSite passes miniserve a route prefix instead of rebuilding with a different BASE_PATH, so local preview exercises the production /index URL layout.</description>
    </item>
    <item>
      <title>cargo-unit caches Rust tests per #[test]</title>
      <link>https://ix.dev/cargo-unit-per-test</link>
      <guid isPermaLink="true">https://ix.dev/cargo-unit-per-test</guid>
      <pubDate>Tue, 26 May 2026 07:19:45 GMT</pubDate>
      <description>cargo-unit caches Rust tests per #[test]. nix-cargo-unit used to build one derivation per test binary; a single flaky case re-ran every test in the file and lost Nix scheduler parallelism. The generated tests.&lt;binary&gt; attrset now exposes all (legacy whole-binary behavior) plus cases.&quot;mod::test_x&quot;, one runCommand per individual test, invoked with --exact. Case enumeration would have been N serial IFDs, one per binary, since Nix walks IFDs single-file. They are collapsed into one testManifestDrv that depends on every test binary and writes per-target .list and .ignored.list files. Touching any cases entry now triggers one workspace-wide build instead of paying N round-trips. The same arc covers doctests, scoped to root targets, and per-binary coverage reports in passthru.coverage.</description>
    </item>
    <item>
      <title>ix-dev-diagnose probes ix.dev reachability</title>
      <link>https://ix.dev/ix-dev-diagnose</link>
      <guid isPermaLink="true">https://ix.dev/ix-dev-diagnose</guid>
      <pubDate>Mon, 25 May 2026 23:42:25 GMT</pubDate>
      <description>ix-dev-diagnose probes ix.dev reachability. nix run .#ix-dev-diagnose reaches https://ix.dev/ from the caller&apos;s network path, prints success or failure, and writes one JSON report capturing system resolver answers, per-address TCP and TLS results, parsed certificate issuers and fingerprints, native and Mozilla-root verification outcomes, response headers, and a bounded body sample. Defaults are sensible: the report lands at ix-dev-diagnose-&lt;host&gt;-&lt;ms&gt;.json in the current directory and the body sample is capped at 64 KiB. Pass --max-body-bytes, --output, --pretty, or --json to override. Intended for cases where the failing client sees different bytes than a working one: SEC_ERROR_UNKNOWN_ISSUER, captive portals, ISP interception, stale DNS, or CDN edge differences. Attach the report to a support ticket instead of describing the symptom.</description>
    </item>
    <item>
      <title>Self-hosted OpenTelemetry stack module</title>
      <link>https://ix.dev/observability-stack</link>
      <guid isPermaLink="true">https://ix.dev/observability-stack</guid>
      <pubDate>Sat, 23 May 2026 07:25:53 GMT</pubDate>
      <description>Self-hosted OpenTelemetry stack module. modules/services/observability now wires a complete self-hosted stack: an OpenTelemetry collector, Tempo for traces, Loki for logs, Mimir or Prometheus for metrics, and Grafana with a generated overview dashboard. The dashboard is defined in Nix through dashboards/lib.nix, so panels can be composed and reused instead of pasted as JSON blobs. An examples/observability/stack/ fleet shows the smallest viable deployment. The module is module-tested end-to-end through the existing tests/ harness.</description>
    </item>
  </channel>
</rss>
