Skip to content

Reproducibility and the taint model

This is where most of the engineering goes. The KDL parsing is the easy part.

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.

knixl.lock.kdl, KDL for grep-and-diff friendliness (the Nixtamal ethos). It records:

  • tool version
  • formatter name + version
  • oracle nixpkgs-rev + options-hash
  • per input file: blake3 hash
  • per module used: name + version
  • per output file: blake3 hash, which input it came from, which modules produced it
  • per host (nested): pin lines (a package’s version pin: package, version, resolved nixpkgs-rev, and strategy) and a baseline line (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”.

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.

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: generate applies it silently.
  • A version moved and would change output: that is a potential regression. generate refuses (exit NeedsAck) and points at upgrade. upgrade shows 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.

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.