ADR 0008: Out-of-tree oracle modules
Status: accepted
Refines: ADR 0003 (validate against real NixOS options) and ADR 0007 (per-host baseline nixpkgs rev). Relates to: ADR 0005 (pinning).
Refined by: ADR 0014 (accepted, 2026-09-29): an oracle-modules entry may name a declared
flake input, so one declaration drives both validation and the host’s imported modules.
Context
Section titled “Context”The oracle (ADR 0003) validates every emitted option path against the NixOS option set for
one pinned nixpkgs rev. That set is exactly what nixosOptionsDoc produces from nixpkgs
itself, so a module that targets an option from an out-of-tree NixOS module, e.g. disko’s
disko.devices.* or sops-nix’s sops.*, has no home in it: the path is genuinely unknown to
nixpkgs alone, and the oracle refuses it as UnknownOption even though the generated Nix would
evaluate cleanly once the real system imports that module. A fleet that wants knixl to validate
disko or sops-nix output has to either turn the oracle off for those paths or accept
false positives.
ADR 0007 already lifted the oracle from one global rev to one rev per host. The same per-host split is needed for module sets: a fleet migrating host by host onto disko does not want every host augmented with it at once.
Decision
Section titled “Decision”The oracle’s option set spans nixpkgs at its pinned rev plus every declared, pinned out-of-tree module.
knixl.kdldeclares the project’s defaults: an optionalnixpkgs release="<rel>"(the project-wide baseline release, mirroring a host’s own) and an optionaloracle-modulesblock, eachmodule "<name>" flake="<ref>" [attr="<attr>"]naming a flake to pull a NixOS module from (attrdefaults to"default").- A host may override the set, in full, but only alongside its own baseline: a host’s own
oracle-modulesblock replaces the project’s default for that host (no merging); declaring one requires the host to also declare its ownnixpkgs release="<rel>"(ADR 0007), because the per-hostbaselineline is the only place in the lock able to carry per-host module pins. A host that declaresoracle-moduleswith no declared release is refused with a clear error (exit 5): there is nowhere to store what would be resolved. - Resolution is at pin time only (
install/upgrade), mirroring ADR 0005/0007: each declared flake ref resolves to a{url, rev}pair (git ls-remoteby default,KNIXL_MODULE_RESOLVERoverrides), recorded as anoracle-moduleline under the project’soracle(the default set) or a host’s ownbaseline(an override).generate/checkstay offline; a change to the declared set is anupgrade-gated event, never a silentgenerate. - The augmented
options.jsonis built, not hand-populated:install/upgraderunnixosOptionsDocover the pinned nixpkgs rev plus each resolved module (as a flake’snixosModules.<attr>), cache the result keyed by the effective set (the rev and the module pins together, order-sensitive), and record the built content’s hash as anoptions-hashalongside the existing rev. Fetching the BASE (no-modules) set stays the manual step it already was (docs/06); only the augmented build is new. A missing nix is best-effort (a warning, unless--strict); a nix that runs but fails to build the declared set is always a hard error, since that means the declared module set itself is broken, not merely unverified. - Planning picks the effective set per host: a host without its own override validates
against the project’s default module set; one with an override validates against its own.
Either way the set is looked up from the lock’s already-resolved pins, keyed by the same
(rev, modules) pair the build used, so
generate/checkneed neither nix nor the network.
Consequences
Section titled “Consequences”- A declarative module can target an out-of-tree option once its flake is declared: the escape-hatch and curated-preset paths (ADR 0003) both extend to disko, sops-nix, and similar modules, validated the same way nixpkgs’ own options are.
- The reproducibility boundary widens again: a module’s
url/rev/attrjoin the nixpkgs rev and options hash already there, per project default and per host override. Two projects pinned to the same rev and module set share the same cache entry; either project changing either one is a real,upgrade-gated change. - The host-override-requires-a-release rule is a real modelling constraint, not a simplification to relax later without another lock-shape decision: there is no project-wide place to carry a per-host module pin.
- Still deferred: merging (rather than replacing) a host’s module set against the project default, and validating the module flakes themselves (a module that fails to resolve or build is refused, but nothing here checks the module’s own option set is sane before that).
