packages feed

nova-nix-0.9.0.0: CHANGELOG.md

# Changelog

## 0.9.0.0 - 2026-10-31

On nixpkgs 26.05 and unstable, where 0.8.0.0 failed, CI now holds 22 derivations to the store paths upstream computes, on Nix 2.34.9 and 2.35.4 respectively, alongside Nix 2.24.9 with nixpkgs 24.11.

### Library API

Breaking changes for code that uses nova-nix as a library:

- **`Nix.Expr.Types.Binding`'s `NamedBinding` holds one `AttrKey` where it held an `AttrPath`,** as upstream's `ExprAttrs` holds one name per definition (nixexpr.hh at 2.24.9). The parser already hoisted `a.b.c = v;` into nested sets before evaluation; it now does so as it parses, so no binding carries a path. `Nix.Eval.Compile.BcNamed` holds one `BcAttrKey`, and a named binding's bytecode drops its path-length word. `Nix.Eval.CStatus.CStatusError` includes `CInvariantViolated`, raised through `cInvariantViolated`. In `Nix.Eval.CThunk`, `cthunkNull :: CThunkPtr` and `cthunkBool :: Bool -> CThunkPtr` replace `cthunkNewComputedNull` and `cthunkNewComputedBool`, and the `cthunkSetComputed` setters and `cthunkSetPayload` return `Bool`, whether the thunk took the value, where the setters returned the pending payload. `Nix.Eval.CAttrSet.cattrsetNew` takes the expected entry count as an `Int`, and it, `cattrsetInsert`, `cattrsetUnion`, `cattrsetRemoveKeys` and `Nix.Eval.CList.clistNew` raise `CStatusError` when the C side refuses. (#19)
- **`formatXmlFloat` moved to `Nix.Eval.Print` from `Nix.Eval.StringInterp`, which now prints the value its coercion failure names.** `Nix.Eval.Operator.evalBinary` no longer evaluates `OpAdd`, which its caller handles as it handles the short-circuit operators, because `+` looks at its left operand before the right is evaluated and coerces through the evaluator; `Nix.Eval.Operator` exports `addToInteger` and `addToFloat`, the arithmetic once the left operand is a number. `Nix.Eval.Types` exports `thunkUnderEvaluation`, whether a thunk is being forced, and `typeName` returns upstream's `showType` names. (#250)
- **`Nix.Hash.compressHash` takes and returns a `ByteString`,** the digest it folds and the bytes it folds it to, where both were `[Word8]`. (#48)
- **`Nix.Config` resolves configuration through a file-reading effect, so includes and the file cascade are testable against an in-memory map.** `loadConfig` reads, expands and folds a weakest-first path list plus the `NIX_CONFIG` bytes over a `ReadConfigFile m` (`OsPath -> m (Either ConfigReadFailure ByteString)`, the failure saying whether the file is missing or merely unreadable, which decides how a failed `include` is reported); `expandConfigSource` splices one `ConfigSource`'s includes; `parseConfigLines` replaces `parseConfigText` and yields `ConfigLine`, an assignment or an `IncludeMode` include; and `ConfigLocations` with `configFilePaths` computes upstream's file order from the raw environment and the platform's directories, which the caller supplies so the module carries no platform literal, refusing a relative `NIX_CONF_DIR` as upstream does. `resolveConfig` now folds a flat `[ConfigAssignment]`, failing only on a value a setting cannot take, and `applyConfigText` is gone, since a source's text cannot be applied without the files it includes. (#199)
- **`Nix.Config` carries a source as bytes and its paths as `OsPath`.** `ConfigSource` holds an `OsPath` name and `ByteString` text, `ReadConfigFile m` is `OsPath -> m (Either ConfigReadFailure ByteString)`, `loadConfig` takes `[OsPath]` and the `NIX_CONFIG` bytes, and `configFilePaths` returns `[OsPath]`. `ConfigLocations` takes `NIX_CONF_DIR` and `NIX_USER_CONF_FILES` as the bytes `Nix.Environment.lookupEnvBytes` returns and its directories as `OsPath`. `ConfigLine`'s include target and `ConfigAssignment`'s name and value are `ByteString`, `parseConfigLines` takes an `OsPath` and a `ByteString`, `parseUnsignedSetting` takes `ByteString`s, and `nixConfFileName`, `nixConfDirName` and `nixConfigSourceName` are `OsPath`. The new module `Nix.HostPath` spells bytes as a host path (`hostPathFromBytes`), the spelling `Nix.Store.EntryName` now uses too, and shows one in a message (`hostPathText`). The executable's `filepath` and `directory` floors rise to 1.4.100 and 1.3.8, the first with `System.OsPath` and `System.Directory.OsPath`, as the library's did, and it depends on `file-io` >= 0.1.1 to read a file by its `OsPath`, 0.1.1 being the first whose read does not create a missing file on Windows. (#244)
- **`Nix.Eval.Types.MonadEval` has a new method with no default, `addErrorTrace`, and `Nix.Eval.IO`'s `NixEvalError` and `NixAbortError` carry an `EvalFailure`, a message with its lines of context, where they carried the message alone.** Every instance defined outside this package needs the method. `runEvalIO` still returns the message; the new `runEvalIOTraced` returns the whole `EvalFailure`, and `renderEvalFailure` writes it as upstream reports an error. (#221)
- **`Nix.Eval.Types.NixValue` drops `VDerivation`.** The constructor existed only to carry the build recipe through the derivation value's private `_derivation` attribute, which no longer exists; nothing constructed it any more and no Nix operation could observe one. Exhaustive matches over `NixValue` lose a case. The recipe a build needs is the `.drv` ATerm the evaluator records under its path during evaluation (`Nix.Eval.IO.EvalState`'s `esDrvClosure`, or `lookupSessionDrv` inside the evaluator), which is what the build driver and the test harness now read. (#217) `Nix.Eval` now exports `deferApply`, the shared thunk that applies one value to another when forced; the derivation wrapper's fallback uses it, and library code that built the same synthetic thunk by hand can too. (#230)
- **`Nix.Eval.Types.MonadEval`'s `getEnvVar` takes the bytes of a variable's name and returns the bytes of its value,** `ByteString` both ways where it was `Text`, so every instance defined outside this package changes with it. `Nix.Builtins.parseNixPath`, `splitNixPath` and `isNixPathPseudoUrl` take a `ByteString` and `searchPathRoots` returns them, since a `NIX_PATH` entry is bytes. The new module `Nix.Environment` reads one variable as bytes (`lookupEnvBytes`, answering an `EnvLookup`). Outside Windows the read goes through `unix`'s `System.Posix.Env.ByteString`. (#241)
- **`Nix.Eval.Print` exports `printAmbiguous`, the `ByteString` `Builder` for what `nova-nix eval` prints,** after upstream's printer of the same name. (#259)
- **`Nix.Eval.CList` exports `clistIndex` and `clistDrop`,** a bounds-checked read of one element and the list without its first elements, which shares the source's C array through the C layer's new `nn_list_drop`. (#263)
- **`Nix.DependencyGraph.buildDepGraph` reads each `.drv` in the caller's monad,** `(Monad m) => (StorePath -> m (Either Text Derivation)) -> Derivation -> StorePath -> m (Either Text DepGraph)`, where it took a pure reader. The build driver passes a read in `IO`, which replaces the `unsafePerformIO` read the pure signature forced on it; a pure lookup runs under `Data.Functor.Identity`. (#251)
- **`Nix.Eval.Types.MonadEval` has a new method with no default, `withCallFrame`, and `Nix.Eval.IO.EvalState` a new strict field, `esCallDepth`.** Every instance defined outside this package needs the method; `newEvalState` fills the field with upstream's default ceiling, and a caller with a configured one replaces it. The new module `Nix.Eval.CallDepth` holds the counter type, the default and the entry rule. `Nix.Eval.CBytecode` exports the `OpApp` flag values `appDirect` and `appDeferred`. `Nix.Config.NixConfig` gains `ncMaxCallDepth`, `Nix.Config.applyAssignment` now returns `Either Text NixConfig` because a scalar setting's value can fail to parse, and `Nix.Config.parseUnsignedSetting` is exported. (#222)
- **`Nix.Store.EntryName` decides which NAR entry names and symlink targets a host can hold, replacing `Nix.Store.isSafeNarName`.** `NameRules` (`PosixNames`, `DarwinNames`, `WindowsNames`), `hostNameRules`, and the pure `checkEntryName` and `checkLinkTarget` answer for any platform on any platform; `entryNamePath` and `linkTargetPath` spell an admitted name or target as the running host's `OsPath`, and `shownBytes` renders one in a refusal. `onDiskNameKey` and `caseHackDiskNames` take the `ByteString` names a NAR carries instead of `Text`, `orderLinks` orders `(OsPath, OsPath)` pairs, and `Nix.Store.ExecBit` gains `markExecutableOsPath`. The library's floors rise to directory >= 1.3.8 and filepath >= 1.4.100, the first releases with the `OsPath` APIs it now calls, and the test suite's filepath floor with it. (#84)
- **`Nix.Eval.Types.MonadEval` has three new methods with no defaults,** `evalPolicy`, `checkUri` and `resolveSymlinks`, and `doesPathExist` takes a `PathExistence` first argument (`ExistsAsEntry` or `ExistsAsDirectory`, the two questions `builtins.pathExists` asks); every instance defined outside this package needs them. The new module `Nix.Eval.Policy` holds the policy record (`EvalPolicy`, `unrestrictedPolicy`) and the pure decisions behind the modes (`isAllowedPath`, `isAllowedUri`, `uriAccess` and their messages), and `Nix.Eval.Types` re-exports the record. `Nix.Builtins.builtinEnv` and `builtinEnvWithScope` take an `EvalPolicy` first argument, since the impure constants exist by policy; `Nix.Builtins.searchPathRoots` reads the roots back from a search path, and `isNixPathPseudoUrl` says which entries name a download. `Nix.Eval.IO.EvalState` gains `esPolicy` and `esAllowedPaths`, with `allowEvalPath` to seed the allowed roots. `Nix.Eval` exports `FetchGitArgs (..)` and `gitInputUrl`, the URL form a `fetchGit` input is checked and reported in. `Nix.Config.NixConfig` gains `ncAllowedUris`, `ncRestrictEval` and `ncPureEval`. (#75)
- **`Nix.Eval.Types.mkSyntheticThunk` takes the bytecode index of the code it runs instead of an `Expr`, and every bytecode store opens with five reserved instructions.** A caller compiles once (`Nix.Eval.Compile.compileExpr`) and passes the index, rather than having the code compiled again for each thunk. `Nix.Eval.CBytecode` exports `reservedApply1` and `reservedApply2`, the deferred applications of an env's slots a store starts with, so a fresh store's `cbcOpCount` is 5 and the first instruction compiled lands at index 5. `Nix.Eval.Compile.BcInheritFrom` carries a `BcInheritedName` per name, its symbol and the bc_idx of the select that reads it, where it carried the symbols alone, and the `inherit (from)` encoding stores that select after each symbol. (#269)
- **`Nix.Parser.parseNix` returns `Either SourceError Expr`, and a plain `inherit` is a binding of its own in the AST.** `SourceError` is `SyntaxError ParseError`, the failure it returned before, or `UndefinedVariable Text`, a variable binding found unbound. `parseNix` binds against the root environment's names under the default policy; `parseNixWithScope` binds against the names given, which for evaluation under a policy are `Nix.Builtins.rootScopeNames`, plus a `scopedImport` scope's. `Nix.Expr.Resolve.resolveVars` takes those names and returns `Either Text Expr`; `staticGlobalNames` is now upstream's whole base environment, `impureOnlyGlobalNames` the part `pure-eval` drops, and `undefinedVariableMessage` upstream's wording for the failure. `Nix.Expr.Types.Binding`'s `Inherit (Maybe Expr) [Text]` becomes `Inherit Text Expr`, one per name and holding the variable it copies, and `InheritFrom Expr [Text]`; every match on `Binding` outside this package must cover both. In the bytecode, `Nix.Eval.Compile.BcInherit` carries a symbol and the variable's bc_idx instead of a list of symbols, and `Nix.Eval.CBytecode.bindInheritFrom` is the tag of an `inherit (from)` binding, whose encoding no longer carries a has-from word. (#248)
- **`Nix.Store.Store` has a new strict field, `stGcLease`, `openStore` keys the handle by the directory's canonical spelling, and the store modules grow a garbage-collection surface.** Code that builds a `Store` with the constructor needs the field; `openStore` fills it, and `stDir` is now `canonicalStoreDir` of what was passed. `Nix.Store.GC` is new (`collectGarbage`, which returns `Either` with the refusal of a database keyed under another spelling of the directory, `findRoots`, `addOutLinkRoot`, the pure `reachableFrom`, `referencesMap` and `sweepPlan` with its `SweepPlan`, the abstract `LiveSet` with `withLiveSet` and `isLive`, `GcRoot`, `GcResults`, `gcSummaryLine`, `showFreedBytes`, the roots directory names), as are `Nix.Store.Handle` (`Store`, `openStore`, `closeStore`, `canonicalStoreDir`, `withCollectorLock`, `gcLockFilePath`) and `Nix.Store.Symlink` (`WalkNode`, `classifyWalkNode`, `createSymlinkOfKind`); `Nix.Store` re-exports the first two. `Nix.Store.Lock` adds `LockMode`, `acquireLockFileWith`, `withLockFileWith`, `waitingForLockMessage` and `lockedPathOf`; `Nix.Store.Path` adds `parseStorePathPrefix`, upstream's `toStorePath`; `Nix.Store.DB` adds `queryAllReferences` and the batch `unregisterPathRows`. `deleteStorePathRaw` now takes the collector lock and refuses a path reachable from a root before its referrer check, and `deleteStorePathChecked` is the same delete given a `LiveSet` from `withLiveSet`, for a batch under one lock. (#24)
- **The new module `Nix.Json` holds the JSON value type (`Json`), its rendering as upstream's serializer writes it (`renderJson`, `renderJsonWith`) and a parser that reads JSON as upstream's does (`parseJson`); `formatJsonFloat` moved there from `Nix.Eval.StringInterp`.** The new module `Nix.Derivation.StructuredAttrs` holds the `__json` entry's key, its encoding and decoding, and what a builder makes of it (`withOutputPlaceholders`, `renderAttrsJson`, `renderAttrsShell`, `stringsAttr`). (#36)
- **`Nix.Substituter.httpStatusFailure` is gone, and the transfer layer's retry policy and failure classification live in `Nix.Http`.** `fetchStatusFailure`, which takes the URL and the response's `Status`, replaces it. `Nix.Http` exports `AttemptFailure (..)`, `attemptFailureMessage` and `catchSync`, which `Nix.Substituter` still re-exports; the retry policy (`FetchRetryPolicy (..)`, `defaultFetchRetryPolicy`, `RetryEffects (..)`, `ioRetryEffects`, `retryTransient`, `retryDelayMs`); and the classification (`TransferError (..)` and `statusError`, upstream's verdict on a status, `withTransfer` and `transferBodyReader`, which raise every failure of the connection as an `HttpException`, `transferFailureHandlers`, `fetchStatusFailure`, `fetchExceptionFailure`, which takes an `HttpException` rather than any exception, and `fetchWriteFailure`, for an `IOException` writing a download). `Nix.Substituter.trySubstituteWith` substitutes under a given policy and effects; `trySubstitute` is it at upstream's default, and a build passes its `bcFetchRetry`. (#243)
- **`Nix.Substituter` gains `VerifiedNar`, the only input its strict unpack sink accepts, and stops re-exporting `unpackNarEntry`.** `unpackAndVerify` checked the downloaded bytes against the narinfo's signed NarHash and NarSize before deserializing and writing them, but the bytes had the same `ByteString` type on both sides of the check, so only statement order kept unverified bytes away from the disk writes, and the module's re-export of `unpackNarEntry` offered an unpack that skipped the check altogether. `verifyNar` is now the sole way to obtain a `VerifiedNar` (its constructor is not exported), a module-private sink is the only consumer of one, and `unpackAndVerify` is their composition, so the order is a type the compiler enforces. The wrapper carries the decoded declared digest rather than standing as a bare witness: the registration records its canonical spelling and the on-disk recheck compares against it, where the recheck used to parse the narinfo's NarHash a second time, and the strict recheck now compares a digest exactly as the streaming one does. `unpackNarEntry` stays in `Nix.Store`, which is where its eval-side caller (`builtins.path`, unpacking a NAR this process just serialized) imports it from; the substituter reaches it only through that sink. Upstream has no such type: `LocalStore::addToStore` (local-store.cc at 2.24.9) restores the path through a `TeeSource` over a `HashSink` and compares hash and size after the restore, the order the streaming pipeline mirrors with its cleanup contract; the strict path verifies first, and the wrapper makes that first step unskippable. (#226)
- **`Nix.Expr.Types.StringPart` has a new constructor, `StrEsc`, and `Nix.Eval.StringInterp.stripIndentedChunks` takes a list of the new `StringChunk` type.** An indented-string escape (`'''`, `''$`, `''\n`) was parsed as a constant interpolation, which made it indistinguishable from `${"..."}` by the time indentation was stripped, and upstream strips the two differently. `StrEsc` carries the resolved text as a part of its own, so exhaustive matches over `StringPart` need the case. `StringChunk` (`ChunkLit`, `ChunkEsc`, `ChunkInterp` with its context) replaces the `(Bool, ByteString, StringContext)` triple so an evaluated part keeps its kind, and `concatChunks` joins chunks without stripping, as a double-quoted string does. (#227)
- **The new module `Nix.Eval.Print` exports `printValue`, upstream's value printer, under `PrintInFull` or `PrintForError`.** `Nix.Eval.Operator` exports `evalUpdate`, which takes each operand as an action so the left is checked before the right is evaluated. (#247)
- **`unpackNarEntry`, `newNarUnpackSink`, `caseHackDiskNames`, `onDiskNameKey` and `Nix.Substituter.consumeNarStream` take a `CaseSensitivity` first argument, and `Store` has a new strict field,** `stCaseSensitivity :: CaseSensitivity`. The argument is the destination volume's answer: a caller unpacking into a `Store` passes the field, and one unpacking elsewhere says what kind of volume it writes to instead of inheriting the operating system's default. `Nix.Store.CaseSensitive` exports the type, re-exported from `Nix.Store`, and `probeCaseSensitivity`. (#68)
- **`Nix.Store.ExecBit.serialiseFromPath`, `withNarSource` and `narHashOfPath` take a `NovaCache.NAR.CaseHack` first argument, and `Nix.Store.CaseSensitive` exports `volumeCaseHack` and `processCaseHack`,** re-exported from `Nix.Store`. A store path is read with `volumeCaseHack (stCaseSensitivity store)`, and a source with `processCaseHack`, nova-cache's per-OS default. (#236)
- **`Nix.Store` exports `sinkNarStream` and `NarStreamFailure`, the streaming unpack loop the substituter and the source restore share, and `Nix.Store.ExecBit` exports `withNarSource`.** (#235)
- **Further API changes:** `MonadEval` has a new method with no default, `lookupSessionReferences`. `Nix.Parser.parseNixFile` returns `Either SourceError Expr`. The new module `Nix.Eval.SourceError` holds upstream's messages for a failed read of a source path (`SourceAccess`, `sourceErrorMessage`). `Nix.Eval.Operator` stops exporting `checkedAdd`, `checkedSub` and `checkedMul`. `Nix.Derivation.Platform` gains `I686_Linux` and `I686_Windows`, and `Nix.Builder.BuildConfig` gains `bcFetchRetry`.

### CLI

- **`eval` prints its result as `nix-instantiate --eval` does: a string as its bytes, escaped as upstream escapes it, an attribute name quoted when it is not an identifier, a float to six significant digits, and upstream's markers for an unforced element and a function.** A string is bytes, and the printer decoded it as UTF-8 with replacement before writing it, so `builtins.readFile` of a file holding `a`, the byte 0xFC and `b` printed the three bytes of U+FFFD in the middle, where `nix-instantiate --eval` prints the byte itself and `builtins.stringLength` of the same value is 3. The result now reaches stdout as bytes, with no decode, encoding or newline translation on the way, so on Windows the line ends in LF, as `--aterm` output already did, where it ended in CRLF. The rules are upstream's `printAmbiguous` (print-ambiguous.cc at 2.24.9). A string goes through `printLiteralString` (print.cc): `"` and `\` take a backslash, a newline, carriage return and tab print as `\n`, `\r` and `\t`, and a `$` followed by `{` prints as `\$`, the escape that was missing, so `"\${"` printed as `"${"`, which reads back as an interpolation. An attribute name goes through `printIdentifier`: bare when the lexer reads it back as an identifier and quoted otherwise, so `{ "a b" = 1; "if" = 2; }` prints as written instead of as `{ a b = 1; if = 2; }`. A float is written to a default ostream, so `1.0` prints as `1`, `1.23456789` as `1.23457` and `1234567.0` as `1.23457e+06`, where Haskell's `show` gave `1.0`, `1.23456789` and `1234567.0`. An unforced element prints as `<CODE>` and a function as `<LAMBDA>`, `<PRIMOP>` or `<PRIMOP-APP>`, where nova-nix wrote `<thunk>`, `<lambda>` and `<builtin NAME>`. Every case was compared byte for byte with nix-instantiate 2.33.2. A list or set reached twice under `--strict` still prints in full where upstream writes its `repeated` marker, which needs value identity this printer does not track. (#252)
- **An interrupted command reports `error: interrupted by the user` and exits 1.** A SIGINT reached the CLI's main thread as GHC's `UserInterrupt`, which, uncaught, ended the process by the signal (exit 130) with nothing printed. Upstream's `checkInterrupt` throws `Interrupted("interrupted by the user")`, which `handleExceptions` (libmain/shared.cc at 2.24.9) logs as an error and exits 1 with; the CLI now does the same, and every other asynchronous exception still propagates. (#287)
- **`push --all` selects every valid output path, and a named derivation is refused with the reason.** A store registers its derivations as valid paths like anything else, so `--all` sent them too, and a binary cache serves build outputs only: nova-cache refuses a narinfo for a `.drv` outright, after the path's NAR has already been uploaded. Derivations are recipes a substituter never asks a cache for, so they stay local, and naming one explicitly is refused before anything is uploaded. (#216)
- **`--restrict-eval` and `--pure-eval` on `eval` and `build`, and `restrict-eval`, `pure-eval` and `allowed-uris` in nix.conf and NIX_CONFIG.** Evaluation ran with the process's full ambient authority: any expression could read any file, reach the environment, and fetch from the network. The two flags turn on upstream's modes for one run, and the three settings turn them on for a machine, through the same cascade as `substituters` (a Boolean takes upstream's `true`, `yes`, `1`, `false`, `no`, `0` spellings and any other value is an error; `extra-allowed-uris` appends, and `extra-` on a Boolean names no setting). A flag only turns a mode on, so a machine configured for pure evaluation stays pure whatever the command line says. Under either mode every `--nix-path` and `NIX_PATH` root is allowed, as upstream allows its lookup path, so `--restrict-eval --nix-path nixpkgs=...` evaluates nixpkgs and computes the same `hello.drvPath` as the default mode and as `nix-instantiate --option restrict-eval true`. A root reached through a symlinked directory (macOS's `/tmp` is `/private/tmp`) is allowed under its resolved spelling as well; upstream allows only the given one and refuses the read, a mismatch between its own allow list and its symlink walk rather than a rule. A `channel:`, `flake:` or URL entry names a download this evaluator does not perform and allows nothing; upstream allows such an entry's working-directory spelling only by falling through after a failed download, and a run that cannot fetch should not read more for it. Under `--pure-eval` neither `--nix-path` nor `NIX_PATH` contributes, as upstream drops its lookup path; the bundled `<nix/*>` directory remains, since upstream serves those files from a built-in filesystem and this binary serves them from its data directory, which is why it stays listed in `builtins.nixPath` where upstream shows an empty list. (#75)
- **`nova-nix store gc` collects garbage, `build --out-link PATH` (or `-o PATH`) roots a result, and `store delete` refuses a path a root keeps alive.** `store gc` finds the roots, walks their references, removes every other valid path with its tree, sweeps every store directory entry that is not live (a tree no row describes from a build interrupted before registration, junk, a dead path's lock file) and prints upstream's progress lines on stderr and its summary (`N store paths deleted, X MiB freed`, the `%.2f MiB` of 2.24.9) on stdout; `store gc --print-roots` lists `LINK -> PATH` sorted, each link once, and deletes nothing, as `nix-store --gc --print-roots` does, and like it takes no collector lock, so it does not wait behind a running build. `build --out-link PATH` is nix-build's `-o`: the path is made absolute with `.` and `..` collapsed and its parent directories are created, as upstream's `addPermRoot` and `makeSymlink` do, then `PATH` is created as a symlink to the result and registered as a root, refusing a link inside the store (through `..` as well) or an existing `PATH` that is not a store link (a store link is replaced); the store path prints only once the link exists, so a failed link (Windows without Developer Mode) prints nothing a script would take for success. `build` creates no link unless `--out-link` is given, where nix-build and nix build default to `./result`. `store delete` of a path a root reaches is refused with upstream's words, `Cannot delete path '...' since it is still alive`, and the referrer refusal it already had stays. Upstream refuses a path with an unrooted referrer with the same still-alive line unless that referrer is named in the same invocation, in which case it deletes the named set referrer-first; this delete lists the referrers that stand in the way instead and deletes in argument order, so a chain deletes leaf-first. One invocation resolves every argument first, then runs under one collector lock with one live set, printing upstream's roots line once. The hint names `store gc --print-roots` rather than the `nix-store --query` forms this tool does not have. `--store DIR` is keyed by its canonical spelling (absolute, `.` and `..` collapsed, no trailing separator), as upstream's `absPath` keys a store path, so a store built under one spelling and collected under another is the same store; `store gc` and `store delete` refuse a store whose database holds paths under another spelling of its directory rather than sweep them as dead. (#24)
- **nix.conf `include` and `!include` directives are honored, and the system file and the full XDG cascade are read, in upstream's order.** An include splices the named file in at its line, resolved against the directory of the file being parsed with `.` and `..` collapsed lexically, so a nested include resolves against the nested file and an include from `NIX_CONFIG` must be absolute; `!include` goes on without a file that cannot be read, and `include` of a missing file fails with upstream's own `file '...' included from '...' not found`. The sources are now, weakest first: `$NIX_CONF_DIR/nix.conf` (default `/etc/nix`, an empty variable counting as unset, a relative one refused with upstream's `not an absolute path`, and `.` and `..` in it collapsed, as upstream's settings constructor canonicalizes it); the user files, which are `$NIX_USER_CONF_FILES` when it is set (first listed strongest, an empty value naming no files, empty entries dropped) and otherwise `nix/nix.conf` under the XDG config home and then under each `XDG_CONFIG_DIRS` entry, applied back to front so the config home wins; then `NIX_CONFIG`; then the flags. Every rule was read from `config.cc` and `globals.cc` at 2.24.9 and checked against the running 2.33.2 binary. Two deliberate differences: an `include` whose file exists but cannot be read is an error here (`file '...' included from '...' cannot be read`) where upstream silently drops it (its own TODO questions the swallow), because the only thing `include` promises over `!include` is to fail when the file is unusable, and an include is where a site narrows the substituter or trusted-key set, so dropping it leaves the wider set in force; and an include cycle is refused with a message naming both files where upstream recurses until it crashes. On Windows, where upstream compiles the same `/etc/nix` and `/etc/xdg` literals in and neither names anything, the system file is `%ProgramData%\nix\nix.conf`, the all-users application data directory being the Windows convention for machine-wide application configuration (chosen the way `%APPDATA%` was for the user file, and where the `directory` package maps the system-wide XDG dirs too), with no system file when `%ProgramData%` is unset; `XDG_CONFIG_DIRS` has no default there, since the one directory that answers to it is already read as the system file; `NIX_CONF_DIR` and `XDG_CONFIG_DIRS` are honored when set on every platform; a UNC `NIX_CONF_DIR` keeps its server through the collapse; and `NIX_USER_CONF_FILES` splits on `;` there, since upstream's `:` cannot carry a drive letter. (#199)
- **nix.conf is read as bytes, so an `include` target, `NIX_CONF_DIR` and `NIX_CONFIG` name the files and carry the values upstream's do.** A config file went through a lossy UTF-8 decode and the config variables through base's `lookupEnv` and a conversion to text, so a byte with no UTF-8 reading became U+FFFD, written back as EF BF BD when a file was opened: `include caf\xE9.conf`, a Latin-1 name on Linux, never opened the file it names (a required include failed as not found, an `!include` was dropped), a `NIX_CONF_DIR` holding such a byte named a system file that does not exist, so its settings silently did not apply, and an include beside a user file under such a directory (`NIX_USER_CONF_FILES`, `XDG_CONFIG_HOME`, `XDG_CONFIG_DIRS`) missed in the same way. Upstream keeps each line as bytes and opens the file the target's bytes name (`parseConfigFiles` in `src/libutil/config.cc` at Nix 2.24.9), reading the variables with `getenv`. A source now stays bytes until a setting takes a value from it: the variables are read as bytes, an include target and every config directory reach the open as a host path spelled from those bytes, and a comment or the value of a setting nova-nix does not model is never decoded. A line splits on upstream's separators alone (space, tab, CR, LF): every Unicode space split it before, so `pure-eval = true` followed by a no-break space read as true where upstream refuses the value, and an `allowed-uris` token holding a vertical tab or a form feed became two. A Boolean or integer value is compared as bytes, so such a byte there is upstream's own invalid-value error. Two differences remain. First, a `substituters`, `trusted-public-keys` or `allowed-uris` token with no UTF-8 reading is refused (`setting 'substituters' has invalid value '...': not valid UTF-8`) where upstream keeps the bytes, because each is text everywhere nova-nix uses it (a cache URL, a key matched against a narinfo's signatures, a URI prefix matched against the evaluator's URIs), and the U+FFFD spelling it silently took before names a different cache, key or URI. Second, a message still shows such a byte as U+FFFD where upstream writes the byte itself. On Windows, where a path is UTF-16, a target with no UTF-8 reading names no file, so `include` refuses it and `!include` goes on without it, and a config variable holding an unpaired surrogate is refused, as `NIX_PATH` holding one is. Checked against `nix config show` and `nix-instantiate` 2.33.2, the include case on an HFS+ volume, since APFS refuses such a name. (#244)
- **`NIX_PATH` keeps a `channel:` or `flake:` entry whole and splits an unlisted `scheme://` entry, as upstream's parser does.** A colon stayed inside its entry whenever `//` followed it, so `channel:nixos-24.11` broke into `channel` and `nixos-24.11` while `foo://x` was kept as one entry. Upstream's `EvalSettings::parseNixPath` (`src/libexpr/eval-settings.cc` at Nix 2.24.9) decides at an entry's first colon from the text after the entry's last `=`: `channel` and `flake` keep the entry whole whatever follows, `http`, `https`, `file`, `channel`, `git`, `s3` and `ssh` keep it whole when `//` follows, and the entry then extends through exactly one more colon-free run, so `https://example.com:8080/x` still splits after the host and `channel:channel:x` is `channel:channel` then `x`. The split now follows that rule, matched entry by entry against `builtins.nixPath` from `nix-instantiate` 2.33.2, whose parser is unchanged from 2.24.9. An empty entry is dropped, as upstream's `builtins.nixPath` drops it; it was not inert here, since `<name>` resolved against the filesystem root through it (`NIX_PATH=a::b` made `<etc>` resolve to `/etc`). An entry containing a space is kept whole where upstream's settings round trip would split it, since a Windows path may contain one. The Windows drive rule stays: `C:\x` and `nixpkgs=C:/x` are one entry each, where upstream, which has no drive rule, splits a drive path at its letter. Neither a URL nor a channel entry is fetched yet, so for those entries this changes what `builtins.nixPath` reports, not what `<nixpkgs>` resolves to. (#224)

### Evaluation

- **A derivation with `__structuredAttrs = true` passes its attributes as one JSON object, `__json`, and its drvPath matches upstream's.** Such a derivation was flattened like any other, every attribute coerced to a string, so a nested set or a list of sets failed with `cannot coerce a set to a string`, and nixpkgs packages that set the flag (libxo, sudo, cppitertools, nrr and lutok among them) did not evaluate. Upstream's derivationStrict (the `jsonObject` branch of `derivationStrictInternal`, primops.cc at 2.24.9) converts each attribute with the `printValueAsJSON` behind `builtins.toJSON` and stores the object, rendered by nlohmann's `dump()`, as the single environment entry `__json` beside one entry per output. `args` stays the argument list, `__structuredAttrs` and `__ignoreNulls` stay out of the object, and a null is left out only under `__ignoreNulls`. String context inside the object, a path copied to the store or a derivation's output, becomes the derivation's inputs as it does from the environment. In this mode upstream reads the builder as a string rather than coercing it, reads the system, the output hash fields and each output name as strings without context, and warns that a top-level `allowedReferences`, `allowedRequisites`, `disallowedReferences`, `disallowedRequisites`, `maxSize` or `maxClosureSize` has no effect; nova-nix does the same, with upstream's wording. libxo joins the drvPath parity set, and hand-written derivations covering nested sets, mixed lists, floats, nulls with and without `__ignoreNulls`, a path, a derivation, a placeholder, several outputs and a fixed output match the `.drv` nix-instantiate 2.33.2 writes byte for byte. (#36)
- **`builtins.fromJSON` reads JSON as upstream's nlohmann parser does.** It had a parser of its own, separate from the strict one the builder already uses for a derivation's `__json`, and that parser refused the `\b` and `\f` escapes while accepting what nlohmann refuses: an unterminated string, a trailing comma, a leading zero, a raw control character, a `\u` escape short of four hex digits (read as the letter `u`), a lone surrogate (read as U+FFFD), a number past the double range (read as `null`), and whitespace JSON does not define such as a no-break space. Upstream's `parseJSON` (json-to-value.cc at 2.24.9) hands the string to nlohmann's `sax_parse`, so `builtins.fromJSON` now goes through that same strict parser. The messages for refused input are that parser's, not nlohmann's, which name a line, a column and the last token read. (#276)
- **`builtins.fromJSON` refuses a positive integer past the Nix integer range.** A number above `9223372036854775807` that still fits an unsigned 64-bit value wrapped to a negative int, as Nix 2.24.9 does. Upstream made this an error at 2.25 (`json-to-value.cc`), `unsigned json number 18446744073709551615 outside of Nix integer range`, and `nova-nix` now matches it, as it already did for the integer overflow errors of the same change. A number past 64 bits stays a float, and the unsigned range is unchanged for a builder reading `__json`. (#311)
- **`builtins.stringLength`, `substring`, `unsafeDiscardStringContext`, `unsafeDiscardOutputDependency` and `addDrvOutputDependencies` coerce their string argument as upstream does.** Each refused anything but a string, where upstream calls `coerceToString` at its default arguments (`primops.cc` and `primops/context.cc` at 2.24.9): a path is copied to the store and becomes its store path with context, and a set coerces through `__toString` or `outPath`. A value that cannot be coerced now fails with upstream's `cannot coerce an integer to a string: 1`. 13 of 1,143 sampled top-level attributes on `nixos-unstable` failed on this, nearly all of them .NET packages, and all 13 now produce the store path Hydra built. (#306)
- **`derivation` is upstream's wrapper lambda evaluated as the builtin, so its result has upstream's shape: `outputName`, `all` and `drvAttrs` are present, every output attribute is a complete derivation set, and nothing internal leaks.** The builtin hand-built its result as a set with `drvPath`, `outPath`, `type` and one `{ drvPath, outPath, type }` set per output, plus a private `_derivation` attribute carrying the build recipe. nixpkgs reads what upstream's `src/libexpr/primops/derivation.nix` produces: `lib.customisation.extendDerivation` does `inherit (drv.${outputName}) type outputName`, which failed with `attribute 'outputName' not found` for firefox at the top level and for openjdk, gtk3, systemd and emacs one level down, and the leaked attribute reached `nodePackages.typescript` through an attribute enumeration as a value that cannot be coerced to a string. The source of that wrapper, byte for byte at 2.24.9 minus its documentation comment, is now the value of `derivation` in both the root scope and `builtins`, closed over the three names it reads, and `derivationStrict` returns what upstream's primop returns: `drvPath` with its whole-derivation context and one path per output with that output's context. The laziness is the wrapper's: `derivationStrict` is reached only through `outPath` and `drvPath`, so forcing a derivation to weak head normal form still forces none of its inputs. Upstream's three output-list rules in `derivationStrict` come with it: an output named `drvPath`, a repeated output name and an empty list are refused with upstream's messages. `builtins.functionArgs derivation` is now `{ outputs = true; }`, as upstream's lambda reports. `nix-instantiate` 2.33.2 agrees on every attribute of the result for a two-output derivation, on the hello and dependent drvPath parity cases, and on `nodePackages.typescript`, which now evaluates to upstream's drvPath. (#217)
- **`build` reads the derivation it realizes from the `.drv` the evaluator recorded, and prints the output the value names as its default.** The driver took the recipe from the derivation value's private `_derivation` attribute, which is gone. Every derivation an evaluation computes is recorded under its `.drv` path as the exact bytes that path hashes, for writing the input closure before a build; the root's own recipe now comes from the same record, so what is built is what was hashed. A `.drv` lists its outputs by name, sorted, with no notion of a default, so the output whose path `build` prints is the value's `outputName`, the first of its `outputs`, which is the output upstream's `nix-build` realizes and prints (`nix-build.cc`, `queryOutputName`), with upstream's message for a set that lacks the attribute. `eval --aterm` prints the recorded bytes rather than a re-serialization; the two are identical, and the recorded bytes are the ones whose hash is the path. (#217)
- **A derivation's `name`, `builder` and `system` are read as upstream's `derivationStrict` reads them.** The name is read with `forceStringNoCtx` (primops.cc at 2.24.9): `name = 1` evaluated, coerced to `"1"`, and is now `expected a string but found an integer: 1`, and a name with context is refused with upstream's `the string '...' is not allowed to refer to a store path (such as '...')`, naming the context element upstream lists first; an absent name is `attribute 'name' missing`. A path as the builder or system is copied to the store, as its environment entry already was, so `builder = ./b.sh` puts the store path in the .drv's builder field, where nova-nix wrote the source path and so computed a drvPath upstream does not. An empty builder or system is refused as absent, `required attribute 'builder' missing`, the builder checked first, where nova-nix accepted it. An invalid name reports upstream's `invalid derivation name: name '.' is not valid. Please pass a different 'name'.` and its kin. The same `(such as '...')` now ends the refusal of context in a structured derivation's fields. (#281)
- **A failure inside `derivationStrict` names the attribute and the derivation it came from, in upstream's words and layout.** `cannot coerce a set to a string` arrived with no position and no context, so finding which of a closure's thousands of attributes held the set took a bisection by hand. Upstream processes each attribute of a derivation inside a `catch` that adds `while evaluating attribute 'foo' of derivation 'x'` (derivationStrictInternal, primops.cc at 2.24.9), and so does nova-nix now, for the per-attribute coercion, the structured-attributes serialization and the string fields a structured derivation reads, `system`, `builder`, `outputs` and `args`; the name carries upstream's two lines for it, `__structuredAttrs` and `__ignoreNulls` the lines their `forceBool` adds, and a non-Boolean value for either is refused with upstream's `expected a Boolean` message. A failure several derivations deep names each attribute on the way, outermost first. The report is upstream's `showErrorInfo` layout: `error:` alone on the first line, each line of context after an ellipsis and a blank line, the message on an `error:` line of its own, and every line after the first indented to align with it, a multi-line message included; `build` reports an evaluation failure the same way, where it printed `eval error:`. What it cannot print is what upstream prints with a position: nova-nix tracks none, so the `while calling the 'derivationStrict' builtin` and `whose name attribute is located at` lines and the code excerpts under each line are absent, as is `--show-trace`; every line of context is printed. The lines are context only: a message is unchanged, and a throw stays catchable by `builtins.tryEval`. (#221)
- **A derivation attribute carrying another derivation's `drvPath` brings that `.drv`'s whole closure into the derivation, as upstream's does.** Upstream's `derivationStrict` takes the closure of the `.drv` (`computeFSClosure`, primops.cc at 2.24.9) and adds every path in it to `inputSrcs` and every `.drv` in it to `inputDrvs` with all its outputs; nova-nix added the one `.drv` to `inputDrvs` and nothing to `inputSrcs`, so `derivation { ...; x = "${dep.drvPath}"; }` had a drvPath upstream does not compute. The closure follows each `.drv`'s inputs and the references a `builtins.toFile` text recorded when this evaluation wrote it. (#294)
- **Restricted and pure evaluation gate the filesystem, the environment and the network the way upstream's `restrict-eval` and `pure-eval` do.** Under either mode a read (`readFile`, `import`, `readDir`, `readFileType`, `hashFile`, `builtins.path`, `filterSource`, a path coerced into the store) is refused unless the path lies within an allowed prefix or is an ancestor of one, with upstream's `CanonPath::isAllowed` rule and upstream's message, `access to absolute path '...' is forbidden in restricted mode` (or `in pure evaluation mode (use '--impure' to override)`), which `tryEval` does not catch. The check walks the path through its symlinks prefix by prefix, as 2.24.9's accessor does (`SourceAccessor::resolveSymlinks` through a `FilteringSourceAccessor::maybeLstat` that throws at 2.24.9), so a symlink inside an allowed root that points outside is refused at its target, and the refusal names the first prefix outside the allow list, `/etc` for `/etc/passwd` when nothing under `/etc` is allowed; 2.33.2 names the full path, because its `maybeLstat` answers nothing instead of throwing, and 2.24.9's source is followed. `readFileType` and a symlink read name the path asked for, since upstream checks those without a walk, and refuse a symlink anywhere above the path (`path '...' is a symlink`), as upstream's accessor does beneath its allow list, so the type of a path reached through a symlinked directory is never revealed. An `import` of a directory is checked at the `default.nix` it resolves to as well as at the path named, as upstream's `parseExprFromFile` walks the file it reads after `resolveExprPath` appends the name, so a `default.nix` that is a symlink outside the roots is refused at its target. `pathExists` answers false for a refused path, as upstream turns the refusal into false, and checks a symlink at the final component in place rather than following it, as upstream resolves only the ancestors there, so a link inside the roots exists whatever it points at; a trailing slash demands a directory and follows the link, as it does upstream. The allowed set starts as the search path roots and grows as the evaluation produces store objects, as upstream's `allowPath` grows it: a `toFile` result, a fetched tree, a copied source and a `builtins.path` result are readable afterwards, and the fetchers' scratch directories are the evaluator's own. `getEnv` answers the empty string under either mode (`prim_getEnv`). Under `restrict-eval` a fetch is refused unless `allowed-uris` admits it, with upstream's `isAllowedURI` rule (equal to a prefix, under it at a `/` boundary, or a bare scheme such as `https:`) and message (`access to URI '...' is forbidden in restricted mode`); a path or `file://` URI is checked against the allowed paths instead (`checkURI`). `fetchGit` is checked in the form upstream renders its input, `git+https://host/repo?exportIgnore=1` with the pins in key order, so an `allowed-uris` prefix has to be written for that form, as it does upstream, and a local repository given as a path is `git+file://`. Under `pure-eval` the search path holds only the bundled `<nix/*>` directory and a `<name>` miss says `cannot look up '<name>' in pure evaluation mode (use '--impure' to override)` (still catchable, as the default mode's miss is); `builtins.currentTime` and `builtins.currentSystem` do not exist; `builtins.storePath` is refused (`'builtins.storePath' is not allowed in pure evaluation mode`); `fetchurl` and `fetchTarball` need a `sha256` (`in pure evaluation mode, 'fetchurl' requires a 'sha256' argument`); `fetchGit` needs a `rev` (`in pure evaluation mode, 'fetchGit' will not fetch unlocked input '...'`, 2.24.9's wording where 2.33.2 says `doesn't fetch`, and a rev is what locks a git input at 2.24.9); and `~/x` is refused (`the path '~/x' can not be resolved in pure mode`, upstream's parser message, raised here where the expansion is). Every message was recorded from `nix-instantiate` under `--option restrict-eval true` and `--option pure-eval true` and matched byte for byte on the final error line. Two divergences are deliberate: `builtins.findFile` over an explicit list treats a candidate the policy refuses as absent (2.24.9 probes outside its accessor and hands the path back, an existence oracle; 2.33.2 refuses from there), and the closure of a search path root that is a store path is not allowed with it, since evaluation holds no store database handle. The default is unrestricted, as upstream's is. (#75)
- **A string that spells a relative path is refused wherever a path is coerced from a string, and a search path miss carries upstream's hint.** `builtins.readFile "foo"`, `import "foo"`, `pathExists`, `readDir`, `readFileType`, `hashFile`, `filterSource`, `storePath` and `scopedImport` read a relative string against the working directory (and `import` against the importing file's directory); upstream's `coerceToPath` refuses one with `string 'foo' doesn't represent an absolute path`, observed from `nix-instantiate` 2.33.2 in every mode, and so does nova-nix now. An absolute string is canonicalized like a path value, as upstream's `CanonPath` does. The refusal is also what keeps the access policy sound: a relative string would be checked against one location and opened at another. The miss message for `<name>` is now `file 'name' was not found in the Nix search path (add it using $NIX_PATH or -I)`, upstream's text. (#75)
- **Function calls nested past `max-call-depth` (10000 by default) are refused with upstream's "stack overflow; max-call-depth exceeded".** nova-nix had no ceiling: the counter recursion `f 100000`, which upstream rejects, evaluated here to 100000, and ten million frames evaluated in 12 s at 1.8 GB. The counter is evaluator state, carried per frame by each evaluator (the pure one enforces it too), and it counts what upstream's `EvalState::callFunction` counts (eval.cc at 2.24.9): every application of a lambda, of a builtin (a partial application included) and of a functor (two frames, its inner call being a `callFunction` of its own), a `__toString` coercion, and the operators upstream's parser desugars into builtin calls, `-`, `*`, `/`, `<`, `<=`, `>`, `>=` and unary `-` (parser.y at 2.24.9). The applications builtins build (`map`, `genList`, `mapAttrs`, `zipAttrsWith`) are upstream's app values: applied where their result is forced, with the frame opening before the function is forced, so a `mapAttrs` or `zipAttrsWith` callback's name application runs a frame below its value application, as upstream's two nested app values do; `builtins.sort` with `builtins.lessThan` itself as the comparator opens no frame per comparison, upstream's `prim_sort` bypass. The check runs on entry against the frames already active, so with the default ceiling 10001 nested lambda calls pass and 10002 fail, and the counter recursion passes at 9999 and fails at 10000, its deepest frame being the `n - 1` forced inside `f 0`; every boundary was confirmed against nix-instantiate 2.33.2. The error is an eval error that `tryEval` does not catch, as upstream's `EvalError` is. The ceiling reads from nix.conf and NIX_CONFIG through the cascade `substituters` uses, a plain assignment replacing and `extra-max-call-depth` being an unknown name since upstream accepts `extra-` on list settings only, with upstream's integer grammar: decimal digits, an optional leading `+`, and an optional `K`, `M`, `G` or `T` binary unit suffix in either case; anything else, or a value past `unsigned int`, is refused as `setting 'max-call-depth' has invalid value '...'`. One divergence: upstream narrows a unit product silently, so `5G` becomes 1073741824 there, while here it is refused like any other value outside the range. (#222)
- **A variable that no enclosing scope binds and no enclosing `with` could supply fails the expression before anything is evaluated, with upstream's `undefined variable 'y'`.** It was an error only where evaluation reached it, so `let x = y; in 1` and `if true then 1 else y` evaluated to 1, and `builtins.tryEval (let x = y; in 1)` to `{ success = true; value = 1; }`, three expressions nix-instantiate 2.24.9 and 2.33.2 both reject. Upstream binds every variable when it parses a source (`ExprVar::bindVars`, nixexpr.cc at 2.24.9) and raises `UndefinedVarError` there unless a `with` encloses the name, an error `tryEval` does not catch. Resolution here now does the same, once per source: the expression or file given on the command line, every `import` when it is forced, so a file with an unbound variable on a branch its importer never takes fails at the import, and `scopedImport`, whose scope's names are bound around the file as upstream binds them, so a `with` in that file no longer shadows them. A name under a `with` still waits for evaluation (`with {}; let x = y; in 1` is 1, and `with {}; y` fails when evaluated), `inherit x;` is checked like any other variable, and `inherit (s) x;` checks only `s`, selecting `x` when it is forced. With several unbound variables, upstream reports the first in its own binding order, which follows its symbol table, and nova-nix the first in the source. `__curPos`, which upstream's parser reads as a position rather than a variable, is exempt from the check and still fails only when forced, since nova-nix does not evaluate it yet. nix-instantiate 2.33.2 and nova-nix both bind every one of the 36860 `.nix` files in the nixpkgs 24.11 revision the parity job pins. (#248)
- **The root scope binds every name upstream's base environment does: `__typeOf` and the other `__`-prefixed builtins, and `fetchMercurial`.** Upstream installs each primop and constant under the name it registers and under that name less `__` in `builtins` (`addPrimOp` and `addConstant`, eval.cc at 2.24.9), so `__typeOf 1` is `"int"` there and was an undefined variable here, and with binding checked before evaluation `if false then __typeOf 1 else 2` would have been refused where upstream gives 2. The root scope is now built from the 114 names `createBaseEnv` and the default-enabled `RegisterPrimOp`s produce, each holding the value `builtins` holds under its unprefixed name. Under `pure-eval` it drops `__currentTime` and `__currentSystem`, as upstream drops its `impureOnly` constants, so binding rejects them there. `fetchMercurial` is the one name nova-nix does not implement: it is bound, so binding accepts it, and calling it fails with `unknown builtin 'fetchMercurial'`; `builtins` still does not list it. (#248)
- **An inherited value is evaluated in the scope around the binding set, not in the set itself.** `rec { ${"q"} = 0; x = 1; y = rec { ${"a"} = 1; inherit x; }; }.y.x` recursed forever, because the inner set looked `x` up by name from its own scope and found the attribute it was defining; upstream gives 1, evaluating an Inherited attribute in the enclosing environment (`ExprAttrs::eval` and `ExprLet::eval`, eval.cc at 2.24.9), and so does nova-nix now. A plain `inherit` carries its variable, bound like any other, so the evaluator has no name lookup of its own for one, and an inherit clause that repeats a name is refused when parsed with `attribute 'x' already defined`, as upstream's per-name check refuses it. (#248)
- **Indented strings strip exactly what upstream strips: whitespace-only lines, the opener's own line, and spaces but never tabs.** The stripping was approximate in four ways. A string with no measured line (blank lines and spaces only) kept every space, because the minimum indent started at zero when nothing contributed; upstream starts it at a sentinel, so such a string loses every leading space, and `''` followed by a blank line and an indented closer is `"\n"`, not the eight spaces of the closer. A tab counted as indentation and was stripped; upstream measures only spaces, so a tab fixes the minimum at its column and survives into the value. The closing `''`'s own indentation survived as a trailing line of spaces; upstream removes a final line of spaces from the last part after stripping. And the opener's trailing spaces survived as a line of their own, because only a bare newline after `''` was dropped; upstream's lexer consumes `''`, any spaces and one newline as the opener, so content starts on the next line. The same transcription corrects how escapes meet stripping: upstream's scan skips an escape (it ends a line's leading whitespace without being measured) while its strip runs over escapes as over any literal, so the spaces after an escaped newline are stripped but never measured, and `''  a''\n  b''` is `"a\nb"` where it was `"a\n  b"`. Every rule is `ParserState::stripIndentation` and the `IND_STRING_OPEN` lexer rule at Nix 2.24.9, unchanged through 2.33.2, and every case was checked against nix-instantiate 2.33.2. nixpkgs concatenates `+ ''`, a blank line, `''` into shell hooks constantly, and clang's drvPath differed from upstream's for that reason alone. (#220)
- **A builtin that takes a path coerces its argument as upstream's `coerceToPath` does.** `readFile`, `readDir`, `pathExists`, `hashFile`, `readFileType`, `import`, `scopedImport`, `builtins.path`, `filterSource`, `toPath` and `storePath` each refused anything but a path or a string in a message of nova-nix's own; upstream runs the argument through `coerceToPath` (eval.cc at 2.24.9), which takes a path as itself, follows a set's `__toString`, and coerces anything else to a string that must be absolute. So `builtins.readFile { outPath = "/etc/hosts"; }` reads the file, and `builtins.readFile 1` is `cannot coerce an integer to a string: 1`. `builtins.path { path = 1; }` read the relative path `1`, and `builtins.toPath` gave a path where upstream gives the coerced path as a string with its context, which interpolation does not copy. `baseNameOf` and `dirOf` coerce what is not a path as upstream's `coerceToString` does, so an `outPath` or a `__toString` answers, and `dirOf` of a set gives a string. (#283)
- **`builtins.pathExists` checks the final symlink in place and demands a directory for a trailing slash, `builtins.readFileType` refuses a symlink above its path, and `builtins.path`, `filterSource` and path coercion resolve a symlink root.** Upstream's `prim_pathExists` (2.24.9) resolves a plain argument through its ancestors only and lstats the final component, so a dangling symlink exists, and resolves a string ending in `/` or `/.` fully and requires a directory, so `builtins.pathExists "/etc/passwd/"` is `false`; nova-nix followed the final link (a dangling one read as absent) and erased the trailing slash in canonicalization. `readFileType` lstats its argument without resolution, and upstream's accessor refuses a symlink anywhere in the parent chain with `path '...' is a symlink` (`PosixSourceAccessor::assertNoSymlinks`) in every mode, so `builtins.readFileType /tmp/x` on macOS, where `/tmp` is a symlink, fails exactly as it does under `nix-instantiate`; nova-nix answered the target's type. `builtins.path`, `builtins.filterSource` and a path coerced into the store now resolve their root through its symlinks before reading the tree, as upstream's `addPath` and `copyPathToStore` do, so a root that is a symlink stores what it points at under the root's own name and lands at upstream's store path; nova-nix tried to store a symlink. (#75)
- **Interpolating a path that is a symlink copies what it points to.** `"${./link}"` stored the link itself, where upstream's `copyPathToStore` stores `path.resolveSymlinks()` under the path's own name (eval.cc at 2.24.9). The store path therefore differed from upstream's, and so did the drvPath of every derivation that interpolates such a path. (#279)
- **A list coerces to a string as upstream joins it.** `builtins.toString [ [ ] 1 ]` was `" 1"` and is now `"1"`: upstream's `coerceToString` (eval.cc at 2.24.9) puts a space after every element but the last, except after one that is itself an empty list, where nova-nix joined every element with a space. A list now coerces wherever upstream's permissive coercion runs, so `toString { __toString = s: [ 1 2 ]; }` and `toString { outPath = [ 1 2 ]; }` are `"1 2"`, where nova-nix refused the list, and a derivation attribute or argument holding a list joins the same way. Interpolation still refuses a list, as upstream's does. (#282)
- **`builtins.storePath` follows a symlink into the store and refuses with upstream's messages.** Upstream's `prim_storePath` resolves a path's symlinks unless the path is itself a store path (primops.cc at 2.24.9), so a link outside the store pointing into it names the store path it reaches, where nova-nix refused it. A path outside the store is `path '/x' is not in the Nix store`, naming the resolved path, and a component under the store that is no store path is the `StorePath` constructor's refusal (path.cc), `'x' is too short to be a valid store path` or `store path '...' contains illegal base-32 character 'E'`, where nova-nix said `builtins.storePath: not a valid store path` for all of them. (#293)
- **`builtins.toXML` writes upstream's document and keeps the contexts of the strings in it, so a NixOS toplevel evaluates to upstream's drvPath.** The writer was an approximation of `printValueAsXML` (value-to-xml.cc and xml-writer.cc at 2.24.9) in five ways. It indented by list and set nesting rather than by element: the value under `<expr>` started at column 0 and an `<attr>`'s value sat at the attribute's own indent, where upstream's `XMLWriter` indents every element two spaces per element open around it. A newline in an attribute value was written raw; upstream writes `&#xA;`, since an XML parser normalizes a literal one to a space. Every function was `<function />`; upstream writes a lambda's argument pattern, `<varpat name="x" />` or an `<attrspat>` listing the formals in name order with the `@` binding and the ellipsis as attributes, and a primop as `<unevaluated />`. A derivation was written as a plain set, and since its `all` and output attributes refer back to it the writer never finished; upstream writes a `<derivation drvPath=... outPath=...>` element for it, holding its attributes the first time its drvPath appears and only `<repeated />` every time after. And the result was a plain string, where `prim_toXML` gives it the contexts of the strings it wrote. NixOS writes GRUB's installer configuration with `pkgs.writeText "grub-config.xml" (builtins.toXML ...)`, so the configuration's text and its missing inputs made the toplevel's drvPath differ from upstream's. Every case was checked against nix-instantiate 2.24.9 and 2.33.2, and the toplevel joins the parity case set. (#221)
- **`builtins.toJSON` escapes backspace and form feed as `\b` and `\f`, as upstream does.** It wrote them as `\u0008` and `\u000c`. Upstream serializes through nlohmann's `dump()`, whose escaper has two-character escapes for the quote, the backslash, backspace, form feed, newline, carriage return and tab, and `\u00xx` only for the other control characters. The same rendering now writes a structured derivation's `__json`, whose bytes are hashed into its store path. (#36)
- **`builtins.toJSON` converts a set with `outPath` through the `outPath` value itself, and a set with `__toString` through upstream's coercion of the result.** Either kind of set was coerced to a string with the derivation-environment rules, so `{ outPath = { a = 1; }; }` and `{ outPath = [ 1 2 ]; }` failed with `cannot coerce`, `{ outPath = 1; }` and `{ __toString = s: 1; }` became the string `"1"`, and `{ __toString = s: ./x; }` copied `x` to the store. Upstream's `printValueAsJSON` (value-to-json.cc at 2.24.9, unchanged in 2.33.2) converts the `outPath` value as it converts any value, so those become `{"a":1}`, `[1,2]` and `1`, and a path there is still copied to the store with its context. It coerces a `__toString` result through `tryAttrsToString` with `coerceMore` and `copyToStore` both off, so an integer is an error, a path is its own absolute text with no store copy and no context, and a string keeps its context. A structured derivation's `__json` is built by the same conversion, so its attributes follow the same rules. Every case was compared with nix-instantiate 2.33.2. (#36)
- **An environment value's bytes reach the evaluator unchanged, as upstream's do.** `builtins.getEnv` and `builtins.nixPath` read a variable through base's `lookupEnv`, which decodes it with the file-system encoding and carries a byte with no UTF-8 reading as a lone surrogate, and the conversion to text then replaced that with U+FFFD: `NIX_PATH` holding the byte 0xFC followed by `=/x` gave a three-byte prefix (EF BF BD) where `nix-instantiate` 2.33.2 gives the one byte. Upstream reads a variable with `getenv` and keeps its bytes (`getEnv` in `src/libutil/environment-variables.cc` at Nix 2.24.9). On POSIX the evaluator now makes the same call through the unix package, for the name as well as the value, so `builtins.getEnv` of a name that is not valid UTF-8 looks that variable up instead of failing. `NIX_PATH` is split and parsed as bytes, as upstream's parser is, and a search path prefix is compared byte for byte (`suffixIfPotentialMatch`), so an entry whose prefix has no UTF-8 reading no longer stops a `<name>` lookup from reaching the entries after it. A matching entry whose path has no UTF-8 reading is an error, since path values are text here, and such a root adds nothing to the restricted-mode allow list instead of a lossy spelling of itself. A Windows environment is UTF-16: a value becomes its UTF-8 bytes, and one holding an unpaired surrogate, which has no UTF-8 form, is refused rather than approximated (`builtins.getEnv` raises an evaluation error, a `NIX_PATH` fails setting up the evaluation), the rule the NAR serializer applies to such a file name. The read also stops catching every exception as "unset", which turned an interrupt arriving during the read into an empty value. (#253)
- **`-`, `*`, `/`, the arithmetic and bitwise builtins and the comparisons fail with upstream's messages.** Each is an upstream primop (`prim_sub`, `prim_mul`, `prim_div`, `prim_add`, `prim_bitAnd` and its kin, `prim_lessThan`, primops.cc at 2.24.9) that checks its operands with `forceInt`, or with `forceFloat` once either is a float. So `1 - {}` reports `expected an integer but found a set: { }` where nova-nix said `cannot apply subtraction to an integer and a set`, `1.0 - {}` reports `expected a float but found a set: { }`, and `builtins.bitAnd 1 {}` names the value it refuses where nova-nix said `builtins.bitAnd: expected two integers`. Division looks at its divisor first, as upstream's does: `1 / {}` fails the divisor's `forceFloat`, and a zero divisor is `division by zero` before the dividend is checked, so `{} / 0` is a division by zero, for `/` and `builtins.div` alike (nova-nix prefixed the builtin's message). Comparing two types is `cannot compare an integer with a set`, and comparing a type with no order is `cannot compare a Boolean with a Boolean; values of that type are incomparable`, where nova-nix wrote `cannot compare an integer and a set` and `cannot compare a Boolean and a Boolean`; a lambda and a primop are one type there, upstream's `nFunction`. From 2.25 upstream appends both values to a comparison's message, and 2.24.9's wording is kept. (#278)
- **`+` and string coercion fail with upstream's messages, and a message that names a type names it as upstream's `showType` does.** `1 + "a"` reported `cannot coerce an integer to a string with the + operator` and now reports `cannot add a string to an integer`, and `"a" + 1`, `"${1}"` and `builtins.toString {}` report `cannot coerce an integer to a string: 1` and `cannot coerce a set to a string: { }`, the messages of upstream's `ExprConcatStrings::eval` and `coerceToString` (eval.cc at 2.24.9). The left operand of `+` is dealt with before the right is evaluated, and its type alone decides the operation, as upstream's loop decides it: an integer or float makes `+` arithmetic and refuses a right operand that is not a number, and anything else makes it a strict concatenation. So `true + 1` fails to coerce the Boolean instead of failing to add it, and `{} + throw "b"` fails on the set without evaluating the throw, an error `builtins.tryEval` does not catch. Upstream copies a path into the store only when the left operand is a string, so `{ outPath = ./src; } + "x"` is now the path's own text with no context, where nova-nix copied the source. A store-path context appended to a path reports upstream's `a string that refers to a store path cannot be appended to a path`, and an interpolated path literal looks for one only once every piece has coerced, as upstream does, so a later piece that fails to coerce is the error reported. A string with context is `a string with context`, a primop `the built-in function 'map'` and a partial application of one `the partially applied built-in function 'map'`, where nova-nix said `a string` and `a built-in function`, in every type error and in a selection path's error alike. (#250)
- **The value a type error shows, and the one `builtins.trace` prints, is a set's attributes and a list's items as upstream's `printValue` prints them (print.cc at 2.24.9), where nova-nix printed `<<a set>>` and `<<a list>>`.** Attributes print by name, and in an error with `_type` and `type` first, as upstream sorts them whenever the print is bounded. An error's print shows at most ten attributes and ten items over the whole value and ten levels of nesting, and counts what it leaves out, the bounds of upstream's `errorPrintOptions` (print-options.hh). Nothing is forced: an entry not yet evaluated prints as upstream's thunk marker, a primop as its primop marker, and a thunk being forced as upstream's marker for a possible infinite recursion. Which entries are thunks follows nova-nix's evaluator, which agrees with upstream's for a literal written in the set or list, a function's argument and anything already forced, and not for an indented string, a `~/` path, a variable bound to a literal by `let` or `rec`, or a global such as `map`, all of which upstream hands over as values. A function value carries neither its name nor its source position, so a lambda's marker is the bare word where upstream adds both, and a set or list reached twice prints again where upstream marks it repeated. (#250)
- **Integer overflow and negation fail as upstream does.** An overflowing `+`, `-` or `*` reports upstream's `integer overflow in adding 9223372036854775807 + 1`; upstream checks these from 2.25, and 2.24.9 leaves them to undefined C++ arithmetic. Dividing the smallest integer by -1, which 2.24.9 does check, reports its `overflow in integer division`, where nova-nix wrote `integer overflow in dividing a and b`. Unary minus is upstream's `__sub 0 e` (parser.y at 2.24.9) in every respect, so `-(0.0)` is positive zero and `builtins.toJSON (-0.0)` is `"0.0"`, and `-{ }` fails with `expected an integer but found a set: { }`. (#250)
- **A failed read of a source path reports upstream's message, not GHC's.** `builtins.readFile ./nope` reported `/d/nope: withBinaryFile: does not exist (No such file or directory)` and now reports `opening file '/d/nope': No such file or directory`, the wording of upstream's `PosixSourceAccessor` (posix-source-accessor.cc at 2.24.9), which names the access and the path and appends the system error. An absent path under an lstat (`readFileType`, interpolation, `builtins.path`, `filterSource`, the target of an `import`) is `path '/d/nope' does not exist`, as `SourceAccessor::lstat` has it, and a path reached through a file counts as absent there; a file is opened with `opening file`, a directory read as a file is `reading from file '/d/dir': Is a directory`, and a directory is listed with `reading directory`. Each names the path as upstream does: resolved through its symlinks for a read, `readDir` and a copy, as given for `readFileType`. A failure inside the walk of a copied directory still carries GHC's text. (#277)
- **A non-set operand of `//` and a non-function passed to `builtins.functionArgs` fail with upstream's messages.** `null // {}` reported `cannot merge null and a set` and now reports `expected a set but found null: null`, the message of `evalAttrs`, which upstream's `ExprOpUpdate::eval` runs on the left operand and then the right (eval.cc at 2.24.9). A non-set left therefore fails before the right is evaluated, so `builtins.tryEval (null // throw "b")` raises the type error instead of catching the throw. 2.33.2 checks the right operand first; 2.24.9 is followed. `builtins.functionArgs 1` reports upstream's `'functionArgs' requires a function` (`prim_functionArgs`, primops.cc). The offending value is printed as upstream's `printValue` prints it, and `builtins.trace` shares that printer, so a float it prints has upstream's six significant digits: `trace: 1.23457e+06`, where it printed `trace: 1234567`. (#245)
- **`builtins.warn` refuses a message that is not a string, as upstream does.** It printed any value as the warning; upstream's `prim_warn` forces the message with `forceString` (primops.cc at 2.24.9), so `builtins.warn 1 2` fails with `expected a string but found an integer: 1`, which `builtins.tryEval` does not catch. Upstream keeps the argument a string to leave room for extending it; `builtins.trace` remains the way to print any value. (#249)
- **`builtins.deepSeq`, `builtins.trace` and `builtins.warn` finish with their first argument before they force their second.** Upstream's `prim_deepSeq` forces the first argument deeply, `prim_trace` prints it and `prim_warn` warns with it, and only then does each force the second (primops.cc at 2.24.9). nova-nix forced both arguments before the builtin ran, so `builtins.deepSeq { a = throw "a"; } (throw "b")` failed with `b` where upstream fails with `a`, `builtins.trace "hi" (throw "b")` failed without printing the trace, and a value printed inside `deepSeq`'s second argument still showed unforced thunks. `builtins.warn`'s line now carries upstream's `evaluation warning:` prefix, where nova-nix wrote `warning:`. (#285)
- **`builtins.head` and `builtins.tail` no longer copy the list, and `head`, `tail`, `elemAt` and `length` fail with upstream's messages.** `head` built a Haskell list of every element to return the first, and `tail` built one to copy all but the first into a new C list. `head` now indexes the C array as `elemAt` does, and `tail` returns a new header over the same array, which is safe because a list is never written after construction; upstream's `prim_tail` copies (primops.cc). A copy per call made a chain of tails quadratic in time and in arena memory, which is freed only when evaluation ends: folding `tail` 9999 times over a 10000-element list took 0.72 s and 449 MB and now takes 0.01 s and 33 MB, and `head` of an 800000-element list takes 0.13 s, as `elemAt` does, where it took 0.20 s. The messages are 2.24.9's. An index outside the list fails with `list index 5 is out of bounds`, which `head` of an empty list reports too as `list index 0 is out of bounds`, since upstream's `prim_head` is its `elemAt` at index 0; `tail` of an empty list fails with `'tail' called on an empty list`, a non-list with `forceList`'s `expected a list but found an integer: 1`, and a non-integer index with `expected an integer but found a string: "x"`, checked before the list because upstream forces the index first. 2.26.0 reworded the empty-list and bounds messages, so nix-instantiate 2.33.2 prints `'builtins.head' called on an empty list` and `'builtins.elemAt' called with index 5 on a list of size 2`. (#48)
- **`map`, `genList`, `concatMap`, `mapAttrs` and `zipAttrsWith` no longer compile code for every element they make, and an `inherit (from)` no longer compiles its selects each time it is evaluated.** Each element these builtins make is a deferred application, and its three or five instructions were compiled afresh per element into the bytecode store, which is freed only when evaluation ends, so the store grew with every element mapped; an `inherit (from)` likewise compiled the select of each name it inherits every time its binding ran. Every store now opens with that application code at fixed indices, and each element runs it in an env of its own holding the function and its arguments, as upstream builds an app value per element (`mkApp` in primops.cc at 2.24.9); `genList` passes the index as an argument value, as `prim_genList` allocates an int per element. The compiler makes each inherited name's select once, with its binding, as upstream's parser makes an `ExprSelect` per name (parser.y at 2.24.9). Evaluating `hello.drvPath` on the pinned nixpkgs 24.11 compiles 102800 instructions where it compiled 862288 and peaks at 116 MB resident where it peaked at 127 MB, and mapping over 100000 elements compiles 79 instructions where it compiled 600074. `map` and `concatMap` also share one thunk for the function across their elements, where they allocated one per element. A `genList` that is built and never forced costs more than it did, since an env and an int per element replace three instructions: building a million elements takes 0.27 s of user time and 260 MB, against 0.19 s and 226 MB. (#48)
- **Store paths, derivations and string literals evaluate with less work.** A store path's hash was folded to its 20 bytes through a fresh 20-element list for every byte of the digest; it is now XOR'd together a chunk at a time, the same bytes upstream's `compressHash` produces by folding byte `i` into position `i mod 20` (hash.cc). A derivation's `.drv` text was rendered twice, once to hash for its path and once to record for the build, where upstream's `writeDerivation` hashes and writes one rendering; it is rendered once. A string with no interpolation went through the general path an interpolated string takes; it is now its bytes, as upstream's parser makes it an `ExprString` holding its value (parser.y). `hello.drvPath` allocates 2082 MB where it allocated 2203 MB and takes 0.90 s of user time where it took 0.96 s, 2000 derivations allocate 292 MB where they allocated 623 MB, and a million evaluations of `if a == "a" then "b" else "a"` take 0.65 s where they took 0.92 s. (#48)
- **Reaching the C data layer outside the `arenaInit` .. `arenaDestroy` window raises `ArenaNotInitialized` instead of crashing the process.** Evaluating anything before `Nix.Eval.Arena.arenaInit`, or after `arenaDestroy`, was a SIGSEGV, so a library user who left the bracket out could not tell a missing setup step from memory corruption. Each C sub-arena's one allocation funnel now reports failure through the sentinel its callers already check (NULL, `UINT32_MAX`, `NN_SYMBOL_INVALID`) instead of dereferencing state it does not have, and the Haskell boundary asks `nn_arena_live` on that failure path and raises the new `Nix.Eval.CStatus.CStatusError`: `ArenaNotInitialized` naming the C entry point that refused, or `CAllocationFailed` when the arena is live and the allocation itself failed. The question is asked only after a failure, so evaluation pays one sentinel comparison per allocation and nothing more, and `hello.drvPath` evaluates in the same time. Upstream guards the same boundary with `assert(gcInitialised)` in the `EvalState` constructor, an abort rather than an error a caller can catch, which is the one place this diverges on purpose. `checkedCPtr` moves to `Nix.Eval.CStatus` and runs in `IO`, so the arena is asked at the moment the call failed rather than when a lazy message is rendered; the bytecode emitters raise `CStatusError` where they raised an `IOError`; and the thunk allocators, slot arrays and list construction now check their C result, where a NULL previously flowed on to the next dereference or became a silently empty list. Parsing needs no arena. `ArenaNotInitialized` is a setup diagnosis rather than an error to catch and retry in the same process: GHC updates a thunk whose evaluation raised with that exception, so a shared value whose evaluation reached C before the window re-raises it inside a later, live arena. (#206)
- **The C data layer reports a failed or oversized allocation as `CAllocationFailed` instead of aborting the process, and its 32-bit counts no longer wrap.** The symbol table, the attribute set's insert, union and key removal, and the bookkeeping behind lists, closures and context strings called `abort()` when an allocation failed, so evaluation ended with SIGABRT and no way for a caller to recover. Each now refuses through its status, the Haskell boundary raises `Nix.Eval.CStatus.CAllocationFailed` naming the C entry point, and `builtins.tryEval` does not catch it. That is upstream's shape: its collector turns exhaustion into `std::bad_alloc` (eval-gc.cc at 2.24.9), which `tryEval` lets through and the CLI reports as an error. Upstream's symbol table still aborts at its own `uint32_t` ceiling (`ChunkedVector::addChunk`); refusing there instead is deliberate. The 32-bit arithmetic is now checked where it wrapped. An attribute set holds at most 4294967295 entries, the ceiling upstream's `Bindings` has, and its doubling and union sizes are computed in 64 bits; a key past index 2^31 was reported absent and is now found. A string of 4 GiB or more is refused where its length was truncated to 32 bits, so that it read back as only its first bytes, and a list of 2^32 or more elements is refused where its count wrapped to a shorter allocation. `nn_list_new` and `nn_list_drop` report failure through a status of their own, since the NULL they returned also meant the empty list. A thunk whose force fails while its value is being stored is left re-forceable, and a refused thunk state transition raises the new `CInvariantViolated` instead of being ignored. The `null`, `true` and `false` thunks are shared static cells, as upstream shares `vNull`, `vTrue` and `vFalse`: they need no arena, so forcing one before `arenaInit` no longer raises `ArenaNotInitialized` for the rest of the process, and one kept across `arenaDestroy` no longer points into freed memory. Re-initializing the symbol table frees the previous one. (#19)

### Store

- **The store has garbage-collector roots, a reachability walk, a sweep, and a store-wide lock that keeps a collection and a writer apart.** Roots live under the store's own metadata directory, `.nova-nix/gcroots`, since this store has no state directory apart from that one and a store copied as one directory then keeps its roots. Both of upstream's permanent-root forms are read: a symlink whose target lies in the store, which roots the store path the target lies in (the path itself, a trailing-separator spelling of it, or anything beneath it, upstream's `toStorePath`), and a regular file named `<hash>-<name>`, which is the form that needs no symlink privilege. An out-link's indirect root is a regular file under `gcroots/auto`, named by a digest of the link's absolute path and holding that path, where upstream's is a symlink to the link; the record has to be writable on every platform the store runs on, and a symlink on Windows needs Developer Mode. The record is published by rename, so a listing under the shared lease never reads a torn one. A record whose out-link is gone is removed on the next walk with upstream's `removing stale link` line; a symlink root naming a path the database does not hold is skipped with its `skipping invalid root` line, a regular-file root naming one is dropped silently, and an entry the walk cannot read is reported with its `cannot read potential root` line and skipped, each as upstream does. The walk is pure over the references map; dead rows go in one transaction, because the outputs of one derivation reference each other both ways and no order of one-row deletes gets through a cycle; trees go one by one after the rows, so an interruption leaves inert unregistered trees rather than rows describing nothing. What the sweep removes is every store directory entry that is not live, as upstream's sweep removes it: a dead path's tree, a store-shaped tree no row describes, junk, and a dead path's lock file. A live path's lock file stays: upstream's holders delete their lock files on release, so its sweep reclaims crash debris, while here a holder never deletes and the live path's lock file is its normal state. A dead row with no tree is unregistered without a line or a count, since upstream's sweep walks the directory and never meets one, and what is announced and counted is what upstream announces and counts. The store directory is keyed by its canonical spelling from the moment it is opened (absolute, `.` and `..` collapsed, no trailing separator, symlinks unresolved, as upstream's `absPath` keys a `--store` path), so one directory named two ways keys one set of rows; a database holding a path under another spelling of the directory would compare as dead throughout, so the collector and the checked delete refuse it rather than sweep a rooted closure over a text mismatch. Upstream serializes collection against writers with a shared-or-exclusive `gc.lock` plus a socket; nova-nix keeps the lock and drops the socket: every open store handle holds `.nova-nix/gc.lock` shared from open to close, and a collection (or a checked delete) trades the handle's lease for the exclusive lock, so it runs only while no other process has the store open. An opener arriving meanwhile waits, announced with upstream's `waiting for the big garbage collector lock...`; upstream's own opener never waits there, since it hands its roots to the running collector through the socket, so the collector's line is the one that names the wait. A handle whose lease cannot be renewed after a collection keeps a surrendered lease rather than none, so it still closes. Listing the roots needs only the handle's shared lease, as upstream's listing takes no collector lock. The per-path locks the builder and substituter hold were not enough on their own: they cover a path while it is being produced, not the window between a dependency's registration and the build that consumes it, nor the writers that register without one (the `.drv` closure, the eval-time store writes). Not implemented, and stated in the module: runtime roots (upstream's `/proc` scan on Linux and `lsof` elsewhere), temporary roots, `keep-derivations` and `keep-outputs` (the builder records no deriver, so an unrooted `.drv` and the sources only it references are collected), and the `.links` optimization sweep. (#24)
- **The store probes whether its volume folds case when it opens, and case-hacks sibling names only where it does.** Whether two NAR entries differing only by case can share a directory was decided at compile time by operating system, so a store on a case-sensitive APFS volume still had `makefile` written beside `Makefile` as `makefile~nix~case~hack~1`. The store now asks the volume (`pathconf(_PC_CASE_SENSITIVE)` on macOS, the Win32 default on Windows, inherent on Linux) and carries the answer in the new `Store` field `stCaseSensitivity`; every NAR materialization keys names by it, and a volume that does not fold gets its true names. Upstream's `use-case-hack` is a per-process setting defaulting to on for Darwin (archive.cc at 2.24.9), so upstream case-hacks on a case-sensitive macOS volume as well; that spelling is a workaround for folding, and a volume that does not fold needs none, so this is a deliberate divergence in on-disk spelling only, since either tree re-serializes to the same NAR. (#68)
- **A store path is read back under its volume's case-hack mode, so a case-sensitive macOS store keeps a name that literally ends in `~nix~case~hack~N`.** The store case-hacks sibling names only where its volume folds, but the serializer stripped the suffix by operating system (nova-cache's `defaultCaseHack`, on for macOS and Windows). On a case-sensitive macOS volume nothing is ever hacked, yet every registration, on-disk recheck, adoption check, fixed-output check, push and evaluation read of a store path stripped the suffix, and the unpack refused an incoming NAR entry carrying it, so a tree holding such a name could not be substituted or registered. Upstream ties both directions to one per-process setting, `use-case-hack`, on for Darwin (archive.cc at 2.24.9), so it strips and hacks alike on every macOS volume. The store now reads its own paths under its probed volume's answer: stripped where the volume folds, as before and as upstream, and verbatim where it does not, which is upstream with `use-case-hack` off, and an incoming name carrying the suffix is refused only where the volume strips it. A path read as a source, outside any store, keeps the per-process setting, as upstream's every read does. (#236)
- **A NAR entry name or symlink target the host's filesystem can hold is materialized as its bytes, as upstream restores it on POSIX; each platform refuses only what its own filesystems cannot hold.** The store refused, on every platform, a name with no UTF-8 reading and every name Windows reserves (a device stem such as `aux.c` or `con.h`, a colon, a backslash, a trailing dot or space), so a NAR that `nix-store --restore` unpacks on Linux and macOS failed here. Upstream's restore appends each name to the destination path and hands each target to `symlink` as bytes (`RestoreSink` in fs-sink.cc at Nix 2.24.9), refusing only what the archive grammar refuses: an empty name, `.`, `..`, a `/` or a NUL (`parse` in archive.cc), which stay refused on every platform. Linux now holds every other name and target byte for byte, through the strict and the streaming unpack alike. macOS refuses a name that is not UTF-8 or holds a Unicode noncharacter: APFS refuses both at the create, and HFS+ stores such a name with each byte outside UTF-8 (and U+FFFE and U+FFFF) rewritten as a `%XX` escape, a tree that no longer reproduces its NAR. A link target keeps its bytes there. Windows refuses a device name, a trailing dot or space, a character Win32 reserves (`<>:"\|?*` and the controls), and a name or target with no UTF-16 form. Names reach `open`, `mkdir` and `symlink` as `OsPath` bytes rather than through the locale's codec, which could not encode a UTF-8 name under an ASCII file-system encoding. A store tree is therefore no longer guaranteed to materialize identically on every platform: a path holding a name some host cannot hold is refused when it is substituted onto that host, naming the entry and the reason, and materializes everywhere else. (#84)
- **A sibling pair the volume folds but the case-hack key keeps apart fails at the write, naming the entry, instead of silently replacing the earlier one.** On a folding volume the store keys sibling names by their uppercase, and that is not the volume's own fold table: APFS reads capital and small sharp s (U+1E9E, U+00DF) as one name while the key keeps them apart. Both NAR unpack paths created or truncated each file and merged directories, so the second entry of such a pair landed on the first, `builtins.path` returned a store path its tree did not reproduce, and a later build refused to register it with advice that re-evaluation could not follow. Every regular file and directory an unpack writes is now created exclusively, so the volume decides and the collision fails where upstream's restore makes it fail (`RestoreSink` in fs-sink.cc at 2.24.9: `O_CREAT | O_EXCL` for a file, a refused `create_directory` for a directory), with upstream's text. A file reads `creating file '"<path>"': File exists`, the inner quotes being `std::quoted`, through which upstream streams a `std::filesystem::path`, and a directory over a directory reads `path '<path>' already exists`. A directory over anything else reads `creating directory '<path>': File exists`, and a link `creating symlink from '<path>' -> '<target>': File exists`: 2.24.9 printed the C++ library's `filesystem_error` text for these two, which is implementation-defined and differs by platform, and 2.25 wrapped it in these messages, which are followed. The unpack's root is created the same way, so `unpackNarEntry` and the streaming sink refuse a destination that already holds an entry rather than writing into it, as upstream's restore does; every caller clears a stale destination first. `copyPathInto`, the fallback for a build output that cannot be renamed into place, creates exclusively as well and raises the same refusals. (#234)
- **A coerced source path is restored into the store from a dump of the source, as upstream restores one, so a sibling pair the source's volume keeps apart and the store's folds is case-hacked instead of failing the build.** The build driver copied each coerced source (`src = ./dir`) file by file and registered whatever landed under the store path evaluation had computed from the source, with nothing checking that the copy reproduced it. On a folding store, a source from a case-sensitive volume holding `Makefile` and `makefile` merged and was registered under the two-file address. Upstream never copies: `LocalStore::addToStoreFromDump` restores the dump it hashed (`restorePath`, local-store.cc at 2.24.9), so its case-hack applies on the way in. The store now does the same and holds `Makefile` and `makefile~nix~case~hack~1` under the path, NAR hash and size nix-instantiate 2.33.2 records for that source. The source's NAR streams from the walk through the hash into the streaming unpack sink, so no file is held whole. The dump must reproduce the address evaluation computed, so a source edited between evaluation and the build is refused rather than registered under a hash its bytes no longer have, and the restored tree is rechecked on disk before its row exists. `builtins.path` without a filter, `fetchTarball` and `fetchGit` likewise unpack the NAR entry they hashed rather than copying the source, as a filtered `builtins.path` already did. Sources follow the store's per-platform name rules (#84): on Linux and macOS a device stem such as `aux.c`, a colon or a trailing dot restores as its bytes, as the copy stored it and as upstream does. (#235)

### Builder

- **A derivation carrying `__json` is built as upstream builds one: its attributes reach the builder as `.attrs.json` and `.attrs.sh` in the build directory, named by `NIX_ATTRS_JSON_FILE` and `NIX_ATTRS_SH_FILE`, and not as environment variables.** The builder copied every environment entry of the `.drv` into the build's environment, so a structured derivation's builder saw one `__json` variable and neither of the files stdenv's setup script looks for. Upstream takes the mode from the presence of `__json` (`ParsedDerivation`, parsed-derivations.cc at 2.24.9), sets `outputs` to an object naming each output's path, writes the object to `.attrs.json` and the part of it bash can hold to `.attrs.sh`, and passes no attribute and no output through the environment (`initTmpDir` and `writeStructuredAttrs`, local-derivation-goal.cc at 2.24.9). The shell file follows `writeStructuredAttrsShell`: a string single-quoted, an integral number in decimal, null as `''`, a boolean as `1` or nothing, an array or object of those as `declare -a` or `declare -A`, and anything else, or any name that is not a shell identifier, left out. nova-nix now does each of these, rewriting placeholders and store paths in both files as it does in the environment, and reads `impureEnvVars` from the object, where it must be a list of strings. A structured build here wrote `.attrs.json` and `.attrs.sh` byte-identical to a Nix 2.33.2 build of the same derivation, the store directory aside. Upstream writes a number through `static_cast<int>`, so an integer keeps its low 32 bits, and a float past `int`'s range is undefined behavior there; nova-nix writes what an aarch64 build of upstream writes, the saturated value, where an x86-64 build writes `-2147483648`. `outputChecks`, `exportReferencesGraph` and `unsafeDiscardReferences` are neither enforced nor exported in this mode, as their flat-mode counterparts are not. (#36)
- **An x86_64 host runs i686 derivations of its own operating system natively, as upstream's default extra-platforms does.** `i686-linux` and `i686-windows` are platforms of their own now rather than unrecognized strings. A derivation for `i686-windows` spawns directly on an x86_64 Windows host, where WoW64 runs it, and `i686-linux` does the same on an x86_64 Linux host, where the kernel does; upstream adds `i686-linux` to its extra platforms on `x86_64-linux` by default and has no Windows entry yet, so the Windows rule is the same one applied to the platform upstream has not reached. On any other host an i686 derivation is refused or launched through `--exec-wrapper` like any foreign system. (#213)
- **`builtin:fetchurl` retries a transient failure with backoff before moving to the next URL, as upstream's transfer layer does.** One timeout, reset or 5xx against a flapping host used to end that URL's attempt at once, so a short outage on every listed mirror cost the whole fetch. Each URL now gets upstream's five attempts (the `download-attempts` default in `libstore/filetransfer.hh` at 2.24.9), waiting 250 ms before the first retry and doubling after that, with upstream's jitter on the exponent, and each retry is announced on stderr with upstream's `; retrying in N ms` suffix. Upstream's classification decides what a retry can outlive (`libstore/filetransfer.cc` at 2.24.9): a 404 or 410, a 401, 403 or 407, any other 4xx except 408 and 429, a 501, 505 or 511, a malformed or unsupported URL, a redirect loop, a failure writing the output, and a download that verifies wrong are deterministic and move straight to the next URL; everything else, name resolution and TLS included, is retried. A retry restarts the download from the first byte with a fresh hash, where upstream resumes from the written offset only when the server accepts ranges. The policy lives in `BuildConfig` as `bcFetchRetry`, a `FetchRetryPolicy` whose default is upstream's, and the retry loop, delay formula and both classifications are exported from `Nix.Http` with their effects injected, so the policy is tested without a clock or a network. (#238)
- **An interrupt during a push, a fetch cache read or write, a store derivation read or an archive unpack stops the process instead of being read as a missing file, a cache miss or a failed push.** Each of these caught every exception, so a Ctrl-C or a killed thread arriving mid-operation became its fallback value and execution went on: a push reported `push failed: user interrupt` or `HTTP error for <url>: user interrupt` as if the cache had refused it, a fetch cache entry being read became a miss and one being written was dropped, a `.drv` being read became a missing derivation (a failed build or a modulo-hash error), a source tree being verified on disk became a mismatch and was deleted or refused, and an archive being unpacked failed the `builtin:unpack` build. Each now catches only the failure it expects: an `IOException` for a file read, write or removal, an `HttpException` for a push request, and the `ErrorCall` the zstd decoder raises for a corrupt frame. A push, which also meets sqlite and serializer failures, catches every synchronous exception through `Nix.Http.catchSync` and lets an asynchronous one through. Upstream draws the same line by type: `Interrupted` is a `BaseError` beside `Error` rather than under it (`src/libutil/signals.hh` and `src/libutil/error.hh` at 2.24.9), and its fallbacks catch `Error`, as substitution does (`substitution-goal.cc`). A push reads each response body through the transfer layer, so a connection lost mid-body is reported as an HTTP error like any other failure of the request. (#251)

### Substitution

- **The substituter retries a narinfo or NAR download under upstream's transfer-layer policy, the one `builtin:fetchurl` uses.** Upstream serves every download through one `FileTransfer` (`libstore/filetransfer.cc` at 2.24.9) with one backoff and one table of the failures that are never retried, and the substituter kept an older policy of its own. A narinfo fetch was not retried at all, so a single timeout, reset or 5xx on the narinfo cost the path its substitution and, with no other cache holding it, a local build; it now gets upstream's five attempts. A NAR download retried every 5xx and every failure the HTTP client raised, so a 501, 505 or 511 and a redirect loop spent five attempts on an answer that cannot change; they now stop at once, as upstream's never-retried statuses and curl results do, while a 4xx other than 408 and 429 stops at once as before. The delays between attempts become upstream's 250 ms doubling with jitter on the exponent, where the NAR retry waited 0.5, 1, 1.5 and 2 seconds, and each retry is announced on stderr with upstream's `; retrying in N ms` suffix. A narinfo answered 404, 410, 401, 403 or 407 is a miss and is never retried, where only a 404 was a miss and the other four were substitution errors: upstream's transfer layer calls the first two `NotFound` and the rest `Forbidden`, and its binary cache store reads both as the file not being in that cache, since an S3 bucket that cannot be listed answers a missing key with 403. A NAR the narinfo named and the cache answers the same way stops after one request, as before and as upstream's `SubstituteGone` does. An unreachable cache therefore costs each narinfo the full retry budget before the scan moves on (the delays alone come to four to five seconds), as it does upstream, which disables a failing cache for a minute only when `fallback` is set. A connection reset in the middle of a body is now a transport failure, retried like any other, in `builtin:fetchurl` too: the HTTP client wraps what fails while a response is opened but hands the body reader over unwrapped, so a reset mid-body arrived as a bare `IOException` and was taken for a failure writing the output, where curl's receive errors are transient upstream. `bcFetchRetry` governs the substituter's requests as well as `builtin:fetchurl`'s, as upstream's one `download-attempts` setting governs every transfer. (#243)
- **Every HTTP request nova-nix's own client makes identifies itself, as upstream's do.** Only `builtin:fetchurl` sent a User-Agent; the substituter and `push` sent none. A request with no User-Agent is what bot mitigation in front of a server scores lowest: Cloudflare in front of cache.novavero.ai challenged such requests from a GitHub runner and passed the same requests carrying `nova-nix/VERSION`, so the public cache could be read by a person's browser and not by the tool built to read it. Upstream sends `curl/VERSION Nix/VERSION` on every request. The header is now set in one place for the fetcher, the substituter and `push`, with the version Cabal built in. `builtins.fetchurl` and `builtins.fetchTarball` run `curl`, which sends its own User-Agent. (#215)

### Windows packages

- **`pkgs/windows/fetchurl.nix` understands nixpkgs' `mirror://<site>/<path>` scheme, and hello and sed fetch through `mirror://gnu/`.** A recipe named a hand-picked handful of hosts, so one unreachable host could fail the fetch. The site's hosts now come from `pkgs/windows/mirrors.nix`, a verbatim copy of the sets this package set uses from nixpkgs' `pkgs/build-support/fetchurl/mirrors.nix` at the commit the file names, so a recipe spells its source the way the nixpkgs recipe for the same package does and gets the whole list, tried in nixpkgs' order. nixpkgs expands the scheme in its fetch builder at build time; `builtin:fetchurl` runs no shell, so the expansion happens at eval and the derivation records the expanded list, which drops the `ftp://` host the HTTP client cannot fetch. An unknown site is an eval error naming the recipe's URL, where nixpkgs' build script only warns; a mirror URL with no path (`mirror://gnu`) is an eval error too, where nixpkgs expands it to the bare hosts. The source output paths are unchanged (they are fixed by the hash); the source and package derivation paths change, as they describe the new URL list. (#238)

### Distribution

- **Each Hackage upload carries the library's documentation, built on the pinned toolchain.** Hackage's own documentation builder runs a GHC older than the base floor this package declares, so it could not plan the build, and the package page showed a failed build report and no documentation. The Hackage workflow now builds the haddocks for the library and uploads them after the sdist, and a docs-only run uploads them for a version that is already on Hackage. (#211)
- **The public cache carries the current Windows Hello closure, refreshed on every push to main.** cache.novavero.ai was seeded by hand once and nothing republished it when the recipes changed, so a fresh Windows store pointed at it substituted the MinGW-w64 toolchain and built everything else. A publish job after the Windows end-to-end job builds the closure, runs Hello and pushes it, and a second job substitutes that closure into a fresh store from the public cache alone and fails on any local build, so a stale cache turns CI red instead of causing a silent rebuild. (#212)
- **The `default.nix` at the repository root is removed.** It pinned nixpkgs 24.11, whose newest GHC cannot satisfy this package's `base >= 4.22`, so it could not build nova-nix, and nothing used it. (#317)

### Dependencies

- **`unix` (POSIX) and `Win32` (Windows) become direct dependencies, at `unix >= 2.8 && < 2.9` and `Win32 >= 2.9 && < 2.15`.** `base` has no exclusive open, and the store's unpack now needs one, so it calls the platform's own (`openFd` with `O_EXCL`, `CreateFileW` with `CREATE_NEW`). Both are GHC boot packages that `directory` and `process` already pulled into every build, so the solver resolves the versions it already had. The floors are the APIs used: unix 2.8 is the first whose `openFd` takes `creat` and `cloexec` in its flags, and Win32 2.9 the first whose `hANDLEToHandle` serves the native Windows I/O manager. (#234)
- **New dependencies and raised floors.** The library adds `random >= 1.2 && < 1.4` and the executable adds `file-io >= 0.1.1 && < 0.2`. `directory` rises to `>= 1.3.8` in the library and executable, and `filepath` to `>= 1.4.100` in all three. The test suite adds `http-types >= 0.12 && < 0.13` and `QuickCheck >= 2.12.5 && < 2.19`, and requires `process >= 1.6.12`.

## 0.8.0.0 - 2026-10-08

0.7.0.0 was published on GitHub but not uploaded to Hackage, so on Hackage this release follows 0.6.0.0. Library users upgrading from 0.6.0.0 should read the 0.7.0.0 entries below as well.

### Library API

Breaking changes for code that uses nova-nix as a library:

- **`Nix.Eval.Types.MonadEval` has five new methods with no defaults:** `adoptStorePath`, `lookupFetchCache`, `onEvalError`, `setExecutableFile` and `writeFetchCache`. Every instance defined outside this package needs them.
- **`Nix.Builder.BuildConfig` has a new strict field,** `bcExecWrappers :: Map Text FilePath`.
- **`BuildConfig` drops `bcBashPath` and `bcSandbox`, which nothing read.** Both were defined and defaulted, and no code consulted either one: the Windows stdenv takes bash from the MSYS2 seed, and no platform implements build sandboxing. A field that reads like a switch while switching nothing suggests isolation that does not exist, so it goes until there is a mechanism for it to gate (#25). Library code that set either field stops compiling and loses no behavior. (#209)
- **`Nix.Substituter.CacheConfig` replaces `ccPublicKey :: Text` with `ccPublicKeys :: [Text]`,** since a narinfo is now accepted when any trusted key verifies it.
- **The store-write cache records how each path was written.** `Nix.Eval.IO.EvalState`'s `esStoreWriteCache` maps each key to `([StorePath], StoreWriteMode)`, and `Nix.Store.materializeEvalStoreWrites` takes the same shape.
- **`Nix.Eval.StringInterp.coerceToString` takes a `CoercePath m` argument** that decides how a path is coerced at each call site.
- **`Nix.Expr.Types.Expr` has a new constructor, `EPathStr`, and `Nix.Parser.Lexer.Token` has `TokPathInterpStart`, `TokPathLit` and `TokPathEnd`,** so exhaustive matches over either need new cases.

### CLI

- **`build` selects an attribute, or takes an expression, not only a file.** `nova-nix build FILE.nix -A a.b.c` follows a dotted attribute path into the file's value, and `nova-nix build --expr 'EXPR'` builds with no file at all. A build previously took one file whose value had to be a derivation itself, so a package set could only be built by carrying a pointer file per package, which is the arrangement the nixpkgs by-name layout exists to remove. A component may be double-quoted to carry a literal dot (`-A 'foo."bar.baz"'`), matching upstream's tokenizer including the behaviours that read as accidents: quotes concatenate rather than delimit, so `foo"bar"` is the single name `foobar`, and a trailing dot is dropped rather than becoming an empty component. Three upstream behaviours are deliberately absent. A numeric component indexes a list upstream and is an ordinary attribute name here, because nothing this selects over is a list and upstream's integer-or-name test defers to a C++ numeric parse whose accepted spellings have not been checked against a running Nix. A near-miss attribute name gets no `Did you mean` line, which upstream renders from a Levenshtein search this evaluator has no suggestion channel to carry; the message itself is upstream's, only without that second line. Every selection message matched upstream byte for byte when diffed against `nix-instantiate` 2.33.2. And `-A` is not repeatable: upstream accumulates a derivation per occurrence, while a build here realizes one, so a second `-A` is an error rather than a silent last-one-wins. (#174)
- **`--exec-wrapper SYSTEM=PATH` builds a derivation whose system this machine cannot execute, through a named launcher.** `--exec-wrapper x86_64-windows=/path/to/wine` builds an `x86_64-windows` derivation on Linux through Wine. `SYSTEM` is the derivation's own `system` string, spelled exactly as a `.drv` spells it. The launcher is prepended to the builder at the spawn boundary and never enters the derivation, so the store paths a wine-hosted build produces are the same ones a Windows host produces. A derivation for the host's own platform is spawned directly whatever is configured; a derivation for any other system with no launcher named for it is refused before the builder runs, rather than spawned natively and left to fail in the loader. The option is accepted before or after `build`'s target, and repeated options accumulate across both positions with the same duplicate-system and launcher checks. (#171, #201)
- **`eval` accepts `--store` after the subcommand.** The eval sub-parser rejected `--store` where `build`, `push` and `store delete` accepted it, even though eval-side reads follow the selected store identically. (#193)
- **Substituters and trusted public keys can be configured in nix.conf and NIX_CONFIG, not only as flags.** A machine can now say once, in `nix/nix.conf` under `$XDG_CONFIG_HOME` (by default `~/.config`, or `%APPDATA%` on Windows) or in the NIX_CONFIG environment variable, which binary caches to substitute from and which public keys to trust, instead of repeating `--substituter` and `--trusted-key` on every invocation. The format matches upstream's: name = value lines, a # comment truncates the rest of the line, list values split on whitespace, the binary-caches and binary-cache-public-keys aliases are honored, and an extra- prefix appends to a list rather than replacing it. Sources are resolved in upstream's precedence order, the user file, then NIX_CONFIG, then the command line, with the command line winning; a plain assignment in a higher source replaces the accumulated value and an extra- widens it, which is the load-bearing rule for trusted-public-keys since a mishandled precedence would silently widen what the machine trusts. The trusted-key set is now flat as upstream models it: a narinfo is accepted when its signature verifies under any trusted key, and a malformed key anywhere in the set is a loud error rather than a silently skipped one. The include/!include directives and the /etc/nix and XDG_CONFIG_DIRS sources are tracked separately in #199. (#200)

### Evaluation

- **Path literals accept string interpolation (`./${v}/x`), which the nixpkgs 26.05 tree uses 89 times across 54 files.** The lexer had no representation for an interpolated path, so both ./${v}/x and /tmp/${v}/y were parse errors. The lexer now carries a path mode alongside the string modes, mirroring upstream's INPATH machinery: the head piece opens the literal, chunks and interpolations follow, and a synthesized end token closes at the first non-path character. Every semantic rule is pinned to nix-instantiate 2.33.2's observed behavior: the result is a path; pieces concatenate with no separator (mid-segment and back-to-back interpolation included) and the whole canonicalizes textually, so a dot-dot arriving at runtime collapses; a path segment coerces without a store copy, unlike string interpolation, and a string carrying store-path context is refused ("cannot be appended to a path"); and a trailing slash is a parse error ("path has a trailing slash"), now for plain path literals too, matching upstream's grammar. A search path followed by /${v} stays two expressions, as upstream parses it. The head piece is absolutized in the same pre-eval pass as plain literals, so closure capture across import keeps working. (#189)
- **An attrset coerced through outPath now copies to the store and carries context, matching upstream byte for byte.** The coercion engine handled paths with a fixed context-free case, so a path reached through an attrset's outPath (or a __toString result) rendered as raw filesystem text with no store copy and no string context, wherever the attrset stood in for the path: src = builtins.fetchGit { ... } produced empty inputSrcs and a drvPath diverging from real Nix 2.33.2 while the .outPath spelling worked, a user { outPath = ./dir; } leaked the raw path into the build environment, and the same drop reached interpolation, the + operator, and builtins.toJSON. The path behavior is now a parameter the attrset recursion carries, so every coercion site states its mode: interpolation, derivation fields, +, and toJSON copy to the store; builtins.toString stays verbatim. Verified against nix-instantiate 2.33.2: the attrset form's drvPath now matches upstream exactly. (#187)
- **A syntax error inside a lambda is reported where it happened, not as an attr-set complaint at the top of the file.** The parser decided lambda-versus-attr-set (and lambda-versus-expression) by backtracking, and a failed try discarded its error entirely, so a real failure at line 410 of a nixpkgs file surfaced as "expected '=' but got ','" at line 2, column 6: the valid comma in the file's opening formals. Two changes close it. A parsed lambda header now commits: "ident :", "ident @", and "{ formals } :" never begin anything else, so a body failure propagates from its own position instead of rewinding, which also covers the case where the other reading succeeds on a prefix (the empty attr set in "{ }: body") and the old parser complained at the stray colon. And when no reading parses at all, the error from the branch that got furthest into the input is the one reported, with end-of-input ranking past every positioned failure; upstream's LR parser never backtracks and always errors at the true site, and this is the closest a backtracking parser comes. (#181)
- **`builtins.fetchGit` remembers a pinned revision.** A fetch of a pinned `rev` is now recorded under the user's cache directory and reused while the tree it names is still in the store, since re-cloning it on every evaluation is pure waste and fatal whenever the far end is down. A bare `ref` is never cached, since it means "whatever this branch points at now". Everything that decides what comes back is in the key, `shallow` included: it does not change the tree, but it does change `revCount`, which can reach a derivation's environment. A hit re-records the store write the fetch would have made, so the path it hands back is registered before any derivation naming it is built. (#172)
- **`builtins.fetchGit` with `shallow = true` reports revCount 0 and can check out a pinned rev.** A shallow fetch counted the truncated history, so revCount was always 1 regardless of the real depth, a wrong number that can reach a derivation's environment. Upstream reports 0 under shallow (its fetcher skips computing the attribute and evaluation fills the default; observed from nix-instantiate 2.33.2, whose shallow result keeps all eight attributes with revCount = 0), and nova-nix now records and reports the same 0 without running rev-list at all. A pinned rev also becomes the fetch refspec itself, matching upstream's construction: a depth-1 fetch of a branch tip cannot contain any other revision, so asking the remote for the SHA is what lets shallow and rev compose, and what finds a rev not on the fetched ref. A server may refuse a SHA it does not advertise; that failure surfaces as git's own fetch error, the same surface upstream has. (#184)
- **`builtins.fetchGit` honors allRefs, verifies a declared narHash, refuses unsupported attributes, and cleans up after a failed fetch.** allRefs = true fetches every remote ref (upstream's refs/*:refs/* refspec), the caller's way to a pinned rev the remote refuses as a direct SHA want; the scratch clone has a worktree, unlike upstream's bare cache repo, so the fetch passes --update-head-ok, which is safe because an explicit checkout always follows. A declared narHash is verified against the fetched tree on the cache-hit and fresh paths alike, and a mismatch is an error in upstream's orientation (expected is what the fetch produced, got is the declared pin), never a silent recompute; upstream matched from nix-instantiate 2.33.2. An attribute outside the supported set (url, name, ref, rev, submodules, shallow, allRefs, narHash) is refused instead of silently dropped, which is exactly how the unhonored pins stayed invisible. A failing fetch no longer leaks its scratch clone: every throwing step runs under a new onEvalError cleanup primitive, the error half of a bracket. (#188)
- **`builtins.placeholder` is substituted at build time.** `builtins.placeholder "out"` evaluates to a sentinel, since an input-addressed derivation's output path is a hash of the derivation itself and the expression cannot name a path it hasn't produced yet. The sentinel now gets replaced in a derivation's `args` and in its environment, names as well as values, at the spawn boundary, with the same per-output path `$out` already carries, so argument-passing and environment-passing builders agree on where to write. Upstream rewrites the whole `name=value` string rather than the value alone, so a placeholder in a dynamic attribute name is substituted too. This is what lets a builder that takes its destination as an argument rather than reading the environment (stage0's `hex0 input output`) be driven directly. The builder path itself is left alone: a placeholder there would name a program that does not exist yet. (#126)

### Store

- **Eval-time store writes are verified at both ends: writers rewrite a mismatched leftover, and registration refuses content that does not reproduce its path.** Every eval-side writer skipped its write when the destination existed (and the fetchurl writer wrote unconditionally, crashing on a sealed leftover), so a file truncated by an interrupted earlier run was adopted as-is, registered valid under the hash of the content it should have had, sealed read-only so nothing could repair it, and then consumed by builds. Writers now adopt an existing destination only when its bytes are the intended bytes (raw comparison for toFile and fetchurl, recursive NAR digest for source copies and builtins.path) and otherwise clear and rewrite, which is where the bytes exist to rewrite with, matching upstream's delete-then-rewrite in addTextToStore. Registration independently re-derives each recorded path from its on-disk content under the scheme that named it (the write cache now carries text, recursive, or flat alongside the references) and refuses loudly on a mismatch rather than sealing a hash the bytes do not have. (#182)
- **The eval-side materializers join the per-path lock protocol.** materializeEvalSources ran its check-then-act with no lock: two processes materializing the same source path raced the validity check, and the loser's clear-and-recopy deleted the tree the winner had just registered. Both materializers now run each path's adopt or prepare under the same cross-process path lock the builder and substituter hold, with validity re-checked once the lock is held, so a peer mid-producing a path is waited out rather than raced. The store-write materializer's batched registration stays outside the lock deliberately: registration is an upsert over content both holders verified reproduces the same path. (#192)
- **A concurrent invocation waits for the store database instead of dying on ErrorBusy.** SQLite's default busy timeout is zero, so while one process held a write transaction (a registration mid-commit), a second nova-nix crashed in under a tenth of a second with an uncaught SQLError instead of waiting its turn, which at build volume means any concurrent pair of builds or substitutions could kill one side. The connection now sets the one-hour busy timeout upstream configures at open (sqlite3_busy_timeout, sqlite.cc at 2.28.7; the pragma reaches the same API), so concurrent invocations queue on the database exactly as upstream's do. (#180)
- **The executable bit survives on Windows.** A NAR records whether each regular file is executable, and a store path's identity is the hash of that. Windows has no such bit: `getPermissions` answers from the file's extension there, so a checked-out `configure` serialised as non-executable and a tracked `foo.exe` as executable, whatever either one was, and the substituter's unpacked tree then failed to reproduce the declared hash, so any cached path holding a mis-extensioned executable was downloaded, rejected, and deleted. The bit now lives in an NTFS alternate data stream whose presence is the bit. `builtins.fetchGit` sets the marks from git's index before `.git` is stripped. On Unix nothing changes. (#128)
- **Windows NAR hashing streams instead of materializing the tree in memory.** The executable bit lives in an alternate data stream that nova-cache's own walk could not see, so on Windows narHashOfPath serialised the whole tree in memory and rewrote the flags afterwards. nova-cache 0.11.1 added the exec-bit resolver hook for exactly this: the walk now asks the ADS source of truth per regular file, with the on-disk case-hack-suffixed name, so hashing streams in chunks on every platform and the post-hoc flag-rewrite walk is gone. (#191)

### Builder

- **A build writes into its output path, not into a temp directory it is moved out of.** `$out` is now the final store path, as upstream's is. Building somewhere else and moving afterwards looks equivalent and is not: anything that records where it was built (a compiler driver, a libtool archive) recorded the temp path, which is deleted the moment the build ends, leaving a store path that isn't self-contained and that the reference scanner can note but not repair. A stale tree from an interrupted run is cleared before the builder starts rather than at registration, and a failed build takes its partial output back out of the store, an interrupted one included. (#130)
- **A partial rebuild no longer hands the builder an output that is already valid.** A multi-output derivation with one output deleted still runs one builder, which writes every output; a registered path's `NarHash` describes bytes that must not change, and it is sealed read-only besides. Such an output gets a scratch path for the duration of the build, discarded afterwards, exactly as upstream's `makeFallbackPath` does. Where upstream then rewrites the scratch path back out of the produced outputs' contents, this refuses instead: an output that recorded a scratch path fails the build and names it. That divergence is deliberate, because a dangling reference becomes a loud failure rather than a registered lie, and it is the only part of upstream's hash-rewriting machinery not reproduced here. (#130)
- **Builders run in a scrubbed environment: the ambient environment no longer flows into builds.** The child process previously inherited every host variable not overridden by the build environment, so a builder consulting an undeclared variable embedded machine-specific data in its output, undermining the reproducibility SOURCE_DATE_EPOCH exists to pin. Upstream builds in a cleared environment (initEnv begins with env.clear() and the child is exec'd with exactly that block), and nova-nix now matches: on Unix nothing ambient passes through, and on Windows, where a process block cannot be empty, exactly SystemRoot, SystemDrive, and windir pass through (SystemRoot is a hard execution requirement, verified on a clean Windows 11 machine, where the CLR's crypto provider fails to load without it), COMSPEC is synthesized from SystemRoot, and PATHEXT is pinned to .COM;.EXE;.BAT;.CMD instead of inheriting the host's resolution rules. All four temp-directory names (TMPDIR, TEMPDIR, TMP, TEMP) now point at the build directory, matching upstream's single assignment of all four; scrubbing without repointing them would send Windows temp files to the Windows directory itself. (#186)
- **A fixed-output derivation's impureEnvVars reach the scrubbed build environment.** Upstream's carve-out for fetchers that need proxies: the variables the derivation's impureEnvVars attribute lists are copied from the ambient environment into the child, gated on the derivation being fixed-output (upstream gates on the type being non-sandboxed, which for nova-nix is exactly the fixed-output case). A listed variable absent from the ambient environment is set to the empty string, not omitted, and the carve-out is written last so it wins even over the derivation's own values, both matching upstream's initEnv. Every other derivation still sees only the scrubbed allowlist. (#190)
- **On Windows a build's process tree runs in a Win32 job object, so an interrupt or a builder exit no longer orphans grandchildren.** The builder was spawned without process-tree containment, so terminateProcess reached only the direct child: on Ctrl-C or a nonzero builder exit, a builder's whole grandchild tree (bash to make to cc, or anything cmd.exe spawned) was orphaned and kept running. The build spawn now sets use_process_jobs, wrapping the tree in a job with kill-on-job-close, so terminateProcess becomes TerminateJobObject and reaps the whole tree and waitForProcess waits for all of it. This is Windows only; POSIX builds are unchanged, and there is still no build timeout. It is phase one of build sandboxing (process containment); filesystem and privilege isolation and resource limits remain tracked in #194 and #195. (#196)

### Substitution

- **Substituting a store path whose symlinks have multi-component targets now works on Windows.** A symlink target like bin/tool was stored by Windows as a reparse point with backslashes, so re-serialising the unpacked tree read it back as bin\tool, the NAR hash no longer matched the signed narinfo, and the on-disk recheck failed the substitution systematically (a hard failure, never silent corruption). nova-cache 0.11.1.1 normalises a symlink target's separators to the POSIX spelling at the NAR boundary on Windows. (#198)

### Windows packages

- **`pkgs/windows` follows the nixpkgs layout, and the package set is a fixpoint.** Packages moved to `by-name/<shard>/<pname>/package.nix` (shard = the pname's first two characters), the stdenv machinery to `stdenv/`, and the sha256-pinned MSYS2 seeds to `bootstrap/`. `pkgs/windows/lib.nix` adds `callPackageWith`, modelled on nixpkgs' `lib.customisation.callPackageWith`, and `default.nix` discovers `by-name` with `builtins.readDir` and calls each `package.nix` through it. A package now declares its dependencies as formal parameters (`{ stdenv, zlib }: ...`) and callPackage supplies them from the set, instead of each recipe reaching across the tree with a relative import; adding a package is adding a directory. The walk reads the entry type `readDir` returns, so a stray file in `by-name` is passed over rather than opened as a directory. No recipe changed beyond its dependency plumbing, but every one of them moved, so all six drvPaths change and nothing already built is reused. `pkgs/windows/hello.nix` now builds the by-name GNU Hello through the stdenv, so its executable is at `bin\hello.exe`, where `make install` puts it, rather than at the output root. (#127)
- **Windows source fetches retry ordered mirrors and verify every download.** `pkgs/windows/fetchurl.nix` accepts a non-empty `urls` list for flat fixed-output downloads. HTTP errors, incomplete responses, and hash mismatches advance to the next URL; only verified bytes enter the store. Cancellation stops the build and releases its output lock without trying another mirror. Hello, sed, and zlib use multiple pinned source locations. Their source and package output paths stay unchanged, while their derivation paths change to describe the new inputs. The bundled upstream `fetchurl.nix` remains unchanged, and single-URL calls retain its interface. (#203)

### Dependencies

- **nova-cache moves to `>= 0.11.1.1 && < 0.12`, with the xz binding pinned to its bundled sources.** The 0.11.1 releases carry four things this project consumes. The per-file executable-bit resolver (`SerialiseOptions`, `withNarSourceOpts`) is the seam the Windows alternate-data-stream executable model plugs into, streaming verifier included. The POSIX default executable answer now reads the file's own owner-execute bit as upstream's dump does, where it previously asked `access(2)` what the calling process could do, so NAR bytes stop depending on who serialised (root, ACLs, and foreign-owned files were the divergence). A symlink target's separators are normalised on Windows. And the xz codec's binding moved off the Hackage-deprecated `lzma-static` onto its successor `xz`, which prefers a pkg-config-found system liblzma; the new `constraints: xz -system-xz` line pins the bundled sources so every build links the same C regardless of the host. (#177, #191, #198)

## 0.7.0.0 - 2026-08-22

- **A copied nova-nix finds its own bundled expressions.** The `<nix/*>` search path came from Cabal's `getDataDir`, which bakes an absolute path in at configure time naming the machine that did the build. That is right for a local `cabal install` and wrong for every copied or downloaded binary: `import <nix/fetchurl.nix>` resolved to a directory that does not exist on the host running it, and the Windows package recipes all open with that line. The data dir now comes from `NIX_DATA_DIR` when set (upstream's own variable name), otherwise from `share/nova-nix` beside the executable's own `bin` directory, which is the layout a release archive ships and needs nothing configured, and otherwise from Cabal as before, so a local install is unchanged. The build matrix now checks both fallbacks against a relocated copy with Cabal's own override pointed at nothing, so a pass cannot come from the baked path.
- **Tags publish downloadable binaries, not only a Hackage sdist.** Seventeen tags had produced zero GitHub releases, because the publish workflow only ever uploaded an sdist, so the only way to get nova-nix was to build it. Each tag now also attaches a per-platform archive for Linux, macOS and Windows, carrying `bin/` beside `share/` and `pkgs/`, so an unpacked copy resolves `<nix/*>` and builds with no toolchain and nothing to configure, plus a `SHA256SUMS` file generated over the exact uploaded bytes. The recipes ride along because they are what there is to build: every path in `pkgs/` is either relative to itself or the `<nix/*>` entry `share/` already provides, so the command the README opens with runs against the unpacked copy rather than needing the repository cloned beside it. Every archive is smoke-tested before it is attached, and the release is created as a draft and only published once all four assets are present, since `releases/latest/download` is a public URL that must not exist while empty. (#12)

  Two gaps the first cut left open. The smoke test ran on the machine that built the binary, where a full GHC toolchain sits on `PATH`, so it could not tell a self-contained executable from one quietly finding its DLLs next door; the Windows archive is now run again with `PATH` cut to the system directories, which is the download's actual situation, so a dependency that starts linking zlib, zstd or libgcc dynamically fails the release instead of the first person to unpack the zip. And the Hackage upload waited only on the checks, not on the archives, so a tag whose binaries failed to build would still have taken the one step of a release that cannot be undone: a Hackage version is immutable. It waits on the packaging job now.
- **`nova-nix --version` and `--help` answer, and `--help` answers on stdout.** A downloaded binary could not say what it was. The CLI had no version handling at all, so a bug report from a machine with no toolchain had no way to name the build it came from, which is most of what a release exists to enable. `--help` did not work either, failing with `unknown argument: --help`: usage came only from a bare invocation, on stderr, exiting non-zero. Both are flags now, answered before the rest of the line is parsed, so `--version` reports the build even when the command after it is one this build does not have. The two spellings of usage keep their difference for the reason they have one: a bare invocation is a usage error and stays on stderr with a non-zero status, while `--help` is a request that succeeded and goes to stdout exiting zero, so it pipes without redirecting stderr. The version is Cabal's, the one the publish workflow's tag guard already checks a tag against, and the archive smoke test now asserts the binary agrees with it before the release is attached: the guard reads a source tree, this reads the executable actually built from it.
- **`--store` is honored on evaluation's reads, not only its writes.** The store directory reached the five eval-time write sites and none of the eleven read sites, which resolved through the platform default, so a redirected store was written to and then read from somewhere else: `builtins.readFile (builtins.toFile "a" "hi")` wrote the file into the chosen store and then failed to find it under `/nix/store`. `builtins.path`, `filterSource`, `fetchGit` followed by `import`, and every other eval-time read had the same split. Before the store directory reached evaluation at all both sides ignored the flag and were at least consistent with each other, so this was newly broken rather than long-standing. The resolver takes the store directory it should read from instead of assuming the platform one, and evaluation passes the store it was given.

  No test caught it because the integration harness built its evaluator with the platform store while handing the build a different one, so the two halves were never asked to agree. The harness now uses one store for both. (#145)
- **Every store object evaluation writes is registered, not only `builtins.toFile`'s.** `writeToStore` recorded its writes for the build driver to register; `copyPathToStore`, `addSourceNar` and `addFixedOutputFile` recorded nothing, so a path from `builtins.path`, `filterSource`, `builtins.fetchurl`, or `builtins.fetchGit` reached `drvInputSrcs` with no registration row and the build died with `registerPaths: ... references unregistered path`. That made `builtins.fetchGit`'s `outPath` unusable as a derivation input, which is the reason it computes one. Recording now happens in one place every eval-time writer goes through, rather than being repeated per builtin and forgotten in three of four.
- **The release workflow's artifact actions match the rest of CI.** `publish.yml` pinned `actions/upload-artifact@v4` and `actions/download-artifact@v4` while `ci.yml` was already on v7, so the workflow that produces every release asset was two and four majors behind the one that builds them. Both move to the current majors (upload v7, download v8), whose inputs the workflow already uses.
- **CI lints its own workflows.** Haskell, C and source encoding were all covered while `.github/workflows/` itself was validated only by GitHub agreeing to parse it after a push. `actionlint` now checks `uses:` references, `${{ }}` expressions, matrix references and job dependencies, and runs shellcheck over every `run:` block, pinned to a version rather than floating so a lint release cannot turn an unrelated pull request red.
- **A build holds its outputs' path locks, so two builds of one derivation cannot interleave.** Per-path locks made delete, materialize and register a single critical section, and the build path never joined it, while `computeBuildDir` hands both processes the same build directory. Two concurrent builds of one derivation therefore wrote into one tree and registered the result as valid: reproduced with a builder appending a line per second, where the registered output held eight interleaved lines instead of six and nothing reported an error. A build now takes every output's lock before it starts and holds them until registration commits, and re-checks validity once they are granted so a derivation another process finished while we waited is adopted rather than rebuilt over. Outputs are locked in store-path order so two processes racing a multi-output derivation request them in the same sequence, and a build holds locks for its own outputs only, so there is no cycle to deadlock on.
- **`builtins.fetchGit` validates `ref` and `rev` before they reach git, closing an eval-time command execution.** Both attributes were spliced straight into git's argument vector with no separator and no shape check, and git parses options after the remote name, so a `ref` of `--upload-pack=<cmd>` named the command git runs as the transport. With a local or `file://` url (both accepted, and the local transport is enabled in the clone's own config) git ran that command through a shell, so evaluating an untrusted expression executed it and the evaluation still succeeded with an ordinary attrset. `ref` is now checked against upstream's `refRegexS` and `rev` against its `revRegexS`, the same shapes upstream enforces before either value reaches git, and `--` separates the positional arguments in `git fetch`. Rejecting an abbreviated `rev` is upstream parity in its own right: a revision that still resolves through git's DWIM rules is not pinned. (#143)
- **The Windows stdenv builds again: no drive letter in derivation text.** `mkDerivation` passed its setup script as `/cygdrive/c${setup}`, a hardcoded drive glued in front of a canonical store path. Once the builder began rendering `/nix/store` to the machine's real store directory at the spawn boundary, the two composed into `/cygdrive/cC:\nix\store/...-setup.sh` and every `mkDerivation` package failed before the builder's first line. The same rewrite reaches environment values, so `setup.sh`'s mapping (which matched on the canonical spelling) stopped firing and put a drive-lettered path into a colon-separated `PATH`, splitting the entry in two. Derivation text is now canonical throughout, which is what its hash is computed over and the only spelling that is correct on a store that is not on C:. `setup.sh` maps native paths to the two forms its consumers need, MSYS2 `/cygdrive/<drive>` for bash and mixed `C:/` for the native mingw tools, in pure parameter expansion, so it no longer shells out to `cygpath` (which the seed's package set does not provide) and no longer infers a drive from `$NIX_STORE`. The Windows E2E job now builds GNU hello through the real stdenv: the existing job drives `cmd.exe` directly and never loaded `stdenv.nix`, which is why this went unnoticed. (#144)

- **`--store` reaches evaluation, not only the builder.** Evaluation resolved every store read and write through the platform default (`C:\nix\store` on Windows, `/nix/store` elsewhere) while the builder honored the flag, so a non-default store left evaluation writing to one directory and the build reading from another. The store directory is now carried in the evaluation state and every eval-time store path resolves through it. A derivation's own strings are rendered at the spawn boundary for the same reason: the builder path, its arguments and its environment values all carry the canonical `/nix/store` spelling that hashes depend on, and that text is mapped to the configured directory only as the process is spawned. On Windows this is what lets a store-path builder be executed at all, since a bare `/`-rooted path there resolves against the current drive rather than the store's.
- **`builtins.fetchGit` fetches a pinned revision, not just a branch tip** - `rev`, `ref`, `submodules` and `shallow` are all now read from the argument set, `git fetch`/`checkout` replace the old unconditional `--depth 1` clone, and the result is the same attrset upstream's returns: `outPath`, `rev`, `shortRev`, `revCount`, `narHash`, `lastModified`, `lastModifiedDate` and `submodules`, all read from the clone before its (and every submodule's) `.git` metadata is stripped and it is copied to the store. Checked against a real Nix on the same two repositories: identical `outPath`, `narHash`, and every other field, submodule fetches included.
- **Substitution accepts bzip2, so historical cache paths and narinfos with no Compression field at all can be fetched.** cache.nixos.org narinfos written before the xz switch declare `Compression: bzip2`, and upstream C++ Nix reads an absent or empty Compression field as bzip2, so those paths could only ever fail here: the register named the codec in its rejection but no decoder stood behind the name. Both decompression paths - streaming and the strict oracle - now dispatch it through nova-cache 0.11's bzip2 sublibrary, bounded by the declared NarSize exactly as xz and zstd are, with stream errors retried and output past the bound refused as the served object misdeclaring; there is no decoder-memory knob to set, because bzip2 carries no attacker-chosen dictionary size and its decoder state is a small constant of the format. The empty spelling now decodes rather than rejects, completing the upstream parity the register was written for. The bound rises to nova-cache >= 0.11 && < 0.12 with the bzip2 sublibrary added to the library's set, and the pinned index-state advances past the release. 0.11 also tightens the zstd codec, which the suite now pins: a truncated frame and trailing bytes after the last frame refuse with a stream error instead of yielding a short output, so truncation is caught at the codec rather than left to the NAR grammar above it. (#115)
- **Cancellation is never spent as retry budget nor as cache fallthrough.** The retry classifier already re-threw asynchronous exceptions, but the per-cache catch-all one frame up converted every exception into a substitution error, so a Ctrl-C or timeout arriving mid-download was swallowed, the cache scan continued, and the build fell through to building locally instead of aborting. The sync/async split now lives in one shared helper used by the retry classifier, the per-cache attempt, and the builder's top-level catch-all: only synchronous exceptions convert into recoverable failures, and an interrupt propagates out of the whole operation.
- **Per-store-path locks close the substitution race.** The streaming substituter deleted the destination before the first byte downloaded, with no lock and no validity re-check anywhere, so two processes sharing a store could interleave delete, materialize, and register into a valid database row pointing at deleted or torn bytes. Substitution now takes an exclusive lock on upstream's "\<store-path\>.lock" file - a raw write-access descriptor locked through filelock (flock on POSIX, LockFileEx on Windows; CC0-licensed, so nothing encumbers the Apache-2.0 distribution), because base's Handle-based locks cannot express this lock - before touching the destination, re-checks validity under the lock and adopts another process's finished path instead of deleting and redoing it (waiting on a busy lock is announced, as upstream does), and holds the lock across download, materialization, the on-disk recheck, and the caller's registration transaction, releasing it on every exit path. The protocol is upstream C++ Nix's pathlocks mechanism (src/libstore/pathlocks.cc and the substitution path in local-store.cc) exactly. One deliberate divergence, documented in Nix.Store.Lock: lock files persist instead of being marked and deleted, which removes upstream's deleted-lock-file hazard outright and needs no separate Windows path.

- **store delete joins the per-path lock protocol, and lock files are not delete targets.** The delete command ran its unregister-and-remove sequence with no lock, so a delete racing another process's substitution of the same path could remove the freshly materialized tree between the substituter's on-disk recheck and its registration commit - the exact valid-row-pointing-at-deleted-bytes race the locks close, reopened through the other door. Deletion now holds the target's path lock across the whole sequence, blocking (with the announced wait) while a substituter holds it, as upstream's deletePath does. A target naming a lock file is refused with the reason: lock files are never deleted here by design, and removing one would reopen the deleted-lock-file hazard that design removes. Names like flake.lock are legal store-path names, so the refusal consults the registration rows: only an unregistered file whose suffix-stripped name is a well-formed store basename reads as a lock file, and a registered object of the same shape deletes normally.

- **zstd, both directions: substitution accepts it, push can produce it.** The modern caches (Cachix, attic, FlakeHub) serve NARs zstd-compressed; both decompression paths - streaming and the strict oracle - now speak it through nova-cache 0.10's zstandard sublibrary, bounded by the declared NarSize like xz (the strict decompressor's resolved function moves into IO: the zstd binding's decoder is IO-native, and the shape follows the codecs). Push gains --compression none|zstd, a property of the destination cache as upstream models it - none stays the default so existing cache content stays uniform - with the compressed object named by its own file hash and the narinfo's file fields describing it. The bound rises to nova-cache 0.10 with the zstandard sublibrary and the pinned index-state advances past the release. (#111)
- **Substitution failures carry a retry class, and every failure path cleans up.** Exceptions thrown mid-stream - a dropped connection, a full disk - previously escaped both the retry budget and the partial-tree cleanup, so the single most common transient failure never retried and could orphan an unverified tree at the store path. Every attempt now converts synchronous exceptions into the pipeline's failure channel (asynchronous cancellation re-throws untouched) and removes the tree on every exit. Failures classify explicitly, matching upstream's transfer layer: transport errors, torn transfers, and truncation retry with backoff; a completed transfer that verifies wrong, a body past its ceiling, or a 4xx fails at once, since retrying a deterministic failure only delays the local-build fallback.
- **The NAR download cap derives from the signed NarSize.** The compressed-body cap was keyed to the narinfo's FileSize, which the Ed25519 fingerprint does not cover, so a rewritten FileSize on a validly-signed narinfo could buy an unbounded download. The cap is now a ceiling computed from the signed NarSize plus a small framing-overhead margin, and the unsigned FileSize may only lower it, never raise it. Caches that omit FileSize (on-the-fly compressors) gain the same margin, so incompressible NARs whose compressed body slightly exceeds NarSize substitute where they previously aborted.
- **An empty Compression field means bzip2, never identity.** Upstream decodes an absent or empty Compression as bzip2, the field's historical default, and nova-cache's parser matches that coercion; treating the empty string as identity diverged from it. The compression vocabulary now lives in one register (Nix.Compression) parsed once at the narinfo boundary, so the streaming and strict dispatch cannot drift.
- **Substitution streams: download, decompress, hash, parse, and unpack in one bounded pass.** The substituter realized every NAR in memory before touching disk - an RSS spike the size of the path being fetched, documented in place since the first cut. The pipeline now drives nova-cache's streaming pieces end to end: the HTTP body feeds the bounded xz source, the incremental hash, the chunk-fed NAR parser, and a new streaming store sink that materializes events as they arrive, so memory is bounded by decoder buffers and the parser's structural-string cap, never by archive or file size. Disk writes therefore begin before the hash can be known - upstream's ordering exactly - and any failure or mismatch removes the tree it wrote; the on-disk recheck streams too. One divergence, documented at the sink: sibling names colliding on a folding filesystem always take upstream's case-hack renaming, never the NTFS true-name path, which can only be enabled on an empty directory the stream cannot pre-scan. (#107)
- **Substitution from cache.nixos.org works: xz NARs decompress.** The default cache serves every NAR xz-compressed, and decompressorFor rejected the value up front, so the shipped configuration could never substitute a single path. nova-cache 0.9's bounded decoder is wired in via its public nova-cache:xz sublibrary - an ordinary solver-visible dependency, no flags on either side; the bound rises to >= 0.9 && < 0.10 and the pinned index-state advances past the release - with output capped at the narinfo's signed NarSize and decoder memory at nova-cache's default, so a hostile stream expands to nothing the narinfo did not promise. Unsupported compression values still reject before any download is paid for. Verified against production data: recorded cache.nixos.org narinfos parse, validate, and signature-verify offline in the suite, and a real path substitutes end to end from the live cache. (#27)
- **nova-cache 0.8.0.0.** The bound rises to >= 0.8 && < 0.9 and the pinned index-state advances past the release. NAR parsing now rejects the full Windows reserved-device set at the parse boundary - COM0/LPT0, space-padded device stems ("NUL .txt"), and the superscript-digit forms - the same names materialization already refused, so the refusal moves earlier and the two guards agree. 0.8 also brings the streaming NAR interface and the NarSize-bounded xz decoder that foreign-cache substitution builds on next.
- **builtin:unpack resolves srcs through the store dir.** The srcs env value carries eval's canonical /nix/store spelling; unpack opened it verbatim, and on Windows a bare /-rooted path resolves against the current drive - the build worked only when the process happened to run from C:. Found by the e2e job's first flights (#100) on GitHub's D:-workspace runners. Each srcs entry now parses as a store path and renders through the configured store dir (the identity on Unix), a non-store-path entry fails loudly, and the e2e job no longer pins its working directory. (#101)
- **Toolchain: GHC 9.14.1 (the first GHC LTS release)** - CI, the release pipeline, and the README badge move from GHC 9.8.4 to 9.14.1. GHC 9.14 opens GHC's new LTS scheme (a minimum of two years of bugfix releases), and under that scheme every earlier series stops receiving fixes, so no version in between had a future. Dependency bounds already admitted the newer compiler and the full set resolves on it. The project targets the LTS alone: base >= 4.22, and foldl' comes from the Prelude, so its eleven redundant Data.List imports are gone. The deprecated pattern namespace specifier stays for now - hlint cannot yet parse the data replacement GHC suggests - with its deprecation warning muted in the cabal file until hlint learns the new spelling.
- **`builtins.toFile` writes are registered before a build that names them.** `writeToStore` computed a content-addressed path and wrote the bytes during evaluation, but nothing registered it, so a derivation naming a `toFile` output as an input died with `registerPaths: ... references unregistered path ...`. The write is recorded in eval state and registered in one batch by the build driver, before building - the same treatment `materializeEvalSources` already gives path literals eval coerces into the store, batched so a text path referencing another resolves. The write is idempotent too: the path is the hash of the bytes, so a file already there already holds them, and skipping the rewrite avoids a permission error on a second evaluation once registration makes the path read-only.
- **A path literal resolves against the file it is written in, not the file being evaluated when it is forced.** Relative path literals were rewritten to absolute ones only for `import`ed and `scopedImport`ed files; the top-level file and `--expr` were left to resolve at force time against whatever directory the evaluator happened to be in. Those differ whenever a literal outlives the scope it was written in, and `builtins.scopedImport { q = ./data.txt; } ./sub/useq.nix` is the short way to see it: `q` is forced during `sub/useq.nix`'s evaluation, so `./data.txt` named `sub/data.txt`, and `builtins.readFile` on it returned the wrong file's contents into whatever consumed them. Resolution is now part of parsing, which is where upstream does it (`absPath(path, state->basePath...)` in the `PATH` production through 2.24, `CanonPath(literal, state->basePath.path).abs()` in 2.35, unchanged in between), so `parseNix` takes the directory to resolve against and no caller can skip the step. A second bug fell out of widening it: `~/x` was treated as relative and joined to the importing file's directory, burying the tilde mid-path where nothing expands it, so `~/x` inside an imported file resolved to `<dir>/~/x`. Home-relative literals are now left for the evaluator, which expands them against `HOME`.
- **A value that needs no evaluation is shown, not reported as a thunk.** Printing an unforced list or attr set showed `<thunk>` for entries that were already values: `[ "a" "b" ]` came back as `[ <thunk> <thunk> ]` where upstream prints the strings, `[ ./x ]` and `[ [ ] ]` the same, and every attr set value regardless of what it was, so `{ a = 1; }` reported a thunk over the literal `1`. The cause was that the decision was made on how a value is represented at runtime (an integer fits an immediate slot, a string does not) rather than on what the expression is, which is what upstream asks: `maybeThunk` is overridden by `ExprInt`, `ExprFloat`, `ExprString` and `ExprPath` to hand back a value already built, and both `ExprList::eval` and the non-recursive branch of `ExprAttrs::eval` go through it. String and URI literals, path literals, and the empty list join the integers and booleans that were already answered directly, and a non-recursive attr set resolves its values the same way; recursive sets and `let` still defer, because the frame their variables point into is still being tied. Path literals can only be answered here because parsing now resolves them, so what reaches the evaluator is already absolute; a `~/` literal still defers, since expanding it reads the environment. Checked against a real `nix-instantiate` 2.24.9 in CI rather than against a reading of its source, and the same overrides are byte-identical in 2.35.2.

  Empty containers print as `[ ]` and `{ }` rather than with the two spaces a join of no elements left between the brackets.

## 0.6.0.0 - 2026-06-12

### Milestone: A Stage-1 stdenv That Builds Real Software

nova-nix now builds real third-party software from source on Windows through a small stage-1 stdenv. A package is `{ name; src; buildInputs; }`, and a `setup.sh` genericBuild drives unpack, configure, build, install, and a fixup that makes outputs standalone - proven on GNU hello, GNU sed, and zlib plus a program that links it.

- **New: a store-pinned MSYS2 userland seed and `mkDerivation`** - the seed bash runs `setup.sh`, which unpacks the source, runs `./configure && make && make install`, and builds through the mingw toolchain. GNU hello and GNU sed build from source with a two-line derivation.
- **New: `buildInputs`** - a package depends on another package. Each input's `include/` goes on `CPPFLAGS`, `lib/` on `LDFLAGS`, and `bin/` on `PATH`, and its derivation builds first. Proven by libgreet (a library built from source) and greeter (a program that links it).
- **New: a `cc-wrapper`** - a `gcc`/`cc` shim installed on the build `PATH` ahead of the real toolchain. It strips ambient header/library search paths from the environment (hermeticity), defaults to `-std=gnu17` so classic gnulib-bearing sources compile under gcc 15+'s C23 default, and adds `-Wl,--no-insert-timestamp` for reproducible PE headers. A package can override the standard with its own `-std`.
- **New: a fixup phase** - Windows has no RPATH, so a distributable binary must carry its non-system DLLs beside it. fixup reads each output binary's PE import table and, for every imported DLL outside the guaranteed Windows ABI, finds it among the declared inputs and bundles it, recursing to a fixpoint. An undeclared non-system DLL fails the build rather than shipping silently broken.
- **New: build-phase overrides** - optional `dontConfigure` / `buildPhase` / `installPhase` attrs let a package build differently from the autotools default (e.g. via a hand-written makefile) while the common case stays a two-line derivation.
- **Real third-party library: zlib 1.3.2** - its `./configure` emits Unix `.so` naming under MSYS2, so zlib builds via its `win32/Makefile.gcc` for a proper `zlib1.dll` and `libz.dll.a` import lib. A demo links zlib (statically against `libz.a`, or dynamically against `zlib1.dll` with the DLL bundled) and round-trips data.
- **Relicensed from BSD-3-Clause to Apache-2.0.** Apache adds an explicit patent grant and trademark terms, and a `NOTICE` file now carries the copyright (Novavero AI Inc.). Earlier releases on Hackage remain under BSD-3-Clause.
- **Substituter: retry transient NAR download failures** before falling back to a local build, matching Nix's `download-attempts` (5 attempts, linear backoff), so a dropped connection or a stale CDN negative no longer forces a rebuild.
- **`builtin:fetchurl` sends a User-Agent**, so hosts that reject empty-UA requests (some GNU mirrors) serve the fetch.
- **Docs:** 100% Haddock coverage across all modules.

## 0.5.0.0 - 2026-06-11

### Milestone: Push and Pull Against a Binary Cache

A native Windows build can now publish its outputs to a binary cache and substitute them back on another machine. The toolchain seed and the `hello` build push to `cache.novavero.ai` with `nova-nix push`, and a fresh store realizes them by signed download instead of rebuilding.

- **New: `nova-nix push`** - uploads store paths and their full reference closure to a binary cache. Each path is NAR-serialized (via `nova-cache`), its NAR uploaded before its narinfo so a client never sees metadata pointing at absent data, and the server signs each narinfo on receipt. Already-cached paths are skipped via one `GET /narinfo-hashes` diff, the recorded NAR hash is cross-checked against the store database before upload, and a signed round-trip is verified at the end. Published narinfos use the canonical `/nix/store` path form. Usage: `nova-nix push --cache URL --key-file KEY (--all | <paths>)`.
- **New: `build --substituter URL --trusted-key K`** - try a binary cache before building. The trusted key (`name:base64`) is required alongside the substituter, since substituting without signature verification would be unsafe; the build falls back to local compilation for any path the cache lacks.
- **New: `--store DIR`** - run a command against an alternate store directory instead of the platform default, so a build or push can target a scratch store.
- **Reproducible `hello`** - `hello.nix` links with `-Wl,--no-insert-timestamp`, and the builder pins `SOURCE_DATE_EPOCH` (1980-01-01) for every build, so determinism-aware tools write fixed timestamps. Two cold bootstraps produce a byte-identical `hello.exe`.
- **Dependencies modernized** - builds against `nova-cache >= 0.4.2` (Ed25519 signing, `normalizeKeyText`), `crypton >= 1.1` with `ram` replacing the deprecated `memory`, and current upper bounds for `containers`, `http-client-tls`, and `time`. No source change beyond the dependency swap; derivation-hash parity is unaffected (CI-verified).

## 0.4.0.0 - 2026-06-10

### Milestone: The First Native Windows Build

`nova-nix build pkgs/windows/hello.nix` compiles and runs a C program through a Nix derivation, natively on Windows: evaluation records the derivation closure, the builder realizes 17 fixed-output fetches and a toolchain unpack in dependency order, and a compiler that is itself a store path produces `C:\nix\store\...-hello\hello.exe`. The first package built natively on Windows by a Nix implementation.

- **New: `builtin:unpack`** - an in-process archive extractor, the counterpart to `builtin:fetchurl`: the unpacker is the thing being bootstrapped, so it cannot be a store tool, and ambient `tar` is unpinned. Extracts `.tar.zst`/`.tar` archives listed in `srcs` into the single `out` output, in order. MSYS2 packages share the `mingw64/` prefix, so a package set merges into one working toolchain root. Symlink and hardlink entries are materialized as copies (store outputs must not require Developer Mode), pacman per-package metadata is skipped, and cross-archive file collisions and path traversal fail the build loudly. MSYS2's zstd frames carry no pledged content size, so decompression is streaming. New dependencies: `tar >= 0.7.1`, `zstd`.
- **New: `pkgs/windows/seed.nix`** - stage 0 of the native Windows stdenv: the 17-package runtime closure of `mingw-w64-x86_64-gcc`, sha256-pinned against `repo.msys2.org`, fetched and merged into a single toolchain root. `pkgs/windows/hello.nix` is the proof build; its recipe also documents the Windows DLL rule (a subprocess resolves DLLs via `PATH`, not RPATH, so dependencies' `bin` dirs go on the build `PATH`).
- **Fix: the build driver materializes eval-coerced source paths** - `src = ./file` produced only the source store path *text* during evaluation (eval performs no store writes - the parity runner's store is not writable), so input validation failed on the missing path. The driver now copies each coerced source into the store, marks it read-only, and registers it with its real NAR hash before building.
- **`data/nix/fetchurl.nix` documentation rewritten as original prose** - the functional content is fixed byte-for-byte by derivation-hash parity and is unchanged (CI-verified).

## 0.3.0.0 - 2026-06-06

### Milestone: Derivation-Hash Parity with Upstream Nix

`(import <nixpkgs> {}).hello.drvPath`, and all 253 derivations in its closure, now hash byte-for-byte identically to `nix-instantiate`, verified in CI on the same nixpkgs tree. A new `eval --aterm` mode dumps a derivation's ATerm for diffing against `nix derivation show`.

- **Fix: indented-string indentation is stripped per literal string-part, before interpolation** - the common indentation was removed from the fully-interpolated string, so a multi-line interpolated value (e.g. `${commonPreHook}` in the stdenv `preHook`) could lower the computed minimum indent to zero and leave the literal lines indented. Indentation is now computed from the literal parts only - interpolated values are opaque content - matching C++ Nix.
- **Fix: path literals interpolated into strings are copied to the store** - `"${./foo}"` left the raw source path in the result; it now copies the file to the store and yields its store path, with the path added to the string context so it lands in the derivation's `inputSrcs`. `builtins.toString` still does not copy, per Nix semantics.
- **Fix: `builtins.placeholder`** - now returns `/` followed by the full SHA-256 of `nix-output:<name>` in Nix base-32, matching Nix's `hashPlaceholder`. It previously truncated the hash and wrapped it as a `/nix/store` path, so any derivation using `${placeholder "out"}` (e.g. perl's `configureFlags`) diverged.
- **Fix: a derivation's default output is its first declared output** - a bare reference to a multi-output derivation now resolves to `outputs[0]` (both name and path), not a hardcoded `out`. This affected packages whose first output is not `out`, such as `xz` (first output `bin`).
- **Fix: `builtins.attrNames` is sorted lexicographically** - names were returned in interned-symbol order rather than sorted by key, and inconsistently with `builtins.attrValues`. Surfaced in `fetchurl`'s `impureEnvVars` (the generated `NIX_MIRRORS_*` list).

## 0.2.0.0 - 2026-06-05

### Milestone: `import <nixpkgs> {}` Evaluates

- **`import <nixpkgs> {}` now evaluates to WHNF** - the top-level package set resolves and enumerates ~23,000 attributes, and a package evaluates through the full stdenv bootstrap down to a derivation: `(import <nixpkgs> {}).hello.drvPath` yields a `/nix/store/...-hello-2.12.1.drv` path. (Derivation-hash parity with upstream Nix and on-Windows building are the next fronts - this milestone is evaluation, not yet building.)
- **Fix: `inherit <name>;` de Bruijn level in positional let/rec blocks** - `inherit x;` (without `from`) in a positional `let`/`rec` block desugars to `x = x;` with the right-hand side resolved against the *outer* scope, so it refers to the enclosing `x` rather than the binding being defined. But the desugared thunk is evaluated at runtime in the *inner* (let/rec) env, which adds one parent-chain level the outer resolution did not count - so every resolved level was short by one. This produced `nn_env_lookup_resolved: idx out of bounds` whenever an outer *lexical* variable was inherited inside a positional block - first triggered by perl's `inherit version;` (argument-set slot 7) nested in a 4-binding `let`. Fixed by shifting the desugared `inherit` RHS up one de Bruijn level in `Nix.Expr.Resolve`. (`inherit (from) ...` was already correct: its RHS resolves against the inner scope.)
- **Fix: Hackage build - ship C headers in the sdist** - the `cbits/*.h` headers are `#include`d by the C sources (via `include-dirs: cbits`) but were never listed in the cabal file, so `cabal sdist` omitted them and the Hackage build failed with `nn_*.h: No such file or directory`. Added `extra-source-files: cbits/*.h`. A regression introduced in 0.1.9.0 (the first release to ship the C data layer); CI never caught it because CI builds the working tree, not the sdist. Verified by building from a freshly-extracted sdist tarball in isolation.

## 0.1.9.0 - 2026-06-05

### nixpkgs Evaluation: Lazy Derivations

- **Fix: `import <nixpkgs> {}` infinite recursion - lazy `derivation` over `derivationStrict`** - `builtins.derivation` was eager: forcing a derivation to WHNF forced its entire env/input closure. This turned nixpkgs' lazy `perl` and `libxcrypt` dependency cycle into a blackhole - `perl`'s `assert (libxcrypt != null)` forced `libxcrypt`'s build, which forced `perl` mid-construction. Restored Nix's two-tier model: the eager `builtins.derivationStrict` primop (identical content-hashing, renamed from the old `builtins.derivation` body) plus a lazy `derivation` wrapper over it (mirroring `corepkgs/derivation.nix`). Forcing a derivation to WHNF now yields its attribute spine without computing `drvPath`/`outPath` or forcing its inputs, so referencing a package no longer builds its closure. No C changes; `drvPath`/`outPath` hashes unchanged. The stdenv bootstrap now progresses through derivation construction instead of looping (next blocker: a variable-slot bug while evaluating perl's `@`-pattern).

### Technical Audit + C Data Layer Polish

- **Fix: Nix integer division semantics** - `builtins.div` and `builtins.mod` now use floored division (`div`/`mod`) instead of truncated (`quot`/`rem`). `-7 / 3` correctly returns `-3` (was `-2`). Affects `builtinDiv`, `builtinMod`, and `evalDiv`.
- **Fix: `inherit (from)` in positional let/rec blocks** - The resolution pass treated `inherit (expr) x y;` as positional, but the bytecode evaluator did not handle `BcInheritFrom` in `allBcPositional`, `bcBindingSlotCount`, `buildBcSlotThunks`, or `buildBcAttrMapFromSlots`. This caused env slot count mismatches and `idx out of bounds` assertions on any nixpkgs expression using `inherit (from)` in a positional let block.
- **C memory safety audit** - `nn_bytecode.c`: `ensure_op_space`/`ensure_data_space` now return error codes instead of failing silently; overflow guard on capacity doubling. `nn_lambda.c`: `NN_ASSERT` bounds checks on entry accessors prevent null dereference when `formal_count == 0`. `nn_attrset.c`: documented partial-realloc-failure behavior.
- **Dead code removal** - Removed unused `cattrsetIntersect` Haskell wrapper, `nn_attrset_intersect`, and `nn_attrset_values_ptr` C functions.
- **New: `nn_assert.h`** - Debug-mode bounds checking macro. Compiles to nothing under `NDEBUG`.
- **Performance** - Stress test: 6.25 MB max residency, 56.3% GC productivity (down from 69.7 MB / 1.6% pre-C-data-layer).

## 0.1.8.0 - 2026-03-08

### Builder Correctness, Windows Store Paths, Hackage Fix

- **Fix #1: builder no longer pre-creates output paths** - `buildDerivationInner` no longer calls `createDirectoryIfMissing` for `$out`, `$dev`, etc. before running the builder. The builder is now responsible for creating its own outputs, matching real Nix semantics. Builds fail with `"builder succeeded but outputs missing"` if the builder exits successfully but expected outputs don't exist.
- **File outputs supported** - `$out` can now be a regular file, not just a directory. `registerSingleOutput`, `addToStore`, `scanReferences`, and `pathExists` all use `doesPathExist` instead of `doesDirectoryExist`. `moveOutput` (renamed from `moveDirectory`) handles both files and directories in cross-device fallback. `collectRegularFiles` accepts file paths directly for reference scanning.
- **Fix #2: Windows store paths use native separators** - CLI now uses `platformStoreDir` (`C:\nix\store` on Windows, `/nix/store` on Unix) for filesystem operations and display. Evaluator internals keep canonical `/nix/store` for ATerm hash compatibility.
- **Fix: `crypton < 1.1` in .cabal file** - Previous `crypton` pin was only in `cabal.project` (which Hackage ignores). Moved to `.cabal` so the Hackage solver respects it. `crypton >= 1.1` switched from `memory` to `ram` for `ByteArrayAccess`, breaking `http-client-tls`.

## 0.1.7.1 - 2026-03-07

### Hackage Build Fix

- **Fix: `nova-cache < 0.3.1` bound** - Hackage ignores `cabal.project` constraints, so the solver picked `nova-cache-0.3.1.0` (which uses `ram`) alongside `http-client-tls` (which uses `memory`), causing `ByteArrayAccess` instance mismatches. Narrowed bound to `< 0.3.1` to force `nova-cache-0.3.0.0` (which uses `memory`) until `http-client-tls` migrates to `ram`.

## 0.1.7.0 - 2026-03-07

### Build Fix: drvPath Resolution + cmd.exe Quoting

- **Fix: `nova-nix build` drvPath resolution** - `builtinDerivation` now writes `drvPath` and output paths (`out`, `dev`, etc.) into `drvEnv` on the `Derivation` struct. Previously these were only present in the eval result attr set, so `buildAndRegister` could not find the `.drv` store path for dependency resolution (error: "no drvPath available for dependency resolution").
- **Fix: `extractDerivation` returns `StorePath`** - The CLI `build` command now extracts the `drvPath` `StorePath` directly from the eval result alongside the `Derivation`, passing it explicitly to `buildWithDeps`. Eliminates the fragile `Map.lookup "drvPath" drvEnv` fallback.
- **Fix: cmd.exe builder quoting on Windows** - New `mkBuilderProcess` detects when the builder is `cmd.exe` with `/c` and uses `Proc.shell` instead of `Proc.proc`. GHC's `proc` wraps each argument in double-quotes for `CommandLineToArgvW`, but cmd.exe doesn't use that convention - `args = [ "/c" "echo Hello" ]` would fail because cmd.exe tried to find an executable literally named `"echo Hello"`.
- **Fix: UTF-16 auto-detection** - New `readFileAutoEncoding` detects UTF-16 LE/BE and UTF-8 BOM at the byte level before decoding. PowerShell's `>` operator writes UTF-16 LE by default - a Windows-first Nix implementation must handle this at the input boundary. Wired into all 5 file-read sites (Parser, Eval/IO, Main).
- **nova-cache `>= 0.3.0`** - Bumped lower bound to track nova-cache 0.3.x series.
- **`crypton < 1.1` pin** - `http-client-tls` still uses `memory`'s `ByteArrayAccess`; `crypton >= 1.1` switched to `ram`, causing instance mismatches. Pinned in `cabal.project` until `http-client-tls` migrates.

## 0.1.6.0 - 2026-02-26

### De Bruijn-Style Positional Env + SmallArray Slots

- **De Bruijn-style variable resolution** - New `Nix.Expr.Resolve` pass replaces `EVar` with `EResolvedVar level index` for lambda-bound variables. Called at parse time; all subsequent evaluation uses positional indices instead of name-based `Map` lookups.
- **Array-based Env** - `envSlots :: SmallArray Thunk` replaces `envBindings :: Map Text Thunk`. Lambda formals get O(1) positional indexing via `indexSmallArray` instead of O(log n) `Map.lookup`. Let/rec bindings and builtins remain in name-based `envLazyScope` (already zero Map.Bin overhead).
- **Scope chain** - `Env` uses parent pointer chain instead of `Map.union`. Variable lookup walks the chain: positional slots, then `envLazyScope`, then parent, then with-scopes. Avoids O(n) `Map.union` when extending large envs.
- **Inherit desugaring** - `inherit x;` is desugared to `x = x;` in the resolution pass so inherited lambda formals resolve to `EResolvedVar` (lambda slots have no names at runtime).
- **Heap savings** - Eliminates 29.9M Map.Bin nodes (1.37 GB) from lambda formals. Replaced by SmallArray (one heap object per env, O(1) index) + scope chain parent pointers.
- `primitive` dependency added for `Data.Primitive.SmallArray`

## 0.1.5.0 - 2026-02-26

### New Builtins, Coercion Fixes, CI Cleanup

- **`builtins.warn`** - prints warning to stderr, returns second arg
- **`builtins.path`** - copies file/dir to store with `SCPlain` context
- **`builtins.seq` fix** - now forces first arg to WHNF (was silent no-op)
- **`builtins.trace`/`traceVerbose` fix** - print to stderr via `MonadEval.traceMessage`
- **`coerceToString` fix** - handle `VAttrs` with `__toString` and `outPath`, enabling `"${pkgs.hello}/bin/hello"` interpolation (was type error on all attrsets)
- **`fetchTarball` fix** - downloads and extracts via curl|tar pipeline (was returning raw .tar.gz)
- **`MonadEval`** - added `traceMessage`, `copyPathToStore` methods
- CI: switched to `haskell-actions/run-ormolu@v16` (matching all Novavero repos)

## 0.1.4.0 - 2026-02-26

### Memory Optimization + Lazy Non-Rec Attrsets

- **Lazy non-rec attrsets** - `evalNonRecAttrs` now uses `LazyAttrs` to defer thunk+IORef allocation until first access. For `all-packages.nix` (~15k entries per stdenv stage), only accessed packages allocate thunks.
- **Per-binding Env in `LazyBinding`** - Each `LazyExpr`/`LazyInherit`/`LazyInheritFrom` carries its own `Env` instead of sharing one per-attrset. Fixes a bug where `//` merges of `LazyAttrs` from different scopes lost variables - broke `lib.extends` overlay pattern used by `makeExtensibleWithCustomName`.
- **Batched formal set matching** - `matchFormalSet` creates ONE `Env` with all formals instead of N singleton `Env`s. Uses knot-tying so default expressions can reference other formals, including forward references (`{ a ? b, b ? 1 }: a` evaluates to `1`).
- **Env scope chain** - `Env` now uses a parent pointer chain instead of `Map.union`. Variable lookup walks the chain: local bindings, then parent, then with-scopes. Avoids O(n) `Map.union` when extending large envs (e.g. 30k-entry nixpkgs rec set). Peak Map.Bin heap: 956MB down to ~40MB.
- **ThunkCell release** - On force, `Pending expr env` is overwritten to `Computed val`, dropping all references to `Expr` and `Env` (matching C++ Nix in-place mutation). Previously retained dead closures indefinitely.
- **Lazy `//` operator** - `mergeAttrSets` preserves `LazyAttrs` when possible, merging binding recipe maps instead of materializing all thunks.
- **Lazy with-scopes** - `pushWithScope` accepts `AttrSet` directly so `LazyAttrs` with-scopes stay lazy. `lookupWithScopes` uses `attrSetLookup` to materialize only the accessed key.
- **Key-driven `intersectAttrs`** - Only touches keys present in both sets instead of materializing entire attrsets.
- **New builtins** - `min`, `max`, `mod`, base64 `encode`/`decode` (via nova-cache 0.2.4)
- `nova-cache >= 0.2.4` dependency bump
- 511 tests, -Werror clean, ormolu 0.7.7.0 clean, hlint clean

## 0.1.3.0 - 2026-02-25

### nixpkgs Compatibility: Module System, Callable Sets, Parser Fixes

- **`__functor` support** - Attribute sets with `__functor` are now callable, matching real Nix. `(set.__functor set) arg` dispatch enables nixpkgs patterns like `lib.makeOverridable` and `lib.setFunctionArgs`.
- **`builtins.setFunctionArgs`** - Wraps a function in a callable attrset with `__functor` and `__functionArgs` metadata. Used by `lib.mirrorFunctionArgs`, `lib.makeOverridable`, and the entire nixpkgs override system.
- **Null dynamic keys** - `{ ${null} = val; }` now skips the binding instead of erroring (matching real Nix). Used extensively by the nixpkgs module system for conditional attributes like `${if cond then "key" else null}`.
- **Indented string interpolation fix** - `''${expr}''` now parses correctly. The lexer had an overzealous guard (`isIndStringEscape`) that blocked `''` from opening an indented string when followed by `$`. This broke `lib.generators` and many nixpkgs files.
- **`splitVersion` fix** - Dots are separators, not components. `splitVersion "1.2.3"` now returns `["1" "2" "3"]` (was `["1" "." "2" "." "3"]`). Fixes `lib.versions.majorMinor` and all version comparison logic.
- **`builtins.functionArgs` on callable sets** - Now inspects `__functionArgs` metadata on attrsets produced by `setFunctionArgs`, enabling `lib.functionArgs` to work on overridable functions.
- **CLI eval sub-parser** - `--strict` and `--nix-path` flags now work after `eval` command (e.g. `nova-nix eval --strict --expr '...'`). Previously only worked before the command.
- **Bundled `<nix/fetchurl.nix>`** - Ships as a Cabal data-file, added to search paths automatically. nixpkgs stdenv bootstrap imports this file; no system Nix install needed.
- **nixpkgs module system working** - `lib.evalModules`, `lib.mkOption`, `lib.mkIf`, `lib.types.*` all evaluate correctly.
- **nixpkgs functional patterns working** - `lib.makeOverridable`, `lib.makeExtensible`, `lib.fix`, `lib.generators.toKeyValue` all correct.
- **`lib.systems.elaborate` instant** - Was taking minutes before lazy builtins; now returns immediately for any system string.
- 101 builtins (up from 91), 511 tests

## 0.1.2.0 - 2026-02-24

### Lazy Builtins + Correctness Fixes

- **Lazy `map`** - returns deferred thunks instead of eagerly forcing all elements. `map f [80000 items]` is now O(1) until elements are demanded.
- **Lazy `genList`** - each element is a deferred `f(i)` thunk, forced only on demand
- **Lazy `mapAttrs`** - each attr value is a deferred `f key val` thunk. Critical for nixpkgs which `mapAttrs` over the entire package set.
- **Semi-lazy `concatMap`** - forces applications to discover list structure for concatenation, but element thunks within sub-lists stay lazy
- **`@`-pattern scoping fix** - `{ system ? args.system or ..., ... }@args:` now correctly binds the `@`-name before evaluating defaults, matching real Nix. Previously, default expressions couldn't reference the `@`-binding.
- **Synthetic thunk memo cell fix** - `mkSyntheticThunk` uses env bindings (not expr) for IORef uniqueness, preventing GHC's full-laziness transform from sharing one memo cell across all deferred thunks. Without this, `map f [a b c]` would return `[f(a) f(a) f(a)]`.
- `deferApply` helper: builds thunks wrapping `EApp (EResolvedVar 0 0) (EResolvedVar 0 1)` in a self-contained env with positional slots, reusing existing eval + memoization machinery
- 511 tests (3 new: map laziness, genList laziness, mapAttrs laziness)

## 0.1.1.0 - 2026-02-24

- **Thunk memoization** - Per-thunk `IORef` memo cells (matching real Nix in-place mutation). `forceThunk` is a `MonadEval` method: IO evaluators memoize per-allocation, pure evaluators re-evaluate. GC reclaims dead thunks naturally - no unbounded global cache. `unsafePerformIO` CAF float-out prevented via `NOINLINE` + `seq` pattern.
- **8.4x builtin dispatch speedup** - Case dispatch replacing polymorphic `Map` reconstruction on every call. Direct pattern match on builtin name for zero-allocation dispatch in the hot path.
- **Regex builtins** - `builtins.match` and `builtins.split` via `regex-tdfa` (pure Haskell POSIX ERE, cross-platform)
- `ESearchPath !Text` AST constructor - `<nixpkgs>` is now its own node, desugared at eval time to `builtins.findFile builtins.nixPath "nixpkgs"` (matching real Nix semantics)
- NIX_PATH parsing: `parseNixPath` converts colon-separated `name=path` entries into `{ prefix, path }` attrset thunks
- `builtinEnv` now accepts search paths: `builtinEnv :: Integer -> [Thunk] -> Env`
- `EvalState.esSearchPaths` populated from `NIX_PATH` environment variable at startup
- Directory imports: `import ./dir` automatically resolves to `./dir/default.nix`
- Dynamic attribute keys: `{ ${expr} = val; }` fully supported in all contexts (non-rec, rec, let, select, hasAttr)
- Two-phase binding resolution for knot-tying: `resolveBindingKeys` (monadic, evaluates dynamic keys) then `buildResolvedBindingsMap` (pure, enables recursive self-reference)
- Monadic `resolveKey` replaces pure `resolveStaticKey` - handles both `StaticKey` and `DynamicKey` uniformly
- CLI `--nix-path NAME=PATH` flag (repeatable, merged with NIX_PATH)
- CLI `--expr EXPR` for inline expression evaluation
- CLI deep-force and pretty-printing for eval output
- `README.md` added to `extra-doc-files` in cabal (shows on Hackage)
- Parser fix: `TokInterpOpen` in expression context expects `TokRBrace` (not `TokInterpClose`) for closing brace
- 91 builtins (up from 88)
- 508 tests (14 new: parseNixPath, search path parsing/eval, dynamic keys, directory import, populated search path resolution)

## 0.1.0.0 - 2026-02-24

### Cross-Platform Fixes

- Cross-platform ATerm serialization: `storePathToText` always uses `/` for store paths regardless of OS, `parseStorePath` accepts both `/` and `\`
- Builder inherits system environment, overlays build env via `Map.union` - fixes silent process failures on Windows (missing `SYSTEMROOT`)
- Builder PATH derived from builder location (`buildPath`) - includes builder dir and MSYS2 sibling `usr/bin` for coreutils discovery
- `findTestShell` prefers known Git for Windows bash over WSL launcher (`System32\bash.exe`)
- Parser strips UTF-8 BOM - Windows editors (Notepad, PowerShell) commonly add byte order marks
- Demo `test.nix` included: derivations, lambdas, builtins.map, arithmetic - runs on all platforms
- 16 builtins exposed at top level without `builtins.` prefix (`toString`, `map`, `throw`, `import`, `derivation`, `abort`, `baseNameOf`, `dirOf`, `isNull`, `removeAttrs`, `placeholder`, `scopedImport`, `fetchTarball`, `fetchGit`, `fetchurl`, `toFile`) - matches real Nix language spec, required for nixpkgs compatibility

### String Contexts, Dependency Resolution, Binary Substituter

- String context tracking on all `VStr` values: `SCPlain`, `SCDrvOutput`, `SCAllOutputs`
- Context propagation through interpolation, string concatenation, `replaceStrings`, `substring`, `concatStringsSep`, and all string builtins
- `Nix.Eval.Context` module: pure helpers for context construction, queries, and extraction
- `derivation` builtin now collects string contexts into `drvInputDrvs` and `drvInputSrcs`
- New builtins: `hasContext`, `getContext`, `appendContext`
- `Nix.DependencyGraph`: BFS graph construction with `Data.Sequence` (O(V+E)), topological sort via Kahn's algorithm, cycle detection
- `Nix.Substituter`: full HTTP binary cache protocol - narinfo fetch/parse, Ed25519 signature verification, NAR download/decompress/unpack, store DB registration, priority-ordered multi-cache
- `Nix.Builder.buildWithDeps`: recursive dependency resolution - topo sort, cache check, binary substitution, local build fallback
- CLI `nova-nix build` now builds full dependency trees, not just single derivations
- Cleanup pass: eliminated all partial functions (`T.head`/`T.tail` to `T.uncons`, `!!` to safe lookup, `last` to pattern match), flattened deeply nested code into composed functions, `Data.Sequence` BFS queues throughout, semantic section organization
- 494 tests (68 new: string context, context propagation, dependency graph, substituter, build orchestrator)

### Content-Addressed Store + Derivation Builder

- Real SQLite-backed store database (`ValidPaths` + `Refs` tables, WAL mode)
- Store operations: `addToStore` (cross-device safe), `scanReferences` (byte-scan for store path references), `setReadOnly` (recursive), `writeDrv`
- `parseStorePath`: parse full store path strings into `StorePath` values
- ATerm parser (`fromATerm`): hand-rolled recursive descent, full round-trip with `toATerm`
- `builtinDerivation` now populates `drvOutputs` with `DerivationOutput` records
- Full `buildDerivation` loop: input validation, temp directory setup, environment construction, process execution via `System.Process`, reference scanning, output registration in store DB
- CLI `nova-nix build FILE.nix`: evaluate, extract derivation, write `.drv`, build, print output path
- 426 tests (45 new: 10 store DB, 13 store ops, 10 ATerm parser, 8 builder, 4 CLI end-to-end)

### Parser, Lazy Evaluator, 85 Builtins

- Full Nix expression parser (hand-rolled recursive descent, 13 precedence levels)
- Lazy evaluator with thunk-based evaluation, knot-tying for recursive bindings
- 85 builtins: type checks, arithmetic, bitwise, strings, lists, attrsets, higher-order, JSON, hashing, version parsing, tryEval, deepSeq, genericClosure, all IO builtins, derivation
- MonadEval typeclass - evaluator is polymorphic in its effect monad (PureEval for tests, EvalIO for real evaluation)
- IO builtins: import, readFile, pathExists, readDir, getEnv, toPath, toFile, findFile, scopedImport, fetchurl, fetchTarball, fetchGit, currentTime
- derivation builtin: attrset to .drv build recipe with computed drvPath and outPath
- ATerm serialization with string escaping, sorted environments
- placeholder and storePath builtins via nova-cache hashing
- Content-addressed store path types with Windows/Unix support
- Derivation types, platform detection, and textToPlatform/platformToText
- Shared hash utilities in Nix.Hash (SHA-256 hex, truncated base-32, byteToHex)
- CLI: `nova-nix eval FILE.nix` evaluates a .nix file and prints the result
- 381 tests, zero framework dependencies
- CI pipeline: HLint, Ormolu, build with -Werror, test, Hackage publish on tags

### Security

- Total functions only - no `read`, `head`, `tail`, `!!`, `fromJust`, or `Map.!`
- Argument injection prevention in fetch builtins (`--` separator before user URLs)
- Path traversal validation in writeToStore (rejects `/`, `..`, null bytes)
- Content-hashed temp directories for fetchGit (no predictable paths)
- Store paths set read-only after registration (immutability enforcement)

### Architecture

- Store paths parameterized via `StoreDir` - no hardcoded `/nix/store/` strings
- Cross-device safe directory moves (rename with copy+remove fallback)
- Hash utilities deduplicated into Nix.Hash (single source of truth)
- currentSystem is a constant (not a function), matching real Nix semantics
- Platform-aware environment setup (HOME vs USERPROFILE, Unix vs Windows PATH)