Architecture
Pipeline
Section titled “Pipeline”KDL inputs -> parse (knixl-kdl, the kdl crate + miette spans) -> per-node schema validation (knixl-modules: NodeSchema) -> dispatch to modules by node name (Registry) -> lower to bucketed Assignments (Module::lower) -> oracle check every emitted option path (knixl-oracle) -> group buckets into files, wire imports, hoist lets -> emit Nix source (knixl-ir: Emit) -> format with pinned nixfmt (knixl-nix) -> hash (blake3), reconcile against lock (knixl-lock) -> write generated/*.nix + knixl.lock.kdlWhen the project declares system {}, the pipeline additionally generates generated/flake.nix, an optional locked artefact defining per-host NixOS configurations, each pinned to that host’s baseline nixpkgs rev. It is reconciled and hashed like the host modules. With input nodes declared (ADR 0014) the flake takes its inputs pinned from knixl.lock.kdl and builds with nixpkgs.lib.nixosSystem; the flake.lock nix writes beside it is derived from those pins and only compared by check, never hashed.
The whole thing is a pure function from (KDL, tool version, module versions, formatter version, oracle rev) to output bytes. Plan::compute runs everything up to “write” and produces a diff; the commands decide whether to write.
The “hoist lets” step is knixl-ir::hoist: within a file, a compound value (attrset, list, or indented string) that appears two or more times is bound once at the top as let _knixl0 = ...; in { ... } and referenced at each use. It is a pure IR-to-IR pass, deterministic, and a no-op on files with no repetition, so it never changes output unless there is genuine duplication. The _knixlN names seen in generated files come from here. The rule is defined in docs/superpowers/specs/2026-07-14-let-hoisting-design.md. examples/hosts/shared.kdl, together with the stdlib module crates/knixl-modules/stdlib/security-headers/knixl-module.kdl, demonstrates it end to end: one security-headers block emitted at two vhosts produces identical assignments that hoist into a single shared binding.
Crate layout and dependency direction
Section titled “Crate layout and dependency direction”Strictly one direction. No crate imports knixl.
knixl-ir: IR types (NixExpr,NixModule,Assignment), theEmittrait, escaping/float/attr-key helpers. Depends on nothing butmietteandsemver.knixl-kdl: input parsing over thekdlcrate, span-carrying diagnostics.knixl-oracle:nixosOptionsDocextraction and best-effort type checking. Depends onserde_json.knixl-modules: theModuletrait,Registry, built-in modules, and the declarativeEmitTemplateinterpreter. Depends onknixl-ir+knixl-kdl+knixl-oracle.knixl-lock: lockfile model and the reconcile state machine. Depends onknixl-nix(for hashing).knixl-nix: formatter invocation (pinned nixfmt) and blake3 hashing.knixl-pipeline: the single generation entry point (gather, dispatch, lower, emit, format, install/strategy helpers). Depends onknixl-ir+knixl-kdl+knixl-modules+knixl-nix+knixl-lock+knixl-oracle.knixl: arg parsing, orchestration, exit codes. Depends on everything.
Keeping the library layers free of the CLI is deliberate: a language server or a GitHub Action can reuse Plan::compute and the emitter without dragging in clap or process handling.
The IR is a constrained subset of Nix, on purpose
Section titled “The IR is a constrained subset of Nix, on purpose”knixl only ever emits module bodies: attribute assignments into option paths, imports, the occasional let, plus a verbatim escape. It does not represent all of Nix. Trying to is the dhall-nix trap (see docs/00).
Two deliberate IR omissions: no dynamic/interpolated attr keys in the value AST (keeps output static and hashable), and condition + priority compose in one fixed order (mkIf cond (mkForce x)) rather than an arbitrary wrapper stack. A general stack is over-engineering for v1.
See crates/knixl-ir/src/ for the full types. The shape:
NixExpr: the value language (scalars,List,AttrSetover aBTreeMapso key order is deterministic by construction,Apply,Lambda,Let,Select,Rawescape).NixModule: not aNixExpr. It has a fixed shape (formals, imports, lets, body, provenance) always emitted the same way.Assignment: one option assignment, with optionalpriority(mkForce/mkDefault/mkOverride), optionalcondition(mkIf), and a doc comment.
Determinism rules (load-bearing)
Section titled “Determinism rules (load-bearing)”- No
HashMapiteration in emit paths. UseBTreeMapor index-preserving structures. AttrSetkeys are sorted by construction (BTreeMap<AttrKey, _>).- Lists preserve KDL source order, so repeated children and
for-eachoutput are stable. fmt_nix_floatalways emits a decimal point (Rust’s default drops it on whole numbers, which Nix reads as int) and rejects non-finite (Nix has no inf/nan).- The formatter and oracle rev are pinned in the lock. Output is only reproducible relative to those pins.
