Reproducibility and the taint model
This is where most of the engineering goes. The KDL parsing is the easy part.
The guarantee
Section titled “The guarantee”output = f(kdl_inputs, tool_version, module_versions, formatter_version, oracle_rev)Deterministic to the byte. Given the same inputs and the same five pins, the generated Nix is identical. That is what makes “immutably recreatable without version or KDL changes” true, and it is what knixl check verifies.
The lockfile
Section titled “The lockfile”knixl.lock.kdl, KDL for grep-and-diff friendliness (the Nixtamal ethos). It records:
toolversionformattername + versionoraclenixpkgs-rev + options-hash- per
inputfile: blake3 hash - per
moduleused: name + version - per
outputfile: blake3 hash, which input it came from, which modules produced it - per
host(nested):pinlines (a package’s version pin: package, version, resolved nixpkgs-rev, and strategy) and abaselineline (the host’s declared nixpkgs release, its resolved nixpkgs-rev, and options-hash)
See examples/knixl.lock.kdl for the shape. The formatter and oracle rev are in the lock because a check that passes under one formatter/nixpkgs and fails under another would break the guarantee. They are part of the generation boundary, same as the tool itself.
Two layers of reproducibility, kept distinct
Section titled “Two layers of reproducibility, kept distinct”- Generation-time (knixl’s lock): the Nix source text is a pure function of inputs + pins.
- Build-time (Nix’s flake.lock): the store paths are a pure function of the Nix + its inputs.
knixl’s layer sits above Nix’s. Document them separately so nobody conflates “my Nix regenerated identically” with “my system built identically”.
Reconcile: three hashes per file
Section titled “Reconcile: three hashes per file”Every generated file has three hashes:
lock_hash: what the lock recorded last time.disk_hash: what is on disk now.expected_hash: what regenerating from current KDL + locked versions produces now.
FileState is derived from comparing them:
Clean: disk == lock == expected. Nothing to do.Stale: disk == lock, expected != lock. Inputs (or module logic) changed the output. The normal, silent edit path.Drifted: disk != lock. The generated file was hand-edited. Tainted. Refuse to overwrite without--accept-drift.Missing: in lock, absent on disk. Recreate.Orphaned: on disk (knixl header present) but not in lock. Offer to delete.
The subtlety that makes it work: Stale and Drifted both mean “disk differs from what the lock knew”, but the third hash tells them apart. If disk still matches the lock, the divergence came from the inputs, not from a human. Stale is expected and silent; Drifted is the taint and must not be silently clobbered.
Version skew is a separate axis
Section titled “Version skew is a separate axis”Skew is about why expected differs: because a version (tool, formatter, module, oracle rev) moved. It is orthogonal to FileState. A file can be Stale because the KDL changed, or Stale because a module version bumped, and those are handled differently:
- KDL changed under the same versions:
generateapplies it silently. - A version moved and would change output: that is a potential regression.
generaterefuses (exit NeedsAck) and points atupgrade.upgradeshows migration notes and a diff, and only rewrites the recorded versions on explicit--yes.
So a framework upgrade cannot change your committed Nix as a side effect. It is always opt-in and reviewable, like bumping a compiler.
Why whole-file taint, not per-node
Section titled “Why whole-file taint, not per-node”Per-node taint needs sentinel markers in generated files and AST-diffing generated-vs-on-disk, which is brittle. Whole-file hashing plus the rule “user code lives in separate, never-generated files” gives reliable detection with no marker parsing. Every generated file carries a header (Generated by knixl ... do NOT edit ... overrides go in a sibling) so the contract is visible at the point of temptation. See ADR 0004.
The one class of breakage the oracle cannot catch
Section titled “The one class of breakage the oracle cannot catch”Two knixl modules can assign the same option path in one file. If exactly one uses a priority, fine. If two both mkForce the same path, Nix throws a conflict at eval time, and the oracle cannot see it (it is a value conflict, not a type error). A plan-time lint catches it: detect multiple assignments to the same AttrPath across modules in one file, warn unless priorities disambiguate. Cheap, and it closes the gap. Implemented in the pipeline (detect_conflicts), surfaced as a CLI warning.
