Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Overview

This is the documentation for the framework’s design and API.

New here? Getting started walks through a first flake step by step; Choosing a flake framework places caisson relative to plain flake-parts, flakelight, and snowfall-lib; the FAQ answers the questions the concept pages tend to raise.

Reference documents the exported surface: the Library functions and the Options.

Conventions: Repository layout describes the directory conventions the documentation and examples assume.

Concepts explain the machinery and the reasoning behind it, in reading order:

  • Closed inputs: how modules and library overlays close over the defining flake’s inputs, and the explicit closure convention every registered thing follows.
  • Module classes: class-keyed module registration and export.
  • Library overlays: namespaced, dependency-declaring lib composition, and the patterns for writing overlays.
  • Ecosystem sources: why integrations pin nothing, and the places a source can come from.

Deep dives: How lib is composed traces a mkLib call from arguments to finished attrset, including the rules that decide conflicts and composing through caisson-core directly. How inputs are closed over traces the closure attrset from mkLib to a registered file, including what each kind of registration receives and which values cross flake boundaries.

Guides: Testing covers unit and integration testing (including callConsumerFlake), and Evaluation weight covers measuring and gating evaluation cost.

For a complete working flake with commentary, see examples/literate-flake/ in the repository. The repository README covers the quick start.


Despite the org name, caisson is an independent project and is not affiliated with, endorsed by, or sponsored by the NixOS Foundation. Nix and NixOS are trademarks of the NixOS Foundation.

Getting started

A step-by-step first flake: create it, add a library overlay, register a module, use an integration, then consume your flake from a second flake. The finished shape of each step also exists as a working flake under examples/literate-flake/ in the repository, with commentary.

1. A minimal caisson flake

Create a directory with this flake.nix:

{
  description = "my first caisson flake";

  inputs = {
    caisson.url = "github:nix-caisson/caisson";
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    flake-parts.url = "github:hercules-ci/flake-parts";
    flake-parts.inputs.nixpkgs-lib.follows = "nixpkgs";
  };

  outputs =
    inputs@{ caisson, ... }:
    let
      lib = caisson.lib.caisson.mkLib {
        inherit (caisson.lib.caisson.pins.flake inputs) sources root;
        name = "my-flake";
        systems = [ "x86_64-linux" ];
        projects = {
          inherit caisson;
        };
        configs = lib: lib.caisson.mkModules ./configs;
      };
    in
    lib.caisson.flake-parts.mkTopConfiguration { };
}

lib.caisson.mkLib composes a library from the lib of nixpkgs, the functions and registries of caisson under lib.caisson (where each integration also has a namespace), and the overlays you register. name is the project’s name: the flake configuration takes it as its name, and your overlays contribute to the composed library under it (lib.my-flake). systems, the platforms the flake builds for, is declared here too; flake-parts reads it from the composition. Consuming caisson as a project registers everything it exports, its integrations included, which contributes lib.caisson (a namespace per integration target); lib.caisson.flake-parts.mkTopConfiguration then evaluates flake-parts with that library and your config module, the configuration registered under the project’s name (configs/flake/my-flake). flake-parts comes from the flake-parts input of the flake, like every ecosystem caisson wraps: the integration calls that source with the composed library, so the evaluation runs on the same nixpkgs lib the rest of the composition does.

The config module is the top-level configuration of the flake, and it is registered. lib.caisson.mkModules ./configs reads configs/<class>/<name>/default.nix into the configs registration, and the configuration is then lib.caisson.configs.flake.my-flake. Create configs/flake/my-flake/default.nix:

{ ... }:
{ pkgs, ... }:
{
  perSystem =
    { pkgs, ... }:
    {
      packages.default = pkgs.hello;
    };
}

Note the two argument lists: every registered file takes the closure attrset ({ closure-inputs, ... }) first, then its ordinary module arguments. That convention is the subject of Closed inputs.

Check it:

nix flake check
nix build

2. Add a library overlay

An overlay contributes a namespace to the composed library. Create lib-overlays/default/default.nix:

{ ... }:
{
  imports = [ ];
  overlay = final: prev: {
    my-flake = (prev.my-flake or { }) // {
      greet = name: "hello, ${name}";
    };
  };
}

Register it in flake.nix and export it, and turn on the lib export in the config module. lib.caisson.mkLibOverlays ./lib-overlays reads lib-overlays/<name>/default.nix into the libOverlays registration:

      lib = caisson.lib.caisson.mkLib {
        inherit (caisson.lib.caisson.pins.flake inputs) sources root;
        name = "my-flake";
        systems = [ "x86_64-linux" ];
        projects = {
          inherit caisson;
        };
        configs = lib: lib.caisson.mkModules ./configs;
        libOverlays = lib: lib.caisson.mkLibOverlays ./lib-overlays;
      };
  caisson = {
    libOverlays.exported = libOverlays: { inherit (libOverlays) default; };
  };

Now lib.my-flake.greet is available everywhere the composed library flows: in the config module, in registered modules, and to consumers in the lib output of the flake, which is the composed library. Use it in perSystem:

      packages.default = pkgs.writeText "greeting" (lib.my-flake.greet "Nix");

3. Register a module

Modules are class-keyed: flake modules feed flake-parts, and integration classes (nixos, homeManager, …) feed their module systems. lib.caisson.mkModules ./modules reads modules/<class>/<name>/default.nix into the registration, the first directory level being the class. Register a flake-class module by creating its directory:

      lib = caisson.lib.caisson.mkLib {
        inherit (caisson.lib.caisson.pins.flake inputs) sources root;
        name = "my-flake";
        systems = [ "x86_64-linux" ];
        projects = {
          inherit caisson;
        };
        modules = lib: lib.caisson.mkModules ./modules;
        configs = lib: lib.caisson.mkModules ./configs;
        libOverlays = lib: lib.caisson.mkLibOverlays ./lib-overlays;
      };

modules/flake/default/default.nix:

{ ... }:
{ ... }:
{
  perSystem =
    { pkgs, ... }:
    {
      devShells.default = pkgs.mkShell { packages = [ pkgs.nixfmt ]; };
    };
}

mkConfiguration applies the selected flake-class modules alongside the config module: moduleImports returns the list to apply, like libOverlayImports, and when it is omitted every registered entry named default applies, here this flake’s default and caisson/default (the default module of caisson, which carries the nixpkgs integration’s module layer). The entries named core apply to every evaluation of the class regardless. Module classes covers registration, selection, and export.

4. Use an integration

Integrations bring the same conventions to other module ecosystems and take their ecosystem as an explicit ecosystemSrc, a function of the tree’s sources. A NixOS configuration takes its systems and its package set from the composition: it is evaluated per system, once for each system mkLib declares, on the package config named default, which the mkLib call declares with pkgSets = lib: { default = lib.caisson.nixpkgs.mkConfiguration { }; };. With the single system declared above, mkTopConfiguration returns the evaluated configuration. At the top level of the config module:

  flake.nixosConfigurations.example = lib.caisson.nixos.mkTopConfiguration {
    ecosystemSrc = sources: sources.nixpkgs;
    configModule =
      { ... }:
      {
        boot.loader.grub.enable = false;
        fileSystems."/" = {
          device = "none";
          fsType = "tmpfs";
        };
        system.stateVersion = "25.05";
      };
  };

Instead of passing ecosystemSrc at every call, the mkLib call can declare a default for the nixos key, the key a NixOS configuration reads (defaultEcosystemSrc = sources: { nixos = sources.nixpkgs; };), and the argument can be dropped; an explicit argument still wins, and the source named exactly nixos in the sources passed to mkLib is the last fallback. See Ecosystem sources.

With caisson consumed as a project, its integration overlays are already registered and applied, so caisson.nixos is present. To compose only some of them, keep the project registration and select per item over the combined dictionary:

        libOverlayImports =
          lib: with lib.caisson; [
            libOverlays."caisson/flake-parts"
            libOverlays."caisson/nixos"
            libOverlays.default
          ];

The library reference documents the integration namespaces.

5. Consume your flake from another flake

A consumer registers your exported overlay the same way:

{
  inputs = {
    caisson.url = "github:nix-caisson/caisson";
    my-flake.url = "github:you/my-flake";
  };

  outputs =
    inputs@{ caisson, my-flake, ... }:
    let
      lib = caisson.lib.caisson.mkLib {
        inherit (caisson.lib.caisson.pins.flake inputs) sources root;
        name = "consumer";
        systems = [ "x86_64-linux" ];
        projects = {
          inherit caisson my-flake;
        };
        configs = lib: lib.caisson.mkModules ./configs;
      };
    in
    lib.caisson.flake-parts.mkTopConfiguration { };
}

The consumer’s composed library now has lib.my-flake.greet: the project registration brings in the overlays you exported, each overlay’s imports chain guarantees anything it depends on composes with it, and your exported modules land in the consumer’s registry under my-flake/<name>, selectable at each use site. A consumer who wants only part of your project selects with libOverlayImports, as shown above; the my-flake.modules.<class>.<name> flake outputs serve consumers who import modules without composing anything.

Where next

  • Closed inputs, the convention every registered file follows.
  • How lib is composed: the whole composition pass, and caisson-core, the layer lib.caisson is built on.
  • Testing, including callConsumerFlake for testing consumer flakes without a push/lock cycle.
  • FAQ for the questions this page tends to raise.

Choosing a flake framework

Where caisson sits relative to plain flake-parts, flakelight, snowfall-lib, and the dendritic pattern, characterized from the documentation of those projects. The honest summary first: all of them produce working flakes, and the differences are about which conventions you want enforced by machinery rather than by discipline.

Plain flake-parts

flake-parts is a minimal module system mirroring the flake schema: it splits configuration into modules, handles perSystem, and deliberately avoids broader opinions, positioning itself as “a single module that other repositories can build upon” with an ecosystem of independent compatible modules.

caisson is built on flake-parts and keeps all of it. What it adds is a set of enforced conventions on top: closed inputs (every registered file takes an explicit closure argument list instead of reaching for inputs ambiently), namespaced library overlays with declared dependencies composed by caisson-core, class-keyed module registration and export, integrations that take their ecosystems as explicit ecosystemSrc arguments, and measured evaluation-cost gates. Use plain flake-parts when you want the module system and your own conventions; use caisson when you want these conventions machine-enforced, particularly across several flakes that consume each other’s libraries and modules.

flakelight

flakelight is a module-driven framework emphasizing automation: sensible defaults, automatic import of nix files from a directory, and auto-generated outputs (packages, overlays, formatters), with the stance that what can be done automatically, should be.

caisson leans the other way: registration is explicit, namespaces are explicit, dependencies between overlays are declared, and nothing is inferred from file layout. If you value minimal ceremony in a single project, flakelight gets a working flake with fewer lines. If you value being able to trace any attribute of a composed library to a declared registration, especially across a fleet of interdependent flakes, that explicitness is caisson’s point.

snowfall-lib

snowfall-lib generates systems, packages, modules, and shells from directory-structure conventions: predictable filesystem hierarchies in exchange for eliminated boilerplate, targeting multi-system NixOS and nix-darwin setups. Its repository currently describes it as seeking new maintainers.

The comparison is similar to flakelight but stronger: snowfall infers the most from layout, caisson infers nothing from layout. caisson’s integrations also differ structurally from a generator: they are thin adapters over each ecosystem’s evaluator, taking the ecosystem as an explicit source argument and pinning nothing.

The dendritic pattern

The dendritic pattern is an organizational discipline over flake-parts rather than a framework: every Nix file except the entry points is a module of the top-level configuration, each file implements one feature across all the configurations it touches, and lower-level modules (NixOS, home-manager, nix-darwin) live as deferredModule values inside the top-level config, merged by name. Files are commonly auto-imported with import-tree, and cross-cutting values are read from the shared top-level config instead of specialArgs threading.

caisson agrees with more of this than with the generators above: both build on flake-parts, both eliminate ambient specialArgs plumbing (dendritic through the shared top-level config, caisson through closed inputs and the composed library), and both group modules by the module system they belong to (dendritic by option path, caisson by class key). The differences are scope and mechanism. Dendritic organizes one repository’s configurations by feature and, with import-tree, derives the import set from the file tree; caisson registers modules and overlays explicitly and infers nothing from layout. Dendritic keeps everything inside a single module evaluation; caisson separates library composition from module evaluation and adds export machinery so several repositories can publish and consume each other’s overlays and modules. Use dendritic to structure one flake’s configurations by aspect with almost no machinery; use caisson when the unit of reuse is a repository and the conventions need to hold across a fleet.

What is caisson-specific

Independently of the convention trade-offs above, these things are distinctive here rather than variations on a shared theme:

  • Library composition with identity (deep dive): dedup, wholesale replacement, and reliable polyfills, implemented in caisson-core, a zero-dependency flake usable without caisson.
  • Explicit ecosystem sources: integrations pin none of their ecosystems; the consumer hands each in, so a caisson revision works with any nixpkgs, home-manager, or colmena revision with a compatible evaluation contract.
  • Evaluation-weight gates (guide): framework overhead is measured and held to committed ceilings in CI rather than described.

The relationship to flakes

Flakes today do acquisition (fetching, pinning, integrity) and composition (deciding which copy of each dependency an evaluation actually uses, via the follows pin bucket). caisson separates them. Flakes keep acquisition. Composition moves to the evaluation layer, with real semantics: deduplication is key identity, override is wholesale replacement of a keyed entry, local patches are the keyless tail, and ecosystems (nixpkgs, home-manager, and the rest) are handed in as explicit ecosystemSrc arguments instead of being re-pinned and re-wired through the input graph.

Everything caisson adds is published through the output the flake schema leaves freeform, lib: composed libraries, the module registry, and the manifest (the composition’s self-description) all live there, and the remaining flake outputs (modules.<class>, libOverlays, per-system products) are projections from it that keep the standard schema’s addresses. A flake built this way needs only a small, regular subset of the flake schema; nothing about it requires upstream changes to evaluate.

FAQ

What goes wrong without caisson that this fixes?

Caisson addresses major failure patterns seen in poly-flake setups:

First, input explosion: a flake-parts module that uses inputs.foo works only if every downstream flake also pulls in foo. This ends up meaning that each level of a dependency tree has to re-mention all of its transitive dependencies, either directly or in the form of a bunch of “follow” pins, or else you get an explosion of flake versions.

Second, collisions: Overlays that write top-level attributes tend to fight over the same flat namespace.

What is closure-inputs, and who sets it?

closure-inputs is the sources your flake passes to mkLib: its pinned inputs, under their input names and with no self. inherit (caisson.lib.caisson.pins.flake inputs) sources root; in the mkLib call passes them. The caisson framework threads it in: mkLibOverlay and mkModule apply it to each registered file as the file’s first argument list. A file always receives the sources of the flake that registered it: an overlay or module consumed from another flake sees the sources of the flake it came from, not those of the flake consuming it. Closed inputs is the full convention.

What happens when two overlays define the same thing?

Composition is done via an ordered pass across the specified overlays, traversing dependencies in a depth-first, postfix manner. For attrSets and lists, following the conventions results in a merge. For atomic attributes, contentions mean that the later overlay’s definition wins. I say “convention” because overlays are actually capable of addressing their predecessor directly, so they can technically implement whatever merging logic they deem appropriate.

Can I adopt this incrementally in an existing flake-parts flake?

Yes. lib.caisson.flake-parts.mkTopConfiguration wraps the mkFlake of flake-parts, and plain flake-parts modules work unchanged. It is recommended to start by composing a library with lib.caisson.mkLib. You can hand your existing top-level module to mkTopConfiguration and let the conventions spread file by file from there.

What does lib.caisson.flake-parts.mkModule do to my module?

A few things:

  • applies the closure argument list, so the module can use closure-inputs
  • records the file’s path as _file, for better error messages
  • gives path-registered modules a deduplication key.

Do I pin nixpkgs, home-manager, and the rest myself?

Yes, you manage the ecosystem pins in your own flake, which is the point: a caisson integration is glue over the ecosystem’s evaluator, composing with whatever ecosystem source version you give it, so caisson imposes no transitive pins and two consumers of the same caisson revision can run different nixpkgs revisions. Version skew within your ecosystems lives in your locks, where you can see and manage it. A flake that uses one version of an ecosystem everywhere can set a flake-level default at mkLib, a function of sources (defaultEcosystemSrc = sources: { nixos = sources.nixpkgs; };). It is also possible to pass ecosystemSrc, also a function of sources, explicitly on each caisson call. The explicit argument wins, and a source named exactly like the key is the final fallback (handy for leaf nodes).

What does this cost at evaluation time?

Measurements show: relatively little. The eval-weight harness runs in CI and holds caisson’s overhead (a minimal caisson consumer minus a raw flake-parts flake) to ceilings on deterministic counters: thunks, values, allocations, and full nixpkgs, nixpkgs-lib, and module-system evaluation counts. The overhead is orders of magnitude below a single nixpkgs evaluation. Evaluation weight documents the harness and the current numbers.

How mature is this?

Pre-release. At the time of writing, it is used heavily by the author and by no one else.

Library Reference

A composed library carries the framework namespaces lib.caisson-core and lib.caisson. lib.caisson-core holds the machinery, injected by mkLib itself (its code lives in caisson-core, which caisson pins internally). lib.caisson holds the integrations and the pkgs-dependent tooling, contributed by the overlays this flake exports. The lib output of the caisson flake is its composed library, so both namespaces are in it (caisson.lib.caisson-core, caisson.lib.caisson).

Type notation used below:

  • lib: a composed nixpkgs-style library attrset
  • module: a module for some module class’s module system
  • overlayFn: final: prev: attrs, the standard overlay function
  • libOverlay: { imports : listOf libOverlay; overlay : overlayFn }, the built overlay produced by mkLibOverlay (both keys always present)
  • path arguments are imported before the rules below apply

The caisson-core namespace

  • Source: caisson-core’s lib/default.nix (the compose primitive, and the composition of the entries below) and lib-overlays/<name>/default.nix (compose, resolve, lifecycle, readers, lists, attrsets, strings, functions). caisson-core is a composition of those entries. mkLib composes them into the library it builds under the keys caisson-core/<name>, and an entry registered under the same key replaces the entry of that key.

mkLib

mkLib :
  { sources           : attrs                              # the tree's pinned sources, as a pin reader returns them
  , root              ? null : root                        # the tree's identity; null for a composition that is not a top
  , name              ? null : str                         # the project's name, also the namespace it contributes
  , modules           ? (lib: { })
                      : lib -> attrsOf (attrsOf module)    # class -> name -> module
  , configs           ? (lib: { })
                      : lib -> attrsOf (attrsOf module)    # class -> name -> configuration
  , libOverlays       ? (lib: { })
                      : lib -> attrsOf libOverlay          # given the core lib
  , libOverlayImports ? (lib: <every project and local registration>)
                      : lib -> listOf libOverlay           # given the core lib
  , extraLibOverlayImports ? (lib: [ ])
                      : lib -> listOf libOverlay           # given the core lib
  , pkgOverlays       ? (lib: { })
                      : lib -> attrsOf pkgOverlay          # given the bootstrap lib
  , pkgSets           ? (lib: { })
                      : lib -> attrsOf configuration       # package configs by name, given the registered lib
  , defaultEcosystemSrc ? (sources: { })
                      : sources -> attrs                   # the tree's default source per ecosystem key, as a function of sources
  , systems           ? null : listOf str                  # the platforms the tree builds on
  , projects          ? { } : attrs                        # consumed upstream contributions, by project name
  } -> lib

Builds a composed library over the seed, the empty attribute set: the caisson-core entry (registered under that name, so a registration under the same name replaces it), the selected registered overlays, then the synthetic overlays: the local module registrations (so local names win over overlay-borne contributions) and the manifest. In a flake that uses caisson, the selected overlays include the nixpkgs-lib entry, which is the lib of nixpkgs loaded from the source ecosystemSrc "nixpkgs-lib" resolves: the source defaultEcosystemSrc sources returns for the nixpkgs-lib key, else the source pinned under exactly that name in sources. The caisson integration overlays import it. Every source arrives as an argument or a declaration; the exact-name fallback over sources applies to ecosystem resolution only (see Ecosystem sources).

The library is built in stages, each a new fixpoint over the seed with a manifest in lib.caisson-core.libManifest. Each stage exists because some argument is a function of it:

  • The core lib holds the caisson-core entries and nothing else, with the lib overlay registry grafted onto its manifest. libOverlayImports receives it.
  • The bootstrap lib adds the selection, and with it the nixpkgs-lib entry and every integration namespace. modules and configs receive it; its manifest has no modules, moduleProjects, configs, pkgOverlays or pkgSets.
  • The registered lib is the same entries with the module registrations, the configurations and the package overlay registry grafted on. pkgSets receives it, since a package config selects from those registries; its manifest has no pkgSets. It is built only when a package config is read.
  • The full lib adds pkgSets. mkLib returns it.

The core, bootstrap and registered manifests have childless = true. A registration made at an earlier stage still closes over the full lib. The constructors the earlier libs hold (mkModule, the integrations’ mkModule, mkLibOverlay, mkPkgOverlay) give the entry the full lib as closure-lib. The manifest of that lib holds the registry the entry joins, which closure-lib.caisson.modules.<class> reads, so a module imports a sibling by name. A registration under a caisson-core/<name> key replaces that entry from the bootstrap stage on; the core lib keeps the original.

sources and root come from a pin reader (see pins below); at a flake top, inherit (caisson-core.lib.caisson-core.pins.flake inputs) sources root;. The registered overlays and modules close over sources as closure-inputs, so the closure holds the pinned trees under their input names and no self. The signature is the pattern of mkLib, with no ...: a missing or unexpected argument, a leftover inputs included, is Nix’s error at the call site, naming mkLib and pointing at the pattern.

  • modules receives the bootstrap lib and returns the class-keyed registration, built with the integrations’ mkModule helpers (lib.caisson.nixos.mkModule, lib.caisson.flake-parts.mkModule, and so on). lib.caisson-core.mkModule "<class>" is for a class no integration covers. lib: lib.caisson-core.mkModules ./modules reads the conventional layout.
  • configs receives the bootstrap lib the same way and returns the configurations of the tree, keyed by module class then name, the layout configs/<class>/<name> on disk (colmena’s class is caisson-colmena); lib: lib.caisson-core.mkModules ./configs reads that layout. They come back in the manifest, which lib.caisson.configs.<class>.<name> reads, so a top and a configuration that evaluates another beneath itself reach them by name rather than by a path out of their directory.
  • libOverlays receives the core lib and returns the registered overlays. An entry is made with lib.caisson-core.mkLibOverlay, which closes it over the sources of the composition; lib: lib.caisson-core.mkLibOverlays ./lib-overlays reads the layout.
  • pkgOverlays receives the bootstrap lib and returns the registered package overlays. An entry is made with lib.caisson-core.mkPkgOverlay; lib: lib.caisson-core.mkPkgOverlays ./pkg-overlays reads the layout (see pkgOverlays below). These four arguments take exactly the function shape shown; passing anything else is an error.
  • pkgSets receives the registered lib and returns the package configs of the tree by name, each a configuration an integration’s constructor built: pkgSets = lib: lib.caisson.nixpkgs.mkConfigurations { }; declares a package config for every configuration in configs/nixpkgsConfig (see caisson.nixpkgs below). mkLib finalizes each with the name it is declared under and the registered manifest as its parent, and records them in the manifest’s pkgSets; the flake reads its package sets from there.
  • libOverlayImports selects which registered overlays apply to the lib of this flake. It receives the core lib and names entries from the libOverlays registry described below. libOverlayImports = lib: [ lib.caisson.libOverlays.my-overlay ]; selects the entry registered as my-overlay. The default selects the project and local registrations, and a selection given here replaces it.
  • extraLibOverlayImports has the same form and adds to the selection, whichever it is: a flake that names a further entry with it keeps the default. Registration also feeds export, so what is registered and what is selected can differ.
  • defaultEcosystemSrc is a function of sources returning the tree’s default source per ecosystem key, keyed by the exact names the integrations resolve (sources: { nixpkgs-lib = sources.nixpkgs; nixos = sources.nixpkgs; } gives the library and the NixOS module collection from a checkout pinned under the nixpkgs key). An attribute set given in place of the function is refused. mkLib records what the function selects in the manifest as defaultEcosystemSrc, and the name of each selected source among sources as defaultEcosystemSrcNames, null for a value the function builds in another way. The nixpkgs-lib entry it composes reads its source through ecosystemSrc "nixpkgs-lib", the same resolution every ecosystem uses; the integrations interpret the rest.
  • projects consumes whole upstream contributions ({ my-dep = inputs.my-dep; }). A project contributes the dictionaries libOverlays, pkgOverlays and class-keyed modules. For a flake built with caisson, mkLib reads these dictionaries from the manifest of the flake, at lib.caisson-core.evalManifest in the lib output, where they are under outputs.exports. mkLib ignores the other outputs of that flake. A value with no manifest is read as an attribute set that holds these dictionaries itself. A project’s overlays and package overlays join their registries and its modules join the class registry under <project>/<name>, so the existing selections keep per-item choice: libOverlayImports decides which overlays apply, the registry selection at each use site decides which modules load, and a local registration beats a same-named project entry. Registering a single overlay by hand is the way to cherry-pick or rename it.

mkModules, mkLibOverlays, mkPkgOverlays

mkModules     : path -> attrsOf (attrsOf module)   # <dir>/<class>/<name>/default.nix
mkLibOverlays : path -> attrsOf libOverlay         # <dir>/<name>/default.nix
mkPkgOverlays : path -> attrsOf pkgOverlay         # <dir>/<name>/default.nix

The directory readers. A reader belongs to the lib it is read from: it takes a directory and returns the registrations, built with the entry constructors of that lib. Each registry function of mkLib receives a lib, so a tree with the conventional layout takes the reader from it and names the directory:

modules = lib: lib.caisson-core.mkModules ./modules;
configs = lib: lib.caisson-core.mkModules ./configs;
libOverlays = lib: lib.caisson-core.mkLibOverlays ./lib-overlays;
pkgOverlays = lib: lib.caisson-core.mkPkgOverlays ./pkg-overlays;

The readers are the registered entry caisson-core/readers, so a composition that registers another entry under that name reads its directories with that entry.

mkModules reads the first directory level as the class, whatever its name, and registers each entry directory through mkModule of the lib, given the name of that directory as the class. caisson keeps no list of classes: the integration that evaluates a class names it in its declaration. mkLibOverlays applies mkLibOverlay, and mkPkgOverlays applies mkPkgOverlay. An entry is a directory holding a default.nix, a symlink to such a directory included; anything else in a directory being read is an error, so a stray file cannot silently vanish from a registry. A tree with another layout writes the registration by hand.

pkgOverlays, mkPkgOverlay, pkgOverlaysFor

mkPkgOverlay   : freeformOverlay -> pkgOverlay
pkgOverlay     : { key; imports : listOf pkgOverlay; overlay : final -> prev -> attrs; origin; project; }
pkgOverlaysFor : listOf pkgOverlay -> listOf (final -> prev -> attrs)

The package overlay registry, recorded as caisson-core.libManifest.pkgOverlays. An entry has the lib overlay entry’s shape with a nixpkgs overlay under overlay: a file handed to mkPkgOverlay takes the closure { closure-inputs, closure-lib, mkPkgOverlay, ... } and returns { imports ? [ ], overlay }. An entry imports a sibling from the registry of the composition that registered it, closure-lib.caisson-core.libManifest.pkgOverlays.<name>:

# pkg-overlays/default/default.nix
{ closure-lib, ... }:
{
  imports = [ closure-lib.caisson-core.libManifest.pkgOverlays.extra ];
  overlay = final: prev: { my-project = prev.callPackage ./my-package.nix { }; };
}

Every registered entry carries its registry name as key, the file it was read from as origin (null for an entry built from a function), and project: null for a local registration, the contributing project’s name for an entry from projects, so the local entries alone are a filter on that field. A project’s entries join under <project>/<name>; a key the project registered itself (a key without a /) becomes <project>/<key> in its imports too, so an import still meets its sibling, and a key naming another project’s entry (a key with a /) is kept, so two projects importing the same entry import one entry.

pkgOverlaysFor turns a selection, a list of registry entries, into the list of nixpkgs overlays a package set applies: each entry after the entries it imports, each key once where it first occurs. One key reached through two paths is one entry when both carry the same origin; two entries with different origins under one key are refused. By convention the entries named default (default, <project>/default) are the default selection, as for modules; the layer that builds package sets applies it. caisson-core applies nothing itself.

mkLibOverlay

mkLibOverlay : freeformOverlay -> libOverlay

freeformOverlay = path | (closure -> { imports ? listOf libOverlay
                                     ; overlay : overlayFn })
closure = { closure-inputs     : attrs    # the defining composition's pinned sources
          ; closure-lib        : lib      # the defining composition's lib, lazily bound
          ; mkLibOverlay       : freeformOverlay -> libOverlay
          ; mkModule           : string -> freeformModule -> module
          }

Applies the closure attrset to an overlay given as a function or a path to it, and normalizes the result: the built libOverlay always carries both keys, with imports defaulted to [ ]. Already-built overlays are registered directly rather than wrapped.

The closure’s closure-lib is the composed library of the composition that registered the overlay, bound lazily: read it inside the overlay function or a function it defines, never while the overlay is being registered. An integration reaches the registry of its composition through it (closure-lib.caisson.modules.<class>), wherever it is later composed. The closure’s mkModule is bound to the defining composition, so modules an overlay makes close over the definer’s inputs and library. A lib overlay does not add to the registry; see Module classes for the ways modules enter it.

mkModule

mkModule : string -> freeformModule -> module

freeformModule = path | (closure -> module)
closure = { closure-inputs        : attrs    # the defining flake's pinned sources
          ; closure-lib           : lib      # the defining flake's composed lib
          ; mkModule              : freeformModule -> module   # bound to the class
          }

Factory for class-specific module normalizers. Given a class name, returns a normalizer that applies the closure attrset to a module given as a function or a path to it; the module takes the closure as its first arg list ({ ... }: when unused). Plain modules are imported/registered directly rather than wrapped. Path modules gain _file and a path-based dedup key.

The mkModule closure member is bound to the same class, so nested module composition stays in that class. A module imports a sibling by name through closure-lib.caisson.modules.<class>.<name>, the registry of the composition that registered it.

modules

modules : attrsOf (attrsOf module)    # class -> name -> module

The class-keyed module registry of this composition: the registrations of the flake merged with every consumed project’s entries (<project>/<name>), locals winning on name conflicts. It is read from the manifest of the library (caisson-core.libManifest.modules). Integration adapters read their class from here (lib.caisson.modules.<class>): every entry named core (core, <project>/core) is the framework module of the class, forced into every evaluation, and every entry named default is the default default, what an evaluation gets when it passes no moduleImports.

manifest

manifest : { _type : "caisson-manifest"; type : "lib";
             name ? str;
             entries : listOf { key : str; opaque : bool; };
             history : listOf event;
             sources : attrs; root : nullOr root;
             modules : attrsOf (attrsOf module);
             configs : attrsOf (attrsOf module);
             libOverlays : attrsOf libOverlay;
             pkgOverlays : attrsOf pkgOverlay;
             defaultEcosystemSrc : sources -> attrs; systems : nullOr (listOf str);
             projects : attrs;
             childless : bool; inputs : list; parent : nullOr manifest;
             ancestors : listOf manifest; nearest : attrsOf manifest;
             children : attrs }

The composition’s self-description, injected as its final overlay. name is the name argument, and is absent when the composition declares none. entries lists the lib overlay selection in composition order, caisson-core’s forced entries first. An entry is opaque when its key names no registry entry, as with an overlay imported by value; a keyless entry gets a synthesized keyless/<n> key. The lib mkLib returns is the full lib of a root declaration, so childless is false, parent is null, and ancestors, inputs, nearest and children are empty. The manifests of the core, bootstrap and registered libs have childless = true, and the core manifest lists only the caisson-core entries in entries. pkgSets holds the package configs declared in the pkgSets argument, each the manifest its configuration returned, with the registered manifest as its parent. sources, root, defaultEcosystemSrc, systems, projects and configs are the mkLib arguments as given, except that a directory reader’s pin files are stated relative to the root when the directory lies in the root’s tree (pin.dir is kept otherwise); libOverlays, modules and pkgOverlays are the registered dictionaries, so consumed projects’ entries appear under <project>/<name> beside the local registrations, with a local winning a name collision. Each records where an entry came from: a lib overlay or package overlay entry carries project (null for a local registration, the project’s name for a contributed entry, caisson-core for the lib overlay entries caisson-core publishes into every composition), and moduleProjects.<class>.<name> holds the same for modules, beside the module dictionary. The export selectors default to the entries whose origin is null. An mkLib composition self-describes: the composed library of a consumer carries the manifest of that consumer. Checks live on the export side only (the flake-parts integration type-checks it and projects the exports and the flake.modules output from it, so an exported selection can re-export a project-borne entry the same way as a hand-registered entry); a producer validates the manifest it publishes in its CI.

_type = "caisson-manifest" marks the attrset as a manifest, which is how manifestOf recognizes a manifest.

history lists the events recorded on the way to the lib, in stage order, and the history of each stage begins with the history of the stage before it. The core stage records one layer event per caisson-core entry, then the lib overlay registrations. The bootstrap stage adds one layer event per entry it composes that the core stage did not, in composition order: the selection, and a registration replacing a caisson-core entry, which so comes after the entry it replaces. The full stage adds the modules, configs and pkgOverlays registrations. A layer event carries prev and result as the stage that recorded it composed them.

event : { manifest : listOf { type : str; name : str; };   # [ ] for the root lib and the top
          type : str;                                        # "lib", or the integration
          operation : "registry" | "layer" | "declaration";
          key : str; index : int;                            # index within its operation
          origin : { project : nullOr str; file : nullOr str; };
          prev ? attrs; result ? attrs; }                    # layer events only, lazy

origin.project is the project that registered the entry (the composition’s name for a local entry, caisson-core for its entries) and origin.file the file the entry was built from, where there is such a file; a lib overlay built from a file records it as its origin. A layer event also keeps prev and result of its overlay call final: prev: result: prev is the accumulation it received and result the attrset it returned. They are the values the lib was built from, and nothing reads them until definers does.

The manifest of every configuration has a history too. The history of its childless manifest is the history of its parent, then a declaration event for each module its evaluator received, in that order. The history of its full manifest is the history of the childless manifest, then what each configuration beneath adds after the history it began with, so the history of a child always begins with the childless history of its parent. manifest is the name path of the configuration, the { type, name } of each manifest beneath the top, which gives its attribute path under children of the top’s manifest; a configuration evaluated per system has its system as a segment. The key of a declaration event says what the module is:

  • the name of the ecosystem, such as nixos, for the module list of the ecosystem, recorded as a single event, with the source and the file the list comes from (nixpkgs, nixos/modules/module-list.nix);
  • caisson.<class> for the framework module of caisson, and caisson.<class>.<purpose> for each other module an integration adds, such as caisson.nixos.pkgSet;
  • the registry key, such as modules.nixos.default, for a module selected from the registry, with the project that registered it;
  • configs.<class>.<name>, such as configs.nixos.laptop, for the configuration module taken from the registry by the configuration’s name, with the project and the file that registered it;
  • the path, for a module given as a path;
  • inline/<index> for any other module, its position among the events of the evaluation, with the file the module names, where it names a file.

A module recorded as inline/<index> was given to the configuration as a value, through configModule or a selection, rather than by a name. We recommend registering such a module and selecting it by name, so its history says where it came from.

A file inside a source in the Nix store is written relative to that source.

definers

definers : manifest -> listOf str
         -> listOf { key; index; origin; value; position : nullOr { file; line; column; }; }

The layers that define an attribute path, in composition order: the last is the winner and the rest are shadowed. value is the path’s value after that layer. position is where the layer binds the name, from unsafeGetAttrPos, kept only when it lies within the layer’s file, so a name the layer computes (with mapAttrs, say) reports none. A layer that returns prev.x // { ... } carries the names already under x without defining them: a name counts as carried when its binding position is the same in what the layer returned and what it received, or, with no position on either side, its value is equal.

definers lib.caisson-core.libManifest [ "my-project" "greet" ]
# [ { key = "default"; value = <function>; position = { file = ".../lib-overlays/default/default.nix"; line = 6; ... }; ... } ]

manifestOf

manifestOf : any -> nullOr manifest

Finds the manifest in whatever a file returns, so that a tool reading a flakeless top needs only its default.nix. The value may be:

  • a manifest, returned as it is;
  • an attrset carrying a manifest at caisson.manifest;
  • an evaluated configuration carrying a manifest at config.caisson.manifest;
  • a composed library carrying the phase manifests (libManifest, pkgsManifest, evalManifest under caisson-core), or a package set carrying them in pkgs.lib. The last of them filled in is the manifest: evalManifest if set, else pkgsManifest, else libManifest.

A value carrying none of these returns null.

manifestOf (import ./.)   # the manifest of a flakeless top
manifestOf lib            # lib.caisson-core.libManifest

libOverlays, pkgOverlays

lib.caisson.libOverlays : attrsOf libOverlay
lib.caisson.pkgOverlays : attrsOf pkgOverlay

These attributes are the lib overlay registry and the package overlay registry of the composition that built the library, keyed by registry name. Both are read from the manifest of the library (caisson-core.libManifest.libOverlays and caisson-core.libManifest.pkgOverlays), which is the only place caisson-core keeps them. Each has the name of the mkLib argument that fills it.

  • libOverlays holds the entries mkLib registered, the entries consumed projects contributed under <project>/<name>, and the caisson-core/<name> entries. A libOverlayImports selection refers into it.
  • pkgOverlays holds the package overlay entries: those mkLib registered and those consumed projects contributed. A package config selects from it: caisson.nixpkgs.overlays = [ lib.caisson.pkgOverlays.default ];.

Both are empty in a library that no mkLib call built.

ecosystemSrc

lib.caisson-core.ecosystemSrc : name -> source | null

ecosystemSrc takes an ecosystem name and returns the source the composition supplies for exactly that name. That is the source defaultEcosystemSrc sources selects for that name, or else the source it pins under that name, or else null. The result is fixed by the arguments of the mkLib call.

An overlay whose added names come from a source calls it on prev. prev.caisson-core.ecosystemSrc "nixpkgs-lib" returns the nixpkgs library source of the flake whose mkLib call composes the overlay, whichever flake registered the overlay. The nixpkgs-lib integration of caisson finds its source this way.

withManifests

lib.caisson-core.withManifests :
  { pkgsManifest ? nullOr manifest; evalManifest ? nullOr manifest; } -> lib

Carried by every stage of a library mkLib builds. It rebuilds that stage from its declaration with the given phase manifests filled in: the same entries and the same libManifest, composed as a new fixpoint, so code inside the library that reads final.caisson-core.pkgsManifest sees the manifest, and manifestOf returns it. Merging the attribute onto a built library would leave those readers seeing null. The nixpkgs integration uses it to hand out pkgs.lib, the bootstrap lib with pkgsManifest filled in, and a module evaluation fills in evalManifest the same way. The rebuilt library carries withManifests too, and a further call keeps what is already filled in. Any other name, libManifest included, and any value that is neither a manifest nor null is refused.

finalizeChild

lib.caisson-core.finalizeChild :
  { name : str; parent : manifest; what ? str; } -> configuration -> manifest

A configuration learns its name and its parent from where it is declared, so what an integration’s mkConfiguration returns is a function { name, parent }: manifest. finalizeChild calls it with the name it is declared under and the parent’s childless manifest, and requires a manifest back. It first reads the function’s pattern with builtins.functionArgs and requires exactly name and parent, so anything else declared where a configuration belongs is refused there, with a message saying what a configuration is. what names the declaration in those messages. mkLib finalizes each pkgSets entry this way, and the caisson.<integration>.configurations options finalize each entry this way.

mkConfiguration

lib.caisson-core.mkConfiguration :
  { type     : str                                 # the name of the integration
  , evaluate : { lib, manifest } ->
               { value; outputs ? { }; children ? { }; forChildren ? { }; }
  , record   ? { }                                 # plain data added to the manifest
  } -> configuration

Builds the configuration of a module evaluation: the function of { name, parent } that finalizeChild calls. It is what an integration’s mkConfiguration is written with. Nothing is evaluated when it is called; the manifest the configuration returns reads value, outputs and children from evaluate when they are read.

evaluate performs the evaluator’s call on lib, and returns the evaluation as the evaluator returned it (value), the integration’s references into it (outputs) and the finalized configurations declared beneath it, by integration and then name (children).

The evaluation has a childless view and a full view. Each is a manifest, and each runs on the declaring lib rebuilt with that manifest as evalManifest (withManifests).

  • The childless view is the evaluation without the configurations declared beneath it: childless = true and no children.
  • The full view is the manifest returned. It carries the childless manifest as childlessManifest, and children are finalized against that, so a child’s parent and nearest.<integration> are the childless manifest of the configuration that declares it.

A configuration with children is evaluated in both views, and a configuration with none is evaluated once.

forChildren is what the evaluation gives the configurations declared inside it. It has three fields:

  • extraDefaultModuleImports, by class a list of selections, each a function of a lib returning modules.
  • defaultPkgs, which selects the package set its children run on by default. It is null when the configuration selects no set.
  • systems, the systems its children are evaluated for. It is null when the configuration lists no systems.

They are read from the childless evaluation and recorded on the manifest as forChildren. A configuration declared inside inherits the selections of its parent, extended by them. Its manifest holds the selections added above it as defaultModuleImports, those from the top first, each a record { select; file; level; }: select is the selection, file the file that defines it, and level the name path of the level that added it. Every level extends them in turn, so a selection reaches the configurations beneath the level that added it, nested ones and ones beneath a system included.

Both views carry type, name, parent, ancestors (the parent’s list with the parent appended), nearest (the parent’s attrset with the parent under its integration; a lib is not among them), inputs, and the parent’s sources, root, systems, projects, defaultEcosystemSrc, pkgSets and registries. A record that names a field mkConfiguration writes is refused.

An integration that evaluates a configuration per system passes perSystem = true. A declared configuration is then an evaluation for every system in force where it is declared, and the configuration returns those evaluations by system. In the tree the system sits above the name:

flake
└── children.system
    ├── x86_64-linux                     type system
    │   └── children.nixos.hostname2     an evaluation, with a value
    └── aarch64-linux                    type system
        └── children.nixos.hostname2     an evaluation, with a value

There are as many evaluations as there are systems in force, also for a single system, and none where no system is in force; nothing is refused. Each evaluation is a manifest as described above, under the name it is declared by, carrying its system as system, and its parent is the system, a manifest of type system. The parent’s full manifest holds each system under children.system, beside the configurations evaluated once for every system, which stay under children.<integration>.

Where a configuration that is evaluated per system is declared inside another, the system of its parent is the default. A home-manager configuration declared inside a NixOS configuration has, by default, one evaluation per evaluation of the machine, for the same system. The manifest of each evaluation of the machine holds that system as systems.

A parent changes this by returning forChildren.systems, the systems its per-system children are evaluated for: a machine that holds an image for another architecture states that architecture there. A configuration that is evaluated once can return a list the same way, to narrow what its children are evaluated for. The list has to come from the systems allowed where that parent is declared, and a system outside them is refused.

finalizeChild accepts either result, a manifest or the evaluations by system.

elide

lib.caisson-core.elide : listOf path -> listOf (listOf str)
path = listOf { type : str; name : str; }

The naming rule. A path is the list of segments from a top down to a thing, ending in the name of the thing; a system is a segment of type system. For each path, in order, elide returns the segments needed to tell the thing apart from the others, as strings in path order.

It keeps the last segment of every path, and beyond it only the segments where paths that end in the same name fork. Among those paths it drops the prefix they share, keeps the segment at which they first differ, the segment nearest the top that separates them, and does the same within each branch.

elide [
  [ { type = "structural"; name = "a"; } { type = "system"; name = "x86_64-linux";  } { type = "nixos"; name = "host-1"; } ]
  [ { type = "structural"; name = "b"; } { type = "system"; name = "aarch64-linux"; } { type = "nixos"; name = "host-1"; } ]
  [ { type = "structural"; name = "a"; } { type = "system"; name = "x86_64-linux";  } { type = "nixos"; name = "host-2"; } ]
]
# [ [ "a" "host-1" ] [ "b" "host-1" ] [ "host-2" ] ]

A name that is alone stays bare, whatever sits above it, so the system above a configuration with a single system in force drops out. A segment is kept as its name, or as type/name where the branches of that fork hold the same name under several types. How the kept segments are written out as a name, and a clash between equal paths, are for whoever publishes them (caisson.integrations.publish).

finalizeTop

lib.caisson-core.finalizeTop : configuration -> manifest

Finalizes the configuration a top ends with. A top has no parent that declares it under an attribute, so its name is the name the composition declares on mkLib, absent when it declares none, and its parent is the lib’s manifest. An integration’s mkTopConfiguration is this followed by the step that turns the result into what a tool reads.

importApply

importApply : freeformModule -> attrs -> module

Applies static arguments to a module through _file/imports wrappers while preserving wrapper metadata. Used for threading arguments through module import chains.

compose, resolve

compose composes entries by key. resolve resolves an ecosystem source from explicit, defaults and sources, in that order. See How lib is composed and the documentation of caisson-core.

The caisson namespace

The functions, registries and manifests a flake uses are under lib.caisson. It holds those listed in Functions and registries, a namespace per integration target (lib.caisson.flake-parts, lib.caisson.nixos, and so on; see Integration namespaces), and the pkgs-dependent tooling documented at the end of this section. caisson’s registered flake modules are listed here too.

Functions and registries under lib.caisson

lib = caisson.lib.caisson.mkLib {
  inherit (caisson.lib.caisson.pins.flake inputs) sources root;
  projects = { inherit caisson; };
  modules = lib: lib.caisson.mkModules ./modules;
  configs = lib: lib.caisson.mkModules ./configs;
  libOverlays = lib: lib.caisson.mkLibOverlays ./lib-overlays;
};
GroupNames under lib.caisson
composingmkLib, pins, callFlake, callConsumerFlake, realizeInputs
making and reading entriesmkModule, mkModules, mkLibOverlay, mkLibOverlays, mkPkgOverlay, mkPkgOverlays, importApply
registriesmodules, configs, libOverlays, pkgOverlays, pkgOverlaysFor
manifestslibManifest, pkgsManifest, evalManifest, manifestOf
for an integration written outside caissonfinalizeTop, elide, ecosystemSrc
  • pins, callFlake, callConsumerFlake and realizeInputs are defined in caisson and documented below.
  • modules, configs, libOverlays and pkgOverlays are read from the manifest of the library (lib.caisson-core.libManifest). They are empty in a library that no mkLib built.
  • Every other name is the value of the same name under lib.caisson-core in the same library, documented in The caisson-core namespace. caisson-core is the library caisson is built from.
  • Several arguments of mkLib are functions of a library that is still being built. In the mkLib of caisson-core, libOverlays and libOverlayImports receive a library that holds only the caisson-core names. lib.caisson.mkLib adds the names above, under lib.caisson, to the library each of those functions receives.

Overlay imports

An entry of libOverlays or pkgOverlays states what it imports as names or as entries, and both registries treat them the same way.

  • A name is looked up in the registry of the flake that registers the importer. imports = [ "base" ]; imports an entry of the same flake, and imports = [ "caisson/nixpkgs-lib" ]; imports an entry of a consumed project.
  • When a project is consumed, its entries become <project>/<name>, and the names in their imports are renamed the same way. An entry that imports another entry of its project is therefore composed once.
  • Registering under a name replaces that entry for everything that imports it.
  • Two entries built from different files under one key are refused.

pins

lib.caisson.pins.flake        : inputs -> { sources; root; }
lib.caisson.pins.flake-compat : path -> { sources; }
lib.caisson.pins.npins        : path -> { sources; }
lib.caisson.pins.gitRoot      : path -> root

pins holds a reader per pin system. Each reads the files of its pin system into sources: the pinned source trees, each as its pin system hands it over (a flake input keeps its outputs), with pin, the record of how it is pinned.

  • pin.system is "flake" from either flake reader and "npins" from pins.npins. It says which tool moves the source.
  • pin.files is { refs; revisions; }: the file that holds the ref and the file that holds the revision, relative to pin.dir when present and to the root of the flake otherwise.
  • pin.dir is the directory holding the pin files, for a reader given a directory.
  • pin.url is the ref as the pin files write it.
  • pin.rev, pin.narHash and pin.lastModified identify the source tree, where the pin system records them.
  • pin.follows is, for a flake input declared as a follows, the path of input names it follows. Its source tree is that of the input it lands on.
  • pin.overridden is set by pins.flake alone. It is true when an --override-input replaced the input, in which case pin.url describes the lock and not the source tree.

pins.flake inputs reads the inputs Nix’s flake evaluator resolved for the flake being evaluated, the inputs its outputs receives, and returns the root from self.

pins.flake-compat ./dir resolves the flake.lock beside a flake.nix in that directory the way flake-compat does, stopping before the flake’s outputs. Nix’s flake evaluator does not see that pair of files, so no --override-input applies to it and it has no root. It stays valid under read-only evaluation (nix flake check --no-build). A flake-parts partition takes the inputs of its lockfile’d subflake from this reader.

pins.npins ./npins reads sources.json format 8 and fetches each pin as npins’ generated default.nix does. It refuses Container pins, which need nixpkgs.

A root is { outPath; dirty; rev; shortRev; dirtyRev; dirtyShortRev; lastModified; lastModifiedDate; narHash; }. It identifies the source tree being built, with the source-info fields a flake’s self has, each null where the reader has no value for it. The names are fixed and the values lazy, since inside a flake’s outputs asking which attributes self has forces the outputs being computed. The names of a flake input’s pin are fixed for the same reason, and pin.url and pin.follows are null where they do not apply. A flakeless top in a git working tree reads its root with pins.gitRoot ./. under an impure evaluation. The result has the revision of a clean working tree, or dirty = true with dirtyRev for a dirty working tree.

# flake.nix outputs
inherit (caisson.lib.caisson.pins.flake inputs) sources root;

# test-only pins beside the pins of the flake
inherit (lib.caisson.pins.flake-compat ./tests/dependencies) sources;

# a flakeless top pinned with npins
inherit (lib.caisson.pins.npins ./npins) sources;
root = lib.caisson.pins.gitRoot ./.;

callFlake, callConsumerFlake

lib.caisson.callFlake : { src, inputs, sourceInfo ? { } } -> flakeOutputs

lib.caisson.callConsumerFlake :
  { path       : path | string   # directory containing flake.nix
  , pool       ? { } : attrs     # inputs resolvable by name
  , overrides  ? { } : attrs     # highest-precedence injections
  , sourceInfo ? { } : attrs     # extra self attrs (lastModified, rev, ...)
  } -> flakeOutputs              # self: inputs, outputs, outPath, _type

callFlake applies the outputs function of a flake to inputs given as values and fetches nothing. The flake-parts integration instantiates flake-parts with it.

callConsumerFlake evaluates a consumer-style flake from source with inputs supplied by hand, which is how a flake checks the flakes under its tests/. The declared inputs of that flake resolve by name: overrides first, then follows chains through the other resolved inputs, then pool. An input that does not resolve throws an error naming it. callFlake handles the self fixpoint and its decoration, and the eval-weight harness uses it too. Nothing is fetched: locks are not read, and sourceInfo attributes appear only if supplied. See Testing.

realizeInputs

lib.caisson.realizeInputs : inputs -> { "<name>/<name>/…" = storePath; }

realizeInputs takes flake inputs that Nix has already resolved. It makes sure that each input is present in the Nix store, and it returns the store path of each input. It does the same for the inputs of each input, and for the inputs of those, to any depth. “Realize” is the word Nix uses for making sure a store path is present, as in nix-store --realise.

The name of each input in the result is the list of input names that leads to it, joined with /. That is the form of name that the Nix flag --override-input takes:

lib.caisson.realizeInputs { inherit (inputs) caisson; }
# => {
#   "caisson" = "/nix/store/…-source";
#   "caisson/caisson-core" = "/nix/store/…-source";
#   "caisson/flake-parts" = "/nix/store/…-source";
#   "caisson/flake-parts/nixpkgs-lib" = "/nix/store/…-source";
#   "caisson/nixpkgs-lib" = "/nix/store/…-source";
# }

When two inputs both follow a third input, the third input appears in the result twice, once under each name that leads to it. The function leaves out self. An input that is a plain source tree has no inputs, and neither has a store path that the caller writes directly as a value.

The problem the function solves. Some tools evaluate a flake from inside a Nix build. nix-unit does this when it runs as a flake check. The check is a derivation. The builder of that derivation runs nix-unit, and nix-unit evaluates the flake that holds the tests.

Nix runs every builder in a sandbox. Inside the sandbox, the builder can see a path in the Nix store only when the derivation names that path as a dependency. The rest of the store is hidden from the builder. The builder also has no network access.

So when nix-unit evaluates the flake and the evaluation needs a flake input, the evaluation fails. Nix looks for the input in the store and does not see it, because the derivation did not name it. Nix then tries to download the input and cannot, because the builder has no network access. The check fails with the message unable to download.

The lock file of the flake does not change this. The lock file tells Nix which revision of the input to use. It does not put a copy of that revision where the builder can see it.

How the problem is solved by hand. nix-unit accepts the flag --override-input <name> <store path> for each input. The flag tells nix-unit to read the input from that store path and not to fetch it. The store path is also written in the command that the builder runs, so the derivation names the store path as a dependency, and the builder can see it. The flake-parts module of nix-unit writes these flags from the option nix-unit.inputs, which is an attribute set of names and store paths.

The inputs of an input need the same flag. Suppose a test composes caisson as a project. To do that, nix-unit has to evaluate the caisson flake. The caisson flake has inputs too, and caisson-core is one of them. Nix looks for caisson-core, does not see it, and the check fails in the way described above. The flag for an input of an input names both of them: --override-input caisson/caisson-core <store path>.

Writing these flags by hand means copying the list of inputs out of the lock file of the caisson flake, and keeping that copy up to date. If a name is missing from the copy, nothing reports it until the check runs and fails inside the sandbox. realizeInputs builds the whole set from the resolved inputs.

Which inputs to give the function. Give the function the inputs of the test flake:

nix-unit.inputs = lib.caisson.realizeInputs inputs;

The result then names each input of the test flake and each input of those inputs, so the test flake needs no follows to point the inputs of its inputs back at inputs it already lists.

To return the store path of an input, Nix has to fetch that input. The function therefore fetches every input it is given, and every input of those inputs, to any depth. It fetches an input even when no test reads it. For most test flakes that adds a few small sources.

The cost is large when one input exists to gather many others. The unit test flake of caisson has an input of that kind, deps, which pins everything any caisson test uses. Given all the inputs of that test flake, the function fetches 24 sources where the unit tests read 6. In that case, give the function only the inputs that the tests evaluate as flakes, and list the other inputs directly:

nix-unit.inputs = inputs // lib.caisson.realizeInputs { inherit (inputs) caisson; };

An input that the test flake declares with flake = false has no inputs as far as the test flake is concerned. Give the function such an input as a source tree, and not as an evaluated flake, or the result will name inputs under it that nix-unit reports as matching nothing. See also Testing.

modules.<class>."caisson/core", modules.flake."caisson/default"

  • Source: modules/generic/core/ (the core module; caisson.* options under caisson/), modules/structural/core, modules/nixos/core and modules/homeManager/core (symlinks to it), modules/flake/core/ (imports it and adds the flake mechanics), modules/flake/default/

caisson’s core module, the caisson.* options every evaluation carries (the manifest, the registry selectors, caisson.exports), registered as core in the class-free generic class and in every class whose integration forces it (flake, structural), and exported so a consumer’s composition carries caisson/core there.

The registry selectors, caisson.libOverlays.exported, caisson.modules.<class>.exported and caisson.pkgOverlays.exported, each choose what of a registry leaves the project, and each defaults to the entries the project registers itself. An entry a consumed project contributed (<project>/<name>), or an entry caisson-core publishes into every composition, leaves only when a selector names it: a project republishes an upstream’s entries only on purpose, and a consumer takes them from that upstream through its projects. The exported entries are in the manifest. The outputs are modules (and flakeModules) and overlays. caisson.flakeOutputs.modules selects which exported modules the modules output holds. overlays holds the exported package overlays as plain nixpkgs overlays. The exported lib overlays have no output. caisson/default for the flake class is caisson’s contribution to the default default: it imports caisson/nixpkgs below.

modules.nixos."caisson/default", modules.homeManager."caisson/nixos-parent"

  • Source: modules/nixos/default/, modules/homeManager/nixos-parent/

These modules are what a NixOS configuration gives the home-manager configurations declared inside it.

home-manager has a NixOS module that embeds home-manager configurations in a machine. For each home-manager configuration, that module sets a few options of the configuration from the machine. caisson does not use that NixOS module. A home-manager configuration declared inside a NixOS configuration is a caisson configuration, and it gets the same settings from the home-manager module caisson/nixos-parent.

caisson/default is the default NixOS module caisson registers. A NixOS configuration imports every registered module named default unless it passes moduleImports. This module sets caisson.forChildren.extraDefaultModuleImports.homeManager, which adds caisson/nixos-parent to the modules that the home-manager configurations declared inside the machine import by default. It also imports caisson/home-manager-activation, which runs those home-manager configurations on the machine.

caisson/default also imports caisson/activation-marker, described under drift detection. With it, a home-manager configuration prints a warning when it is activated on a machine it was not built for.

caisson/default also imports caisson/hjem-activation, which activates the hjem configurations declared inside the machine, and adds the hjem module caisson/nixos-parent to the modules those configurations import by default.

caisson/default also defines networking.hostName as the name the NixOS configuration is declared under, at mkDefault priority. So the machine declared as nas-1 names itself nas-1, and a host name the machine’s modules set replaces it. A NixOS configuration with no name, and an evaluation without NixOS’s networking module (the minimal NixOS evaluator), keep NixOS’s default.

caisson/nixos-parent sets these options of the home-manager configuration:

Option of the home-manager configurationValue
home.homeDirectoryusers.users.<user>.home of the machine, when the machine declares that account
home.uidusers.users.<user>.uid of the machine, when the machine declares that account and its uid is set
nix.enablenix.enable of the machine
nix.packagenix.package of the machine, when the machine has Nix enabled
submoduleSupport.enabletrue, which home-manager reads to tell that a machine activates the home-manager configuration
home.packageshome-manager’s command-line program, when the home-manager configuration sets programs.home-manager.enable

<user> is home.username of the home-manager configuration.

A machine whose user is managed by systemd-homed has no entry for that user in users.users. For such a home-manager configuration, nixos-parent leaves home.homeDirectory and home.uid alone, and the home-manager configuration sets its home directory itself.

home-manager leaves its command-line program out of a home-manager configuration that has submoduleSupport.enable set. nixos-parent puts it back, because the program is how the user runs home-manager switch.

A NixOS configuration or a home-manager configuration can take nixos-parent out:

  • A NixOS configuration that passes moduleImports selects its modules itself. It does not import caisson/default, and then no home-manager configuration inside it gets nixos-parent from caisson.
  • A home-manager configuration that passes moduleImports selects its modules itself, and then that home-manager configuration does not get nixos-parent.

Each can keep it while selecting its other modules. The NixOS configuration lists caisson/default, which is a NixOS module, in its selection. The home-manager configuration lists caisson/nixos-parent, which is a home-manager module, in its selection.

nixos-parent reads the machine through the manifest of the home-manager configuration, so only a home-manager configuration with a NixOS configuration above it can import it.

In a NixOS evaluation whose library is not a caisson composition, caisson/default defines nothing, as the core module declares nothing there.

modules.nixos."caisson/home-manager-activation"

  • Source: modules/nixos/home-manager-activation/

The NixOS module with which a machine activates the home-manager configurations declared inside it. caisson/default for the nixos class imports it, so a NixOS configuration has it unless it passes moduleImports.

Activating a home-manager configuration means running the activate script of its activation package as the user of the home-manager configuration. The script links the files of the home-manager configuration into the home directory and installs its packages. This module writes a systemd unit for each home-manager configuration, so that switching the machine to a new generation also applies its home-manager configurations. caisson does not use home-manager’s NixOS module for this.

Which home-manager configurations. The module activates the home-manager configurations that the NixOS configuration passes up. Those are the home-manager configurations declared inside it, also through a structural configuration, that its caisson.home-manager.exported selector selects. The module leaves out:

  • A home-manager configuration declared inside a NixOS configuration that is itself inside this one. That home-manager configuration belongs to the inner machine.
  • A home-manager configuration evaluated for a system other than the system of this machine.

The account of the user. The module expects the machine to declare the account of the user of each home-manager configuration in users.users. A home-manager configuration whose user has no account there is refused, with an assertion that names the home-manager configuration and the user. On most machines such a home-manager configuration is a mistake: a misspelled user name, or a home-manager configuration declared on the wrong machine.

Some accounts are provided by something other than users.users. An account that systemd-homed manages is one. systemd-homed keeps the record of it, the machine must not declare it a second time, and its home directory is mounted only at login. The option caisson.home-manager.activation.usersDeclaredElsewhere lists the users that have such an account on the machine. The home-manager configuration of a listed user is not refused.

Which unit. The unit depends on what provides the account of the user.

Account in users.usersUser listed as declared elsewhere
Unitsystem unit home-manager-<user>user unit home-manager-<user>
Runsduring boot, as that user, before users can log inwhen the service manager of that user starts, at login
Restricted byUser=<user>ConditionUser=<user>
Waits forthe home directory, and the Nix daemon socketthe home directory

<user> is home.username of the home-manager configuration.

The system unit is the unit home-manager’s NixOS module writes.

The profile of the home-manager configuration. Both units run activate with no driver version. home-manager calls that the legacy behavior: the script also updates the home-manager profile of the user. The user can then run home-manager switch and home-manager generations on the same profile. home-manager’s NixOS module passes --driver-version 1, which leaves the profile alone.

Two home-manager configurations for one user. A machine that would activate two home-manager configurations with the same home.username fails an assertion. The message names the user and both home-manager configurations. Give one of the home-manager configurations a different home.username, or leave one out with caisson.home-manager.exported.

Where the module defines nothing. The module reads the home-manager configurations from the full evaluation of the NixOS configuration, so it defines nothing in the childless evaluation. It also defines nothing in a NixOS evaluation that declares no systemd options. The nixos-minimal integration produces such an evaluation, and a machine of that kind cannot run units.

modules.nixos."caisson/hjem-activation"

  • Source: modules/nixos/hjem-activation/

The NixOS module with which a machine activates the hjem configurations declared inside it. caisson/default for the nixos class imports it, so a NixOS configuration has it unless it passes moduleImports.

Activating a hjem configuration means applying its manifest with hjem’s CLI. The CLI links the files of the user and removes the files of the previous generation, whose manifest the machine keeps in /var/lib/hjem/manifest-<user>.json. The module writes the units that hjem’s NixOS module writes, under the same names:

UnitRuns
hjem-preparecreates /var/lib/hjem
hjem-activate@<user>as the user, links the files of the new manifest
hjem-reload@<user>as the user, reloads the systemd user units that the change touched, when the service manager of the user is running
hjem-update-state@<user>stores the new manifest in /var/lib/hjem
hjem-cleanupremoves the stored manifests of users that are no longer activated

The target hjem requires them all, and starts at boot and when the machine switches to a new generation. Each instance runs the CLI of the hjem configuration of its user. The units link with hjem’s CLI and back up a file they replace under the prefix .backup-. hjem’s NixOS options for another linker, for systemd-tmpfiles and for clobberByDefault have no counterpart here; a configuration sets clobberFiles itself.

The module also adds the packages of each user to users.users.<user>.packages, and keeps the extraDependencies of each user in the closure of the system.

Which configurations. The hjem configurations that the NixOS configuration passes up and that are enabled, leaving out those declared inside a NixOS configuration that is itself inside this one, and those evaluated for another system. A machine that would activate two hjem configurations with the same user fails an assertion naming the user.

The module defines nothing in the childless evaluation, and nothing in a NixOS evaluation that declares no systemd options, such as one from the nixos-minimal integration.

modules.hjem."caisson/nixos-parent"

  • Source: modules/hjem/nixos-parent/

The hjem module that a NixOS configuration adds to the modules that the hjem configurations declared inside it import by default, through caisson/default. It sets directory to users.users.<user>.home of the machine, at mkDefault priority. It refuses an enabled configuration whose user has no enabled account on the machine, with an assertion naming the user, as hjem’s NixOS module does.

A hjem configuration that passes moduleImports gets this module only if its list names it. The module reads the machine through the manifest of the configuration, so only a hjem configuration with a NixOS configuration above it can import it.

Drift detection: caisson/activation-marker, modules.homeManager."caisson/activation-drift"

  • Source: modules/nixos/activation-marker/ (registered also for darwin and systemManager), modules/homeManager/activation-marker/, modules/hjem/activation-marker/, modules/homeManager/activation-drift/, and activatingAncestorOf, markerOf, checksMarkerOf and driftCheckOf in lib-overlays/integrations/default.nix

A configuration with an activation, declared inside another configuration with an activation, warns when it is activated by hand after being built for a different version of the configuration above it. A home-manager configuration inside a machine is the common case.

The machine activates the home-manager configurations declared inside it. The user can also activate such a home-manager configuration by hand, with home-manager switch, which builds the home-manager configuration from the source the user has checked out. That source can describe a different machine than the machine that is applied. This happens when the user changed the machine and has not switched it yet, or when the checkout is older than the machine. A home-manager configuration reads settings of its machine, so it was then built for a machine other than the machine it is activated on.

Which configurations. An integration whose configurations are activated passes activation to mkModuleConfiguration, and the manifest records it. It names two things, each computed from a manifest: the identity of a configuration and the path of its marker.

IntegrationIdentityMarker
nixos, darwin, system-managerthe system, toplevel/etc/caisson/marker.json
home-managerthe activation package$HOME/.local/state/caisson/markers/home-manager-<name>.json
hjemthe manifest file.local/state/caisson/markers/hjem-<name>.json in the directory of the user
nvf, nixvimthe Neovimbeside its profile; nothing is declared inside one to read it

A marker of a user is named by $HOME and not by $XDG_STATE_HOME, because a unit of the machine and a shell of the user may see $XDG_STATE_HOME differently, and both must find the same file.

The identity is that of the childless evaluation, which leaves out the configurations declared inside. A change to a home-manager configuration leaves the identity of its machine the same, and a change to anything else about the machine changes it.

Every configuration with an activation checks the nearest configuration above it that has one. A machine is the exception: it is applied on the host it describes, so a machine declared inside another, such as an image, checks nothing above it.

The marker. The default module of each of these classes imports its caisson/activation-marker. When a configuration declared inside checks it, the activation writes the SHA-256 digest of the store path of the identity into the marker, as { "identity": "<digest>" }. Computing the store path evaluates the childless evaluation and builds nothing, and the digest names no store path, so the marker depends on nothing and cannot be read as a path. A machine writes it with environment.etc, a home-manager configuration with an activation step after its files are linked, and a hjem configuration adds it to its files. The marker is not written in the childless evaluation, or in a NixOS evaluation without environment.etc, which the nixos-minimal integration can produce.

The check. driftCheckOf is the shell text of the check. It compares the marker of the configuration above with the digest of the identity of that configuration as this configuration was built for it, computed from the same childless evaluation through the manifest. It prints a warning to standard error when they differ, or when the marker does not exist, and does not stop the activation. A home-manager configuration runs it from the home-manager module caisson/activation-drift, which the home-manager default module imports, as an activation step before writeBoundary. The option caisson.drift.warn of the home-manager configuration removes the step.

When the configuration above activates it. The marker then already belongs to the generation being activated, so the two are equal, and the check prints nothing.

Cost. Computing the identity evaluates the configuration above a second time, without what is declared inside it. A configuration with nothing declared inside it that checks it writes no marker, and does not pay for the second evaluation.

modules.darwin."caisson/default"

  • Source: modules/darwin/default/, modules/homeManager/darwin-parent/, modules/hjem/darwin-parent/, modules/darwin/home-manager-activation/, modules/darwin/hjem-activation/

caisson/default is the default darwin module caisson registers. A darwin configuration imports every registered module named default unless it passes moduleImports. It holds what a macOS machine does for the home-manager configurations and the hjem configurations declared inside it.

It adds the home-manager module caisson/darwin-parent to the modules that the home-manager configurations inside the machine import by default, and the hjem module caisson/darwin-parent to those of the hjem configurations. They set what home-manager’s and hjem’s nix-darwin modules set from the machine:

ModuleOptionValue
home-manager darwin-parenthome.homeDirectoryusers.users.<user>.home of the machine, when the machine declares that account and sets its home
home.uidusers.users.<user>.uid of the machine, when it is set
nix.enable, nix.packagethose of the machine
submoduleSupport.enabletrue
home.packageshome-manager’s command-line program, when the home-manager configuration sets programs.home-manager.enable
hjem darwin-parentdirectoryusers.users.<user>.home of the machine, at mkDefault priority

nix-darwin leaves users.users.<user>.home at null until the machine sets it. When the machine declares no account for the user, or the account sets no home, reading home.homeDirectory of the home-manager configuration or directory of the hjem configuration fails with a message that says which of the two the machine is missing. The hjem module also refuses such a user with an assertion, which covers a hjem configuration that sets directory itself. An account without uid is accepted, and home.uid of the home-manager configuration stays unset, as with home-manager’s nix-darwin module.

modules.darwin."caisson/home-manager-activation"

The darwin module with which a machine activates the home-manager configurations declared inside it. caisson/default imports it. A step of the machine’s postActivation script runs the activate script of each home-manager configuration as its user, through launchctl asuser and sudo, as home-manager’s nix-darwin module does, so darwin-rebuild switch applies the home-manager configurations. A home-manager configuration whose user has no account in users.users is refused with an assertion, and so is a home-manager configuration whose user’s account sets no home, which matters for a home-manager configuration that sets home.homeDirectory itself. Two home-manager configurations for one user are refused as well.

The step runs activate with no driver version, as caisson’s NixOS activation does. The script then also updates the home-manager profile of the user, so the user can run home-manager switch on the same profile. home-manager’s nix-darwin module passes --driver-version 1, which leaves the profile alone.

modules.darwin."caisson/hjem-activation"

The darwin module with which a machine applies the hjem configurations declared inside it. caisson/default imports it. It writes the two launchd agents of hjem’s nix-darwin module, with the same labels and scripts:

AgentRuns
org.hjem.activateat login and when the machine is activated, as the user: applies the manifest of the user, with the state in ~/Library/Application Support/Hjem
org.nix.link-nix-appsthe same: links the applications of the packages of the user into ~/Applications/Nix User Apps

Each agent exits for a user that no hjem configuration of the machine names, and runs the CLI of the hjem configuration of the user it runs for. The module also adds each user’s packages to users.users.<user>.packages, and lists each user’s extraDependencies in /etc/hjem/extra-dependencies, which keeps them in the system.

The module differs from hjem’s nix-darwin module in where it declares the agents and in how it applies the files at an activation:

  • It declares the agents in launchd.agents. nix-darwin installs those in /Library/LaunchAgents, where launchd loads them for every user who logs in. hjem’s nix-darwin module declares them in launchd.user.agents, which nix-darwin installs for system.primaryUser alone, and it requires the machine to set system.primaryUser.
  • It applies the files of each hjem user who is logged in from postActivation, which nix-darwin runs after it installs the agents of the new generation. nix-darwin loads the agents of /Library/LaunchAgents as root, which loads them into no session, so a user who is logged in when the machine is activated has the agents of the generation that was current at the login, or none. For each such user, the step removes the agents from the session, runs the program of each agent of the new generation as the user, so the files are applied before the activation returns, and loads the agents of the new generation into the session. A user who is not logged in gets the agents at the next login. hjem’s nix-darwin module restarts the activation agent from an activation step that nix-darwin does not run, and the applications agent from a step that runs before the agents are installed.

Profiles: caisson/profile-activation

  • Source: modules/nixos/profile-activation/ (registered also for systemManager), modules/darwin/profile-activation/, modules/homeManager/profile-activation/, modules/hjem/profile-activation/, and profileOf, profileShimsOf, profilesActivatedBy and profileChoicesOf in lib-overlays/integrations/default.nix

An nvf or nixvim configuration is a program a person runs. It relates to the configuration it is declared inside as a home-manager configuration relates to its machine: it has a separate activation, which the configuration above runs each time it is activated itself.

The profile. The configuration has the outputs activationPackage and profile. activate checks for drift against the nearest configuration above with an activation, and points a Nix profile at the Neovim with nix-env --profile. That also makes the profile a garbage-collector root. When the profile already points at the Neovim, activate changes nothing, so activating the same generation again adds no generation. The profile keeps its generations, so it can be rolled back with nix-env --profile <path> --rollback.

Declared insideProfileActivated by
a NixOS or system-manager machine/nix/var/nix/profiles/caisson/<kind>/<name>the unit caisson-profile-<kind>-<name>, as root, wanted by multi-user.target
a darwin machinethe samea step of postActivation, as root
a home-manager configuration$HOME/.local/state/caisson/profiles/<kind>/<name>a step of the activation of the home-manager configuration, after its files are linked
a hjem configuration inside a NixOS machinethe samethe unit caisson-profile-<user>-<kind>-<name> of the machine, as the user, after hjem-activate@<user>, wanted by hjem.target and part of it
a hjem configuration inside a system-manager machinethe samethe unit hjem-activate@<user> of the machine, as the user, after it applies the files of the user
a hjem configuration inside a darwin machinethe samea step of postActivation, as the user, after the hjem agents are restarted
a hjem configuration with no machine above it, on Linuxthe samethe systemd user service caisson-profiles in the files of the hjem configuration, when the systemd of the user starts and at each hjem standalone switch
a hjem configuration with no machine above it, on macOSthe samethe launchd agent org.caisson.profiles in the files of the hjem configuration, when launchd loads it at login and at each hjem standalone switch
nothing (a top)the samerunning activate by hand

<kind> is nvf or nixvim. A hjem configuration has no activation step that a configuration can add to. A machine activates the profiles of a hjem configuration declared inside it. On a NixOS machine, hjem.target starts the units that apply the files of each user, at boot and again at every switch, and the unit of each profile starts with it.

system-manager reloads a changed unit by its name, which fails for a template, restarts each changed unit in a separate request, so the order between units does not hold, and does not stop hjem.target at an activation. On a system-manager machine, caisson therefore writes a single unit for each hjem user, hjem-activate@<user>, wanted by multi-user.target, which links the files, reloads the units of the user, stores the state and activates the profiles inside the hjem configuration, in that order. The unit names the manifests and the profiles of its generation, so it changes, and system-manager restarts it, when the files or the profiles of the user change. Activating the same generation again starts no unit, so a profile changed by hand is put back at the next activation that changes it, or at a boot.

A hjem configuration with no machine above it is applied with hjem standalone switch. On Linux, its files hold the systemd user units that activate its profiles. The service caisson-profiles runs ~/.local/state/caisson/activate-profiles, which the files link to the script that runs the activate of each profile, and is wanted by default.target. The unit files are the same in every generation, and only the link changes, since the systemd of the user keeps the units it has read until it reloads, which a switch does not make it do. The path unit caisson-profiles starts the service when $XDG_STATE_HOME/hjem/standalone/current/manifest.json changes, which hjem standalone switch writes at every switch. The systemd of the user reads the units when it starts, so the switch that first adds them activates the profiles at the next login. To activate them before then, run systemctl --user daemon-reload and systemctl --user start caisson-profiles.path caisson-profiles.service.

On macOS, the files of such a hjem configuration hold the launchd agent org.caisson.profiles, as ~/Library/LaunchAgents/org.caisson.profiles.plist. The agent runs the same script from the same link when launchd loads it, which it does at login, and when the manifest of the current generation changes. Like the units, the agent is the same in every generation. launchd expands no variables in the paths an agent watches, so the agent watches ~/.local/state/hjem/standalone/current/manifest.json, where hjem keeps the manifest when XDG_STATE_HOME is unset. To activate the profiles before the next login, run launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/org.caisson.profiles.plist.

Reset. The configuration above runs activate each time it is activated. A profile changed by hand is put back to what the configuration above declares at the next activation: home-manager switch for a home-manager configuration, a switch of the machine, a boot, since the machine activates its home-manager configurations and its profiles at boot, and hjem standalone switch or hjem standalone rollback for a hjem configuration with no machine above it.

Choosing a profile. Each class installs, with profileShimsOf, a program for each program the Neovims have in bin: nvim, and vi and vim where they have them. The program runs the program of the profile that $CAISSON_NVIM_PROFILE names, read each time it runs, and of the default of the configuration that installed it when the variable is unset. A home-manager configuration and a hjem configuration set the variable to their profile, with home.sessionVariables and environment.sessionVariables, so the programs the machine installs choose the same profile. A shell can export another value.

The default is the only profile of the configuration for that variable. Where several share it, caisson.profiles.selected names the default, and an evaluation without it fails with a message listing them.

modules.systemManager."caisson/default"

  • Source: modules/systemManager/default/; the modules modules/systemManager/home-manager-activation, modules/systemManager/hjem-activation, modules/systemManager/profile-activation and modules/systemManager/activation-marker are the NixOS modules of the same names, and modules/homeManager/system-manager-parent and modules/hjem/system-manager-parent are the nixos-parent modules

caisson/default is the default system-manager module caisson registers. A system-manager configuration imports every registered module named default unless it passes moduleImports. It holds what a machine running another Linux distribution does for the configurations declared inside it, as caisson/default of the nixos class does for a NixOS machine:

PartWhat it does
caisson/home-manager-activationwrites the system unit home-manager-<user> that activates each home-manager configuration declared inside the machine
caisson/hjem-activationwrites the units of hjem that apply each hjem configuration declared inside the machine, and adds the packages of each user to users.users.<user>.packages
caisson/profile-activationactivates the profile of each nvf or nixvim configuration declared inside the machine, and installs the programs that choose among them; see profiles
caisson/activation-markerrecords what the machine applied; see drift detection
caisson.forChildrengives each home-manager configuration the home-manager module caisson/system-manager-parent and each hjem configuration the hjem module caisson/system-manager-parent

The two activation modules are those of a NixOS machine. system-manager declares users.users and the systemd options, and starts the units that ask for multi-user.target with system-manager.target, so system-manager switch applies the home-manager configurations and the hjem configurations. The units and their checks are described at caisson/home-manager-activation and caisson/hjem-activation. system-manager has no system.extraDependencies, so the hjem module keeps no extra dependencies in the system there.

The two system-manager-parent modules are the nixos-parent modules, which read the nearest NixOS or system-manager configuration above the child. They set home.homeDirectory and directory from the account the machine declares in users.users. A home-manager configuration or a hjem configuration inside a system-manager machine is refused when the machine declares no account for its user, unless, for a home-manager configuration, the user is listed in caisson.home-manager.activation.usersDeclaredElsewhere.

A home-manager configuration declared inside a system-manager machine is published as homeConfigurations."<user>@<machine>", as a home-manager configuration inside a NixOS machine is.

modules.flake."caisson/nixpkgs"

  • Source: modules/flake/nixpkgs/

The nixpkgs integration’s flake module. It hands perSystem the package sets of the package configs declared in mkLib’s pkgSets (see caisson.nixpkgs below), and exports from them:

  • The perSystem pkgSets argument is every package config’s set for that system, keyed by config name, and pkgs is the default config’s set. A flake that declares no default config keeps the pkgs flake-parts provides.
  • The flake’s overlays output carries the entries of caisson.pkgOverlays.exported as plain overlays, each with the entries it imports composed in, so a consumer that is not caisson gets a working overlay.
  • pkgs.export.enabled, packages.export.enabled: whether to export legacyPackages, and the package scope of the flake (pkgs.<name>, after the name declared on mkLib) as packages.

integrations

  • Source: lib-overlays/integrations/default.nix

This namespace holds the functions that every integration is written from. Each integration overlay imports this overlay by key, so composing any integration composes this overlay as well, and the integration reads these functions through final.

mkEvaluation

mkEvaluation : { compose, evaluate } -> args -> result

mkEvaluation is the body of both entry points of an integration. It passes the arguments the entry point’s pattern admitted to compose, which returns an attribute set holding ecosystemArgs, the arguments of the evaluator’s call as the integration composed them, beside anything evaluate needs, such as the resolved source. It then calls evaluate with that set and the call to make. When the arguments carry ecosystemArgs, which only the pattern of the WithEcosystemArgs twin admits, they are merged over the composed call verbatim, last.

The pattern is the whole check. Every entry point is a function of an attribute set pattern with no ..., so Nix matches the call against the pattern before the body runs: a missing or unexpected argument is Nix’s error, named after the entry point and raised at the call site, with no frame of caisson above it. Required arguments are bare in the pattern and optional arguments default to null; the composition supplies the value of an omitted argument. builtins.functionArgs reads a signature back as data.

selectedEcosystemSrc

lib.caisson.integrations.selectedEcosystemSrc : context -> manifest -> explicit -> src | null

selectedEcosystemSrc applies a configuration’s ecosystemSrc argument, a function of sources or null, to the sources of manifest. It returns null for a null explicit, and refuses an explicit that is not a function. context names the caller in that refusal’s message.

resolveEcosystemSrc

resolveEcosystemSrc : { name, context } -> { explicit ? null, manifest ? libManifest } -> src

resolveEcosystemSrc finds the source of an ecosystem in the layered order that Ecosystem sources describes: selectedEcosystemSrc applied to the explicit argument first, then the source defaultEcosystemSrc sources returns for the name, then the pinned source named exactly <name>. The first call names the ecosystem and the caller; the second supplies the explicit argument and the manifest to read the sources and the declarations from. When nothing provides a source, it throws a message that names the caller and those places.

coreModules, defaultModuleImports

coreModules          : attrsOf module -> listOf module
defaultModuleImports : attrsOf module -> listOf module

Both take a class registry (lib.caisson.modules.<class>) and select entries by name. coreModules returns every entry named core, which includes the <project>/core entries of consumed projects; these form the framework module of the class, and every integration applies them to every evaluation. defaultModuleImports returns every entry named default, in the same way; this list is the default default, the selection an evaluation gets when it passes no moduleImports.

integrationsIn, childrenOf, configurationsModuleFor, nearestOf

integrationsIn          : options -> listOf str
childrenOf              : { options; config } -> attrsOf (attrsOf manifest)
configurationsModuleFor : listOf str -> module
nearestOf               : listOf str -> manifest -> manifest or null
integrationsDeclaringIn : options -> listOf str

integrationsDeclaringIn is the part of integrationsIn whose caisson.<integration>.configurations a module of the evaluation defines. childrenOf and re-export read only those integrations, so an evaluation does not merge the options of an integration it holds no configurations of.

nearestOf takes a list of integration names and a manifest, and returns the nearest configuration above the manifest whose integration is one of them, null when there is none. The home-manager and hjem integrations find the machine of a configuration with nearestOf [ "nixos" "darwin" ].

integrationsIn takes the options of an evaluation and lists the integrations whose configurations it can hold beneath it: those whose configurationsModule the evaluation imports, which declares caisson.<integration>.configurations and caisson.<integration>.exported. configurationsModuleFor takes a list of integration names and returns one module that declares those options for all of them. The core module of caisson imports it once, for every integration of caisson, which costs less to evaluate than a module per integration. childrenOf takes the options and the config of an evaluated configuration and returns the configurations declared beneath it, by integration and then name, leaving out an integration with none: the children mkModuleConfiguration hands to lib.caisson-core.mkConfiguration. It reads only the options that a module of the evaluation defines.

frameworkModules, mkModuleConfiguration, ecosystemUnit, inEcosystem

frameworkModules      : str -> attrsOf module -> listOf module
mkModuleConfiguration : { type : str; perSystem ? false; defaultPkgs ? null; exportsTo ? null; selection ? null; evaluate : view -> evaluated; } -> configuration
evaluated = { value; outputs ? { }; config ? value.config; modules ? listOf (module | unit); }
ecosystemUnit : { key : str; project : str; file : nullOr str; } -> unit
inEcosystem   : { name : str; src : path; } -> listOf module -> listOf (module | unit)
exportsTo = export | listOf export
export    = { attrset : str; value : manifest -> value; single ? false : bool;
              bySystem ? false : bool; only ? : manifest -> bool;
              name ? : { name, manifest } -> { value : str; description ? : str; }; }

mkModuleConfiguration builds a configuration that is a module evaluation. type and perSystem are what lib.caisson-core.mkConfiguration takes. evaluate is the evaluator step of the integration: from the view being evaluated, { lib, manifest }, it returns value, the evaluation as the evaluator returned it, and outputs, the references into it that the integration declares (config says where the evaluated options are when that is not value.config). defaultPkgs is the package set selection the configuration was constructed with, which the builder records on the manifest (pkgSetOf below). selection is { class; args; }, the class of the evaluation and the arguments the configuration was constructed with, from which the builder records how the evaluation selected its modules as moduleImports (selectionAccountOf below). Every integration of caisson passes it.

evaluate also returns modules, the list of modules the evaluator received, in that order, and the builder records a declaration event in the history of the manifest for each element (manifest above). Where the evaluator receives the module list of the ecosystem, the list holds an ecosystemUnit instead of those modules, so the module list is a single event: key names the ecosystem, project the source, and file the file of the list inside the source, null when the list has no single file. NixOS records ecosystemUnit { key = "nixos"; project = "nixpkgs"; file = "nixos/modules/module-list.nix"; } before the modules of the composition, since eval-config adds its module list first. inEcosystem makes those units for an integration whose list names modules of the ecosystem by their files: each module of the list inside the source src becomes an ecosystemUnit of the source name, with its file inside the source.

exportsTo says how the configuration is published, and the builder records it on the manifest too. It is one export, or a list of them for an integration whose configurations are published in more than one output. attrset is the output attribute set; a NixOS configuration records nixosConfigurations. value takes the manifest of the configuration to what is published.

only takes the manifest and says whether the configuration is published in that output at all. A configuration it leaves out is still passed up. The treefmt integration uses it to publish only the configuration named default.

single says the output holds a single value rather than values by name, as colmenaHive does. The configuration declared under the name default is then the output’s value, and a configuration declared under another name is refused there.

bySystem says the output is keyed by system first, as flakes key packages and formatter. Each configuration is published under the system of its evaluation, <attrset>.<system>.<name>, named by the usual rules over its path without the systems on it. With single too, the output holds the configuration named default for each system, as formatter.<system> does. A configuration with no system, one not evaluated per system, is refused in such an output.

name is for an integration whose configurations are known by a name other than the name they are declared under. It takes the name the configuration is passed up under and its manifest. It returns value, the name to publish the configuration under, and optionally description, a sentence describing how the name was formed. The home-manager integration uses it to name a home-manager configuration declared inside a NixOS configuration: the name of the home-manager configuration, @, and the name of the NixOS configuration.

A structural or flake-parts configuration is built with no exportsTo and is not published under a name. The configurations declared inside it are published, with its segment on their paths.

The configuration holds configurations of any integration beneath it: those its modules declare under caisson.<integration>.configurations are its children, and caisson.exports, which carries what they pass up, is its exports output. What its modules define under caisson.forChildren is what it registers for the configurations beneath it (lib.caisson-core.mkConfiguration).

frameworkModules takes a class and its registry and returns the framework module of the class, which every evaluation of the class imports: caisson’s core module for the class, read from the closure of caisson, followed by coreModules of the registry. The core module declares caisson.manifest, caisson.<integration>.configurations, caisson.<integration>.exported and caisson.exports.

Structural, flake-parts, nixos and nixos-minimal configurations are built with both.

moduleImportsOf

moduleImportsOf : str -> { lib, manifest } -> args -> registry -> listOf module

The selection of an evaluation over the registry of its class. It takes the class, the view being evaluated and the arguments of the configuration, and reads two of them:

  • moduleImports replaces the default of the class. With none given, the selection is that default: every entry named default, followed by what the levels above the evaluation added with caisson.forChildren.extraDefaultModuleImports, those from the top first, each applied to the lib of the evaluation.
  • extraModuleImports is appended to the selection, whichever it is. A configuration that adds a module with it keeps the default of its class.

Both are functions of the registry returning modules, and every constructor of every integration takes both.

selectionAccountOf

selectionAccountOf : { class : str; args } -> { lib, manifest } -> account
account = { act : "default" | "appended" | "reset";
            position : nullOr { file; line; };
            default : listOf { key : nullOr str; level : nullOr namePath; file : nullOr str; };
            selected : listOf (nullOr str); }

How an evaluation of class, constructed with args, selected its modules. The manifest of every configuration records it as moduleImports, through the selection argument of mkModuleConfiguration. act is default when the configuration took the default of its class, appended when it gave extraModuleImports alone, and reset when it gave moduleImports. position is the file and line number where the configuration gave the argument, and null for default. default is the default in force, whatever the act: the entries of the registry named default, with a null level and file, then each entry a level above added, with the name path of the level and the file that defines it. selected holds the registry keys of the modules selected, null for a module that is not from the registry. For a NixOS configuration with a home-manager configuration inside it, the home-manager configuration records:

{
  act = "default";
  position = null;
  default = [
    { key = "modules.homeManager.caisson/default"; level = null; file = null; }
    { key = "modules.homeManager.nixos-parent";
      level = [ { type = "system"; name = "x86_64-linux"; } { type = "nixos"; name = "laptop"; } ];
      file = "modules/nixos/default/caisson"; }
  ];
  selected = [ "modules.homeManager.caisson/default" "modules.homeManager.nixos-parent" ];
}

ecosystemAccountOf

ecosystemAccountOf : { name : str; context : str; } -> { explicit ? null; manifest ? libManifest; } -> account
account = { from : "ecosystemSrc" | "defaultEcosystemSrc" | "sources";
            source : nullOr str; }

How an evaluation came by the source of the ecosystem name. It takes the arguments resolveEcosystemSrc takes and follows the same order. from names the declaration that supplied the source: ecosystemSrc for the selection of the configuration, defaultEcosystemSrc for the selection of the tree, and sources for the source the tree pins under exactly that name. source is the name of that source among the pinned sources of the tree, where sources.<name>.pin records its revision. It is null where a selection builds the value in another way, such as a directory inside a source. The name is read from the selection applied to the sources tagged with their names (caisson-core.selectedSourceName), so reading the account forces no source the selection does not read.

The manifest of every configuration records ecosystems, an account for each part of the stack the evaluation used, keyed as the parts are resolved. nixpkgs-lib is always there, since every evaluation runs on the lib. An integration adds the ecosystems it resolves, and its evaluate returns them as ecosystems. For a NixOS configuration that selects its module collection, in a tree whose defaultEcosystemSrc names its nixpkgs input for the library:

{
  nixpkgs-lib = { from = "defaultEcosystemSrc"; source = "nixpkgs"; };
  nixos = { from = "ecosystemSrc"; source = "nixpkgs"; };
}

A package config records nixpkgs-lib and nixpkgs. Its nixpkgs entry also holds libraries, the top-level names the lib of its sets takes from the source of the package set: the package-set libraries, their tables, and the functions nixpkgs publishes from them at the top level of its library.

pkgSetsForSystem, pkgSetOf

pkgSetsForSystem : { context, what } -> manifest -> system -> attrsOf pkgs
pkgSetOf         : { context, what } -> manifest -> attrsOf pkgs -> pkgs
selection        = attrsOf pkgs -> pkgs

How a configuration gets the package set it runs on. It is the same for every integration whose configurations run on a package set.

A configuration selects its set when it is constructed, with the defaultPkgs argument: a function of the package sets available where the configuration is declared, by package config name, each for the system of the evaluation.

lib.caisson.nixos.mkConfiguration {
  defaultPkgs = pkgSets: pkgSets.stable;
}

The function receives the available sets as an attribute set and returns the one to run on, here the set of the package config named stable.

A selection holds for the subtree: the configuration that makes it and every configuration beneath it that selects none, through levels of any integration. A configuration that selects none runs on what the nearest configuration above it selected. A module of a configuration selects for the configurations beneath it, and not for the configuration itself, with the caisson.forChildren.defaultPkgs option. Where no configuration from the top down selects, a configuration runs on the set named default, and a composition that declares no default and selects nothing is told so, with the sets it declares.

The modules of a configuration can also use any available set by name, through the pkgSets argument.

pkgSetsForSystem gives the sets available to an evaluation: the package configs its manifest holds, each projected to its set for the system. pkgSetOf applies the selection in force at the manifest (defaultPkgs, which mkModuleConfiguration records from the argument and lib.caisson-core carries to everything beneath) to those sets.

context and what are used only in the errors the two functions raise. context names the entry point that was called, and what names the evaluation being built. A package config that builds no set for the evaluation’s system fails only when the evaluation selects it or reads it by name. With context = "lib.caisson.nixos.mkConfiguration" and what = "the NixOS configuration `builder-arm` for aarch64-linux", the message reads:

lib.caisson.nixos.mkConfiguration: the package config `cuda` builds no set for aarch64-linux,
which the NixOS configuration `builder-arm` for aarch64-linux needs. Add the system to
`caisson.nixpkgs.systems` in the config's module.

The argument is on the nixos, nixos-minimal, home-manager, home-manager-minimal, flake-parts, structural and colmena constructors. In a flake it selects the pkgs of perSystem. A structural configuration runs on no package set and selects for what is beneath it.

pkgSetAccountOf

pkgSetAccountOf : { own : bool; perSystem : bool; } -> manifest -> attrsOf pkgs -> account
account = { name : nullOr str; level : nullOr namePath; }

Which package config a configuration runs on, and which configuration selected it. The manifest of every configuration records it as pkgSet. name is the name of the package config whose set the selection in force returns. It is read from the selection applied to the available sets tagged with their names, so it is null where the selection returns a set it builds in another way. level is the name path of the configuration that made the selection: the configuration itself when it was constructed with defaultPkgs, else the nearest configuration above that selected for the configurations beneath it, with its own defaultPkgs or with caisson.forChildren.defaultPkgs. The top has the empty name path, [ ]. Where no configuration selects, name is default and level is null.

A configuration evaluated once for every system, such as a structural configuration or a flake, runs on no single set. Its name is null, and its level still says who selected for the configurations beneath it. For a NixOS configuration beneath a structural configuration group that selects pkgSets: pkgSets.other, the NixOS configuration records:

{
  name = "other";
  level = [ { type = "structural"; name = "group"; } ];
}

namespacesOf

namespacesOf : lib -> { caisson : attrsOf (nullOr (listOf str)); caisson-core : listOf str; }

The names of the namespaces of a lib. The manifest of every configuration records them for the lib its evaluation runs on as namespaces. caisson holds, for each entry of lib.caisson, the names of its attributes where it is an attribute set, and null where it is not. caisson-core holds the names of lib.caisson-core. They are names only, so recording them forces nothing past the keys of the namespaces. namespaces.caisson.nixos lists mkConfiguration, mkConfigurationWithEcosystemArgs, mkModule and the rest of the nixos integration.

entriesOf, publish, recordOf, displayName, topValue

entriesOf   : attrsOf level -> attrsOf (attrsOf finalized) -> listOf entry
publish     : listOf entry -> attrsOf (attrsOf value)
recordOf    : listOf entry -> attrsOf (attrsOf recordEntry)
entry       = { path; namePath; notes; code; manifest; attrset; value; description ?; }
level       = { notes : listOf str; code : listOf position; }
recordEntry = { from.namePath; notes; code; }
position    = { file; line : int | null; }
displayName : listOf str -> str
topValue    : attrsOf manifest -> value | attrsOf value

How the configurations in a tree are named and published.

entriesOf gives the entries a configuration passes up: an entry for each configuration declared inside it, directly or deeper, whose manifest records exportsTo. Its second argument is what each integration’s exported selected, by integration and then name. Its first argument is what the configuration adds, for each integration, to the entries of that integration’s configurations it passes up: notes, its caisson.<integration>.exportNote as a list, and code, the files that define its caisson.<integration>.exported. A selector left at its default adds no file.

The configuration that passes a child up makes the entry, because it knows the name the child is declared under. The entry holds the output attribute set and the value the manifest records, and the path from that configuration to the child. The last segment of the path is the name exportsTo.name returns, when the integration defines name. A name that contains / is refused. The structural, flake-parts and system levels on the way are segments of the path. caisson’s core module defines caisson.exports.configurations with entriesOf.

publish is what a top does with the entries. It groups them by the output attribute set named in each entry, computes the names in each group from lib.caisson-core.elide over the paths, and returns the values by attribute set and name. Entries that still share a name are refused, with their paths. The flake-parts top writes the result into the flake outputs, and the structural top into what it returns.

recordOf is the export record of the same entries, keyed as publish keys the values. For each name published, from.namePath is the path from the top to the configuration by the names the configurations are declared under, before any exported renamed them, so it is the address of the configuration’s manifest under the children of the top’s manifest. notes holds the sentence the integration’s exportsTo.name wrote, when it wrote a sentence, and then the exportNote of each level the configuration passed through. code holds the files that define each level’s exported, and the file and line number where the integration gives its exportsTo, in the same order, from the configuration up to the top. A position is { file; line; }, with the line number in line. The module system records the file of each definition of an option but not its line number, so the position of an exported has a line of null. Both tops publish their manifest as caisson.manifest, carrying the record as exportRecord.

displayName writes the kept segments out as a name: in path order, separated by /. Every name caisson publishes is written by it.

topValue is what a tool reads from a top that is a configuration evaluated per system, given its evaluations by system. They are named as anything published is, and the name of the top, which the file that returns it stands for, is left out of each: a single evaluation is its value, and several are the values by system.

mkIntegration

mkIntegration :
  { name       : string            # the namespace, lib.caisson.<name>
  , class      : string            # the module class this integration owns
  , mkConfiguration                  : pattern -> result  # the entry point, a pattern function
  , mkConfigurationWithEcosystemArgs : pattern -> result  # the same pattern plus ecosystemArgs
  , top        ? null : manifest -> value  # what a configuration with no parent returns
  , extra      ? { } : attrs       # further members of the namespace
  } -> { namespace : attrs }

mkIntegration builds an integration that owns a module class from a declaration. The result has the part namespace.

namespace is the value to publish as lib.caisson.<name>. It holds mkConfiguration, mkConfigurationWithEcosystemArgs, mkModule, configurationsModule, mkTopConfiguration when the declaration gives top, and everything in extra. The entry points come from the declaration, written as pattern functions whose body is mkEvaluation { compose, evaluate } applied to the admitted arguments. The pattern of mkConfiguration names the arguments every entry point takes (ecosystemSrc, pkgSets, configModule, moduleImports, specialArgs) and the few of its target, with the required arguments bare and the rest defaulting to null; a comment beside each argument says what it is, and the at line of Nix’s argument error points at that block. The twin’s pattern repeats the entry point’s plus ecosystemArgs, merged over the composed call before evaluating, so the caller can set or replace anything the evaluator takes. mkModule is lib.caisson-core.mkModule bound to class. extra is for the members a declaration cannot generate, such as a variant entry point, an adapter, or the composition an alt over this class builds on.

configurationsModule is the module that lets a configuration hold configurations of this integration beneath it. It declares caisson.<name>.configurations and caisson.<name>.exported. An integration written outside caisson registers a module that imports it where its configurations may be declared. The integrations of caisson have their options declared by the core module of caisson, through configurationsModuleFor, so importing the configurationsModule of one of them into a caisson evaluation declares its options a second time.

top is what a configuration of the integration returns when it has no parent, as a function of its finalized manifest. When the declaration gives it, the namespace has mkTopConfiguration, which takes the arguments of mkConfiguration, finalizes the configuration with no parent, and applies top to the manifest. Its argument pattern is the pattern of mkConfiguration.

The integration’s compose receives the admitted arguments and returns an attribute set. That set must hold ecosystemArgs, the arguments of the evaluator’s call as the integration composed them; it may hold anything else evaluate needs, such as the resolved source. evaluate receives that set and the call arguments to use, and returns the evaluation.

The constructor returns values rather than an overlay output, because the attribute names an overlay produces must not depend on final. The overlay file writes the key itself:

overlay = final: prev:
  let
    integration = final.caisson.integrations.mkIntegration { ... };
  in
  {
    caisson = (prev.caisson or { }) // {
      nixos = integration.namespace;
    };
  };

mkAltIntegration

mkAltIntegration :
  { over     : attrs               # the integration that owns the class, e.g. final.caisson.nixos
  , mkConfiguration                  : pattern -> result
  , mkConfigurationWithEcosystemArgs : pattern -> result
  , top      ? null : manifest -> value  # as for mkIntegration
  , extra    ? { } : attrs
  } -> attrs                       # the value of lib.caisson.<name>

mkAltIntegration builds an integration that evaluates a class another integration owns. over is that owning integration, reached through the lib. The result is the value to publish as lib.caisson.<name>: the entry points of the declaration, pattern functions as for an owner, mkTopConfiguration when the declaration gives top, and extra. It has no mkModule, because modules of the class are registered through the owner, and no configurationsModule, because its configurations are configurations of the owner. The composition its entry points evaluate over is expected to build on the composition the owner publishes, so that the evaluators cannot produce different configurations from the same arguments; caisson.nixos-minimal composes through caisson.nixos.compose and caisson.home-manager-minimal through caisson.home-manager.configuration, which builds on caisson.home-manager.compose.

Every integration caisson ships is declared with these constructors: mkIntegration for the owners of a class (nixos, flake-parts, structural, home-manager, colmena, terranix, system-manager) and mkAltIntegration for caisson.nixos-minimal and caisson.home-manager-minimal. Each overlay file holds the composition, the evaluator step, the patterns and the extras of its integration, and nothing else.

eval-weight

  • Source: lib-overlays/tooling/eval-weight/

The evaluation-cost measurement harness: eval-weight.mkCheck builds a check derivation that measures eval scenarios in a sandbox and gates deterministic metrics against a committed baseline. Documented in Evaluation weight.

mkMemoizedDerivationRead

  • Source: lib-overlays/tooling/mk-memoized-derivation-read.nix

Builds memoized derivation-content readers; see the source header.

Integration namespaces

Each integration is a library overlay exported by this flake (libOverlays.<ecosystem>). Composing an integration contributes its lib.caisson.<ecosystem> namespace, documented below (the flake-parts integration also contributes the lib.flake-parts mirror of the flake-parts library, instantiated over the composed lib). Each entry point takes its ecosystem as an ecosystemSrc argument, and every ecosystem, flake-parts included, is resolved from the composition of the consumer.

An adapter’s ecosystem source resolves in layers: the explicit ecosystemSrc argument first, applied to sources through selectedEcosystemSrc; then the source defaultEcosystemSrc sources returns for <name> (defaultEcosystemSrc is an mkLib argument, carried by the manifest); then the source named exactly <name> in the sources passed to mkLib. The name is the integration’s ecosystem, and a single ecosystem may serve several integrations: nixos for the nixos and nixos-minimal integrations, nixpkgs for the nixpkgs integration, colmena for the caisson-colmena and colmena integrations, disko for the caisson-disko and disko integrations, nix-darwin for the darwin integration, then home-manager, hjem, terranix, system-manager and flake-parts for the integration of the same name (the table in Ecosystem sources). A full miss throws at the adapter, naming those places; a composition built without mkLib (no manifest) accepts only the explicit argument. Common conventions:

  • Every integration exports mkConfiguration (evaluate the target’s module system with the selected class modules) and, where it has a module class, mkModule, the form a tree registers that class’s modules with (caisson-core.mkModule bound to the class). Target-specific variants and helpers sit beside them under the same namespace.
  • configModule: the top-level module of the configuration, always a single module (compose several with imports); the framework’s selected class modules are applied beside it.
  • specialArgs: extra module arguments; the same name on every entry point, translated to the evaluator’s spelling where it differs (home-manager’s extraSpecialArgs, terranix’s extraArgs).
  • defaultPkgs is accepted by the nixos, nixos-minimal, darwin, hjem, home-manager, home-manager-minimal, terranix, flake-parts, structural, caisson-colmena, colmena, caisson-disko and disko entry points. It selects the package set the configuration runs on from the package configs the composition declares (see caisson.integrations.pkgSetOf). In a configuration evaluated per system, the modules get the package sets by config name as the pkgSets special argument.
  • pkgSets: on the colmena and disko entry points, an attrset of package sets passed through as the pkgSets special argument, where pkgSets.pkgs is the package set of colmena eval. flake-parts and structural forward it to their modules.
  • The signature is the whole surface. An entry point takes exactly the arguments listed for it and composes the evaluator’s call from them; nothing else is forwarded, and an unknown or missing argument is Nix’s function-argument error, raised at the call site and pointing at the entry point’s pattern, whose comments name the caisson argument to use where such an argument exists. That keeps an evaluator argument from being silently overwritten (modules), silently dropped (anything the minimal evaluator does not take), or surfacing as a conflict inside the evaluator (pkgs beside the framework’s nixpkgs.pkgs).
  • The full surface of the evaluator is reachable, deliberately, through the mkConfigurationWithEcosystemArgs twin of each entry point. It takes the same arguments plus ecosystemArgs, an attrset merged over the composed evaluator call verbatim, last: anything the evaluator accepts can be set or replaced there, including what caisson composed (modules, specialArgs, the package set). The name is the contract: from there on the evaluator’s semantics are the caller’s to know.
  • moduleImports: a selection function over the corresponding class registry (lib.caisson.modules.<class>), returning the list of modules to apply; the default is the default default, every entry named default (default, <project>/default). The framework module, every entry named core, applies before the selection whatever it is. The list shape matches libOverlayImports; for order-sensitive list-typed options, prefer mkOrder over selection position.
  • Framework-provided special arguments compose first; the caller’s win on conflict.

caisson.structural (module class structural)

  • Source: lib-overlays/structural/default.nix

The empty integration: it wraps no ecosystem, and its class carries nothing but caisson’s core module: the manifest, the configurations declared beneath, the registry selectors and caisson.exports. A structural configuration is the top of a repository whose point is what it exports (the default.nix of caisson is such a top), and a layer at any depth, declared beneath another configuration under caisson.structural.configurations.

  • mkModule : freeformModule -> module: the registration form for structural modules.
  • mkConfiguration:
mkConfiguration :
  { configModule  ? (the configuration registered under the name)
                  : module                                  # structural class
  , moduleImports ? (every entry named default)
                  : attrsOf module -> listOf module          # selection from the structural class registry
  , specialArgs   ? { }
  , pkgSets       ? null : attrs                            # the pkgSets special argument
  } -> configuration

Returns a configuration, a function of { name, parent }, built with lib.caisson-core.mkConfiguration. The parent that declares it under caisson.structural.configurations.<name> finalizes it, and mkTopConfiguration finalizes it at a top. Nothing is evaluated until the manifest is read.

The evaluation is the framework module (every core of the class, the core of caisson, read through the integration’s closure), the selected structural modules and the config module, with evalModules over the lib of the view being evaluated. When configModule is absent, the config module is the configuration registered under the configuration’s name, lib.caisson.configs.structural.<name>, and there is none when nothing is registered under it. The manifest’s value is the evaluation as evalModules returned it (config, options); outputs.exports is caisson.exports, the lib, libOverlays, modules and pkgOverlays the selectors chose; and children holds the configurations declared beneath it. The integration wraps no ecosystem, so the signature has no ecosystemSrc.

  • mkConfigurations: a configuration for every configuration registered in the class, each as mkConfiguration builds it without configModule.
  • mkConfigurationWithEcosystemArgs: the twin; ecosystemArgs is merged over the evalModules call (class, modules, specialArgs).
  • mkTopConfiguration: the same arguments. It finalizes the configuration with lib.caisson-core.finalizeTop and returns outputs.exports with the configuration’s manifest beside it as caisson.manifest, which is what default.nix returns for a reader that indexes attributes of the file’s value.

caisson.flake-parts (module class flake)

  • Source: lib-overlays/flake-parts/default.nix
  • mkModule : freeformModule -> module: the registration form for flake-parts modules (caisson-core.mkModule bound to the flake class).
  • mkConfiguration:
mkConfiguration :
  { configModule  ? (the configuration registered under the name)
                  : module                                  # flake class
  , pkgSets       ? null                                    # the `pkgSets` special argument
  , ecosystemSrc  ? null : sources -> source                # the flake-parts source
  , moduleImports ? (every entry named default)
                  : attrsOf module -> listOf module          # selection from the flake class registry
  , specialArgs   ? { }                                     # beside `lib`, the composed library
  } -> configuration

Returns a configuration, a function of { name, parent }, built with lib.caisson-core.mkConfiguration. A parent that declares it under caisson.flake-parts.configurations.<name> finalizes it, and mkTopConfiguration finalizes it at a top. The evaluator’s call is flake-parts’ evalFlakeModule over the lib of the view being evaluated: the manifest’s value is the evaluation (config, options), outputs.flake is what flake-parts’ mkFlake returns from it, outputs.exports is caisson.exports, and children holds the configurations declared beneath it. When configModule is absent, the config module is the configuration registered under the configuration’s name, lib.caisson.configs.flake.<name>. flake-parts’ inputs are the manifest’s pinned sources, and the integration ties self the way Nix does for a flake: the evaluation’s outputs with the root’s source info (out path, revision, last-modified) beside them and self.inputs the sources, so flake-parts modules receive self, self' and inputs as they would under Nix’s flake evaluation. moduleImports selects over the flake class of lib.caisson.modules, the same registry every adapter selects from, so modules arriving by local registration, overlay contribution, or consumed project are all selectable. flake-parts itself resolves like every ecosystem, from ecosystemSrc, the source defaultEcosystemSrc sources returns for flake-parts, or the pinned source named flake-parts, and is instantiated over the composed library. The name of the configuration sets flake-parts’ moduleLocation, so exported modules deduplicate across revisions.

  • mkTopConfiguration: the same arguments. It finalizes the configuration with lib.caisson-core.finalizeTop, under the name the composition declares on mkLib, and returns its flake outputs (outputs.flake). It is what a flake.nix returns from outputs, and what a flakeless top that keeps flake-parts returns from default.nix.
  • mkConfigurations: a configuration for every configuration registered in the class, each as mkConfiguration builds it without configModule.
  • types.libOverlay: a module-system option type for built library overlays. Its check verifies the structure recursively: an attrset with an overlay function and a (possibly absent) imports list whose entries are themselves valid libOverlays. Used by options that carry overlays, such as caisson.libOverlays.exported.
  • types.manifest: a structural option type for the caisson-core lib manifest ({ inputs, modules, libOverlays, ecosystems, projects, systems }). The export-side check: the core flake-parts module reads the manifest of the evaluation through an option of this type before projecting the exports and the flake.modules output.

caisson.nixos (module class nixos)

  • Source: lib-overlays/nixos/default.nix
  • mkModule : freeformModule -> module: class-bound mkModule.
  • mkConfiguration:
mkConfiguration :
  { configModule  ? (the configuration registered under the name)
                  : module                                  # nixos class
  , ecosystemSrc  ? null : sources -> source                # the source under the `nixos` key
  , moduleImports ? (every entry named default)
                  : attrsOf module -> listOf module          # selection from the nixos class registry
  , specialArgs   ? { }
  } -> configuration

Returns a configuration, a function of { name, parent }, built with lib.caisson-core.mkConfiguration. A parent that declares it under caisson.nixos.configurations.<name> finalizes it, and mkTopConfiguration finalizes it at a top. ecosystemSrc resolves under the nixos key (see Ecosystem sources); the evaluation is <ecosystemSrc>/nixos/lib/eval-config.nix, a nixpkgs source tree, over the selected class modules and the config module. When configModule is absent, the config module is the configuration registered under the configuration’s name, lib.caisson.configs.nixos.<name>.

A NixOS configuration is evaluated per system, so the integration passes perSystem: a declared configuration is an evaluation for every system in force where it is declared (systems on mkLib), each under its name beneath its system in the tree. There are as many as there are systems, also for a single system, and none where no system is in force. An evaluation’s value is the evaluated NixOS configuration, and its outputs are toplevel, vm, vmWithBootLoader and images, each a reference into config.system.build, and exports, what the configuration passes up to its parent.

A module of the configuration declares configurations beneath it under caisson.<integration>.configurations. They see the evaluation as nearest.nixos.

The package set comes from the composition, through the manifest. An evaluation runs on the set the defaultPkgs argument selects from the package sets available for the system of the evaluation (see caisson.integrations.pkgSetOf), defined as nixpkgs.pkgs. A configuration that passes none runs on what the nearest configuration above it selected, and on the set named default where none above selects. The modules can also use any set for that system by config name, through the pkgSets special argument. A module reads its name as lib.caisson-core.evalManifest.name and its system as lib.caisson-core.evalManifest.system.

A NixOS configuration records exportsTo as nixosConfigurations, the value being the evaluated configuration: a flake-parts or structural top publishes the NixOS configurations declared beneath it there, under names from their paths.

  • mkTopConfiguration: the same arguments. It finalizes the configuration with lib.caisson-core.finalizeTop and returns what a tool reads (caisson.integrations.topValue): the evaluated NixOS configuration where there is a single evaluation, which is what nixos-rebuild --file reads and what a test evaluates, and the evaluated configurations by system where there are several.
  • mkConfigurationFull: as mkConfiguration, additionally passing nixpkgs’ module-list.nix as baseModules.
  • mkConfigurationWithEcosystemArgs: the twin with ecosystemArgs (see the conventions above); eval-config’s baseModules is among the arguments reachable that way.
  • mkConfigurations: a configuration for every configuration registered in the class, each as mkConfiguration builds it without configModule.
  • compose : { context?, nixpkgsModule? } -> { lib, manifest } -> args -> { modules, specialArgs, system, pkgSets, src }: the composition of the class on the view being evaluated, from the caisson arguments (the framework and selected modules, the config module, the package-set module, the system, the resolved nixpkgs source), which every entry point here builds on and which an integration evaluating the nixos class with another evaluator reads, so two evaluators cannot express different machines from the same arguments. nixpkgsModule = false delivers the package set as the pkgs module argument for an evaluation without NixOS’ nixpkgs module.

caisson.nixos-minimal (module class nixos, owned by caisson.nixos)

  • Source: lib-overlays/nixos-minimal/default.nix

A second integration over the nixos class: the minimal evaluator, evalModules from <ecosystemSrc>/nixos/lib, with no NixOS base modules, so the config module declares any options it uses and the package set arrives as the pkgs module argument rather than through nixpkgs.pkgs. It carries constructors only. The class, its registration form (caisson.nixos.mkModule), its framework module and its default default belong to the nixos integration, and the module list comes from caisson.nixos.compose; composing it needs the nixos integration composed beside it. In the tree its configurations are nixos configurations: the manifest’s type is nixos, a configuration is declared under caisson.nixos.configurations and published under nixosConfigurations, and it is nearest.nixos for what is declared beneath it.

  • mkConfiguration : { configModule?, ecosystemSrc?, defaultPkgs?, moduleImports?, extraModuleImports?, specialArgs?, prefix? } -> configuration: the arguments of caisson.nixos.mkConfiguration plus prefix, and the same result, a configuration whose manifest’s value is the evaluation. The system and the package set come from the composition as they do there.
  • mkTopConfiguration: the same arguments; finalizes the configuration at a top and returns the evaluation.
  • mkConfigurationWithEcosystemArgs: the twin with ecosystemArgs (prefix, modules, specialArgs).

caisson.darwin (module class darwin)

  • Source: lib-overlays/darwin/default.nix

A darwin configuration is a macOS machine, evaluated with nix-darwin’s eval-config.nix over the composed library, the way nix-darwin’s darwinSystem calls it. caisson adds to the modules what darwinSystem adds: nixpkgs.source from the nixpkgs the composition declares, no check of NIX_PATH, and the revision of the nix-darwin source in the version of the system.

Like a NixOS configuration, a darwin configuration is evaluated per system, for every system in force where it is declared, and runs on the package set selected for it (defaultPkgs, else the set named default), which caisson hands to nixpkgs.pkgs. nixpkgs.hostPlatform defaults to the system of the evaluation. nix-darwin records no nixpkgs revision for a package set it did not build itself, so the system carries none, as with darwinSystem given pkgs.

A tree registers the machine as configs/darwin/macbook-1/default.nix and declares it with caisson.darwin.configurations.macbook-1 = lib.caisson.darwin.mkConfiguration { };, which finds the configuration by its name. It is published as darwinConfigurations.macbook-1, the evaluated configuration with system beside config, which is what darwin-rebuild reads. darwin-rebuild without --flake '.#<name>' looks the machine up by its local host name, so we recommend declaring a machine under that name.

Every configuration evaluated per system has an evaluation for each system in force where it is declared. A tree whose systems hold both Linux and darwin systems therefore declares its darwin machines under a configuration that narrows the systems beneath it, such as a structural configuration with caisson.forChildren.systems = [ "aarch64-darwin" ];, and its NixOS machines under one narrowed to its Linux systems.

A darwin machine activates the home-manager configurations and the hjem configurations declared inside it, with the modules of modules.darwin."caisson/default". A home-manager configuration inside a darwin machine is published as <user>@<machine>, as a home-manager configuration inside a NixOS machine is, and gets osClass = "darwin" and the machine as darwinConfig, as home-manager’s nix-darwin module gives them.

caisson’s tests evaluate a darwin machine on Linux and compare its system with what darwinSystem builds from the same module and package set. They also evaluate a machine with a home-manager configuration and a hjem configuration inside it, and compare the manifest of the hjem configuration with the manifest hjem’s nix-darwin module makes. caisson’s CI builds the systems of both machines on a macOS runner. Activation on a running machine is not tested.

  • mkModule : freeformModule -> module: class-bound mkModule for darwin modules.
  • mkConfiguration : { configModule?, ecosystemSrc?, defaultPkgs?, moduleImports?, extraModuleImports?, specialArgs? } -> configuration: the arguments of caisson.nixos.mkConfiguration. configModule defaults to the configuration registered under its name in configs/darwin. ecosystemSrc is the nix-darwin flake or source tree, resolved from the composition under the name nix-darwin when absent.
  • mkTopConfiguration: the same arguments; finalizes the configuration at a top and returns the evaluation, or the evaluations by system where several systems are in force.
  • mkConfigurationWithEcosystemArgs: the twin with ecosystemArgs, merged over the call of eval-config.nix (modules, specialArgs).

caisson.home-manager (module class homeManager)

  • Source: lib-overlays/home-manager/default.nix

  • mkModule : freeformModule -> module.

  • mkConfiguration : { configModule?, ecosystemSrc?, defaultPkgs?, moduleImports?, extraModuleImports?, specialArgs?, osConfig?, check? } -> configuration

    mkConfiguration builds a home-manager configuration: a configuration evaluated with home-manager’s evaluator, the modules under <ecosystemSrc>/modules. A home-manager configuration is declared under caisson.home-manager.configurations.<name> in a module of any configuration, or finalized as a top. mkConfigurationWithEcosystemArgs takes ecosystemArgs as well, which is how to pass home-manager’s lib argument.

    A home-manager configuration is evaluated per system. A home-manager configuration declared inside a NixOS configuration is evaluated for the system of that configuration by default, and the NixOS configuration can list other systems with caisson.forChildren.systems. A home-manager configuration declared anywhere else is evaluated for each system the composition declares on mkLib, unless a configuration it is declared inside lists others the same way.

    configModule defaults to the configuration registered as configs/homeManager/<name>, where <name> is the name of the home-manager configuration. home.username defaults to the name the home-manager configuration is declared under, and a module of the home-manager configuration may set a different username.

    The home-manager configuration runs on the package set defaultPkgs selects (see caisson.integrations.pkgSetOf). A home-manager configuration constructed with no defaultPkgs and declared inside a NixOS configuration runs on the set of that NixOS configuration, unless the NixOS configuration sets caisson.forChildren.defaultPkgs. pkgs in the home-manager configuration is that set. The evaluation leaves out home-manager’s nixpkgs module, as home-manager’s NixOS module does under useGlobalPkgs, so nixpkgs.config and nixpkgs.overlays belong in the package config. The modules can also use any package set for that system by config name, through the pkgSets special argument.

    A home-manager configuration declared inside a NixOS configuration is given that machine. The integration hands the home-manager configuration the configuration of the machine as the module argument osConfig, and the class of the machine as osClass. These are the arguments home-manager’s NixOS module passes to a home-manager configuration it embeds. The configuration of the machine is the childless evaluation of the NixOS configuration, which leaves out the configurations declared inside it, because the home-manager configuration is one of them. A home-manager configuration with no NixOS configuration above it gets osConfig = null, as a home-manager configuration evaluated by home-manager alone does. An osConfig passed to mkConfiguration takes the place of the configuration of the machine.

    Such a home-manager configuration also imports the home-manager module nixos-parent by default, which the NixOS configuration adds, and which sets the options of the home-manager configuration that come from the machine. The home-manager default module gives every home-manager configuration activation-drift, which makes a home-manager configuration print a warning when it is activated on a machine it was not built for. The machine activates the home-manager configuration with the NixOS module home-manager-activation. See modules.nixos."caisson/default".

    A home-manager configuration that passes moduleImports selects all of its modules itself. Such a home-manager configuration gets modules."caisson/nixos-parent" and modules."caisson/activation-drift" only if its list names them. extraModuleImports adds to the default selection and keeps both.

    home-manager’s legacy argument nixosConfig stays null. Its fontconfig module reads nixosConfig.home-manager.useUserPackages, an option that only home-manager’s NixOS module declares, and caisson does not use that module.

  • mkTopConfiguration takes the same arguments. It finalizes the home-manager configuration at a top and returns the evaluated home-manager configuration with its activationPackage, which is what the home-manager CLI reads.

  • A top publishes home-manager configurations as homeConfigurations.<name>. A home-manager configuration declared inside a NixOS configuration is published under the name the home-manager configuration is declared under, @, and the name the NixOS configuration is declared under. The home-manager CLI looks up $USER@$(hostname), so that is the name it finds when the two names are the user and the hostname. A home-manager configuration with no NixOS configuration above it is published under its name.

caisson.hjem (module class hjem)

  • Source: lib-overlays/hjem/default.nix

A hjem configuration is the files and packages of one user, written with the options of hjem’s user module (files, xdg.config.files, packages, directory). hjem evaluates a user only inside its NixOS and nix-darwin modules, as an entry of hjem.users. caisson evaluates the user module itself, with the arguments those modules pass: hjem’s library as hjem-lib, the name of the user as name, the package set as pkgs, and the machine above the configuration as osConfig, null when there is none.

A hjem configuration is evaluated per system, once for each system in force where it is declared, and runs on the package set selected for it (defaultPkgs, else the selection of the nearest configuration above, else the set named default). user defaults to the name the configuration is declared under, and clobberFiles defaults to false, as in hjem’s NixOS module.

A tree registers the user as configs/hjem/alice/default.nix and declares it with caisson.hjem.configurations.alice = lib.caisson.hjem.mkConfiguration { };, which finds the configuration by its name. Its outputs are what hjem’s tools read:

OutputValue
manifestthe version 3 manifest of the files of the user, as hjem’s OS modules write it
manifestFilethat manifest as a JSON file, checked against hjem’s schema with cue vet when it is built
packagesthe packages of the user
clihjem’s CLI, built from the hjem source the configuration is evaluated with

A failed assertion of the user fails manifest, manifestFile and packages with its message.

A top publishes the configuration as hjemConfigurations.alice, with manifest and packages. hjem standalone switch --flake . reads hjemConfigurations."$USER" from the flake in the current directory, so it finds the configuration of the user running it when the two names are the same; --flake-attr names another attribute. The value of a hjem configuration that is a top has manifest and packages too, which is what hjem standalone switch --config reads from a file.

Inside a NixOS machine. A NixOS configuration activates the hjem configurations declared inside it with caisson/hjem-activation, and gives each of them the hjem module caisson/nixos-parent, which sets directory from the account of the user on the machine. Such a configuration also imports hjem’s modules/nixos/systemd.nix with the utils and options of the machine, as a user that hjem’s NixOS module embeds does, so it can declare systemd user units under systemd.services and the other unit types. hjem’s NixOS module is not used.

Inside a darwin machine. A darwin configuration applies the hjem configurations declared inside it with the launchd agent of caisson/hjem-activation, and gives each of them the hjem module caisson/darwin-parent, which sets directory from the account of the user on the machine.

Inside a system-manager machine. A system-manager configuration activates the hjem configurations declared inside it with the same caisson/hjem-activation module and units as a NixOS machine, and gives each of them the hjem module caisson/system-manager-parent, the same module as caisson/nixos-parent under a second name. See modules.systemManager."caisson/default".

caisson’s tests compare the manifest of a hjem configuration inside a NixOS machine with the manifest hjem’s NixOS module makes from the same user module, and build its manifest file and the units that activate it. Activation on a running machine is not tested.

  • mkModule : freeformModule -> module: class-bound mkModule for hjem modules.
  • mkConfiguration : { configModule?, ecosystemSrc?, defaultPkgs?, moduleImports?, extraModuleImports?, specialArgs? } -> configuration. configModule defaults to the configuration registered under its name in configs/hjem. ecosystemSrc is the hjem flake or source tree, resolved from the composition under the name hjem when absent.
  • mkTopConfiguration: the same arguments; finalizes the configuration at a top and returns its value, or the values by system where several systems are in force.
  • mkConfigurationWithEcosystemArgs: the twin with ecosystemArgs, merged over the call of evalModules (modules, specialArgs).
  • manifestOf : hjemLib -> config -> manifest: the manifest of an evaluated user, from hjem’s library and the config of the user.

caisson.home-manager-minimal (module class homeManager, owned by caisson.home-manager)

  • Source: lib-overlays/home-manager-minimal/default.nix

A second integration over the homeManager class: home-manager’s minimal evaluation, the module list that omits what a standalone activation needs, which home-manager selects with its minimal flag. It carries constructors only. The class, its registration form (caisson.home-manager.mkModule), its framework module and its default default belong to the home-manager integration, and the configuration it builds comes from caisson.home-manager.configuration; composing it needs the home-manager integration composed beside it.

  • mkConfiguration takes the arguments of caisson.home-manager.mkConfiguration and returns the same result. The configuration is a home-manager configuration: it is declared under caisson.home-manager.configurations and published under homeConfigurations.
  • mkTopConfiguration takes the same arguments and returns the evaluated home-manager configuration.
  • mkConfigurationWithEcosystemArgs: the twin with ecosystemArgs.

caisson.nixpkgs (module class nixpkgsConfig)

  • Source: lib-overlays/nixpkgs/default.nix

The nixpkgs integration builds package sets from package configs. A package config is a module evaluation of the class nixpkgs evaluates pkgs/top-level/config.nix under: nixpkgs’ options (allowUnfree and the rest) sit at the top level, as upstream declares them, and caisson’s sit under caisson.nixpkgs. Its module lives at configs/nixpkgsConfig/<name>/default.nix, and further modules of the class are registered under modules/nixpkgsConfig/<name>.

mkConfiguration

lib.caisson.nixpkgs.mkConfiguration :
  { configModule  ? null : module      # the config's module
  , moduleImports ? null : registry -> listOf module
  , ecosystemSrc  ? null : sources -> path  # the nixpkgs source tree for this config
  } -> configuration

Declares a package config, in mkLib’s pkgSets. configModule defaults to the configuration registered under the name the config is declared under (lib.caisson.configs.nixpkgsConfig.<name>), and a config with neither evaluates with no module of the tree. moduleImports selects from the nixpkgsConfig module registry and defaults to every entry named default. ecosystemSrc is a function of sources returning the nixpkgs tree for this config, replacing the nixpkgs key for this evaluation and nothing else; a value given in place of the function is refused. A config on another nixpkgs revision writes ecosystemSrc = sources: sources.nixpkgs-stable;. When ecosystemSrc is absent, the tree resolves from the composition’s declarations (see Ecosystem sources).

It returns a configuration: a function { name, parent }: manifest that mkLib calls. The manifest has type = "nixpkgs", the config’s name, its systems, the evaluation as value, the config handed to nixpkgs as config, and one package set per system under children.nixpkgs.<system>, whose value is the set.

These options under caisson.nixpkgs declare the sets:

  • caisson.nixpkgs.systems: the systems to build a set for. It defaults to the systems declared on mkLib; a config with no systems in force is an error saying to declare them.
  • caisson.nixpkgs.overlays: the package overlay registry entries the sets apply, each after the entries it imports and each key once. It defaults to every entry named default or <project>/default, and a definition replaces that default.
  • caisson.nixpkgs.extraOverlays: entries applied in addition to overlays, whichever selection that is. A config that adds an entry with it keeps the default: caisson.nixpkgs.extraOverlays = [ lib.caisson.pkgOverlays.<name> ];.

caisson instantiates the sets itself, without going through pkgs/top-level/default.nix: it boots the stdenv stages and stage.nix on the composed library with pkgsManifest filled in, so pkgs.lib is the library the config is declared under and pkgs.lib.caisson-core.pkgsManifest is the set’s manifest. The derivations are those import nixpkgs { system; config; overlays; } produces. mkConfigurationWithEcosystemArgs takes ecosystemArgs as well, merged into every instantiation (crossSystem, crossOverlays, stdenvStages).

mkConfigurations

lib.caisson.nixpkgs.mkConfigurations :
  { moduleImports ? null; ecosystemSrc ? null; } -> attrsOf configuration

Declares a package config for every configuration registered in configs/nixpkgsConfig, by its name, each as mkConfiguration builds it without configModule. The arguments apply to all of them. pkgSets = lib: lib.caisson.nixpkgs.mkConfigurations { }; is the usual declaration; a tree that wants only some configs, or a config that differs, declares them with mkConfiguration. mkIntegration generates this function for every integration whose mkConfiguration finds its module by name.

mkTopPkgSet

lib.caisson.nixpkgs.mkTopPkgSet : manifest -> pkgs

Turns a package config into what the legacy readers take, for a repository that is a package set. Its default.nix ends with lib.caisson.nixpkgs.mkTopPkgSet lib.caisson.nixpkgs.pkgSets.default.

The result is the set for the current system, with a __functor that takes the legacy arguments and returns the set for the system asked for. Nix auto-calls a lambda and not a functor, so nix-build -A hello indexes the set, and import ./. { system = "aarch64-linux"; } calls it.

  • system defaults to builtins.currentSystem. Pure evaluation has none: the result is then the functor alone, and a call has to pass system. A system the config builds no set for is refused, with the systems it does build.
  • overlays are applied over the config’s selection, through the set’s appendOverlays.
  • config must be empty. The nixpkgs config is the package config, declared in configs/nixpkgsConfig/<name>.

The set carries its manifest in pkgs.lib.caisson-core.pkgsManifest, whose parent is the config, so nothing else is added to it.

traced, traceChainOf

<package config manifest>.traced.children.nixpkgs.<system>.value : pkgs
lib.caisson.nixpkgs.traceChainOf : drv -> nullOr (listOf step)
step = { step : "callPackage" | "override" | "overrideAttrs" | "overrideDerivation";
         drvPath : str; from : nullOr str;
         touched : nullOr (listOf str); position : nullOr { file; line; };
         # on the callPackage step:
         file : nullOr str; resolved : listOf str; set : { config : str; system : str; }; }

How each package of a package set was made. nixpkgs keeps no link from the result of an override to what it overrode, so the trace is recorded while a set is built. The manifest of a package config holds traced: the same sets, built on the same lib with callPackageWith replaced by a version that records each call. Only an inspection reads it, so an ordinary evaluation builds no traced set.

In a traced set, every derivation a callPackage returns carries caissonTrace, and so does every derivation its override, overrideAttrs and overrideDerivation return. Each record names the step, the derivation it was applied to, the names the step was given, and the position of the argument set the caller wrote. The callPackage at the bottom also names the file of the package function, the arguments it took from the scope, and the set. The trace is attributes beside the derivation, so a traced package has the drvPath of the same package untraced.

traceChainOf reads a trace as plain data, from the last step down to the callPackage, with each derivation given by its drvPath. It gives null for a value that carries no trace. For a hello made by (hello.override { }).overrideAttrs (_: { pname = "hello-traced"; }) in the traced set of the default config, the steps are overrideAttrs, override and callPackage, and the last names pkgs/by-name/he/hello/package.nix and { config = "default"; system = "x86_64-linux"; }.

pkgSets

lib.caisson.nixpkgs.pkgSets : attrsOf manifest     # the package configs, by name

pkgSets is a view of the manifest. It holds the package configs mkLib recorded, by name.

Overlay constructors and types

  • mkScope : pkgs -> (callPackage -> attrs) -> scope: a makeScope wrapper handing the scope function its callPackage.
  • mkPackagesOverlay : pkgsFn -> name -> overlayFn: turns a scope function (or path; optionally context-taking { callPackage, inputs, lib }) into an overlay that merges the scope under attribute name.
  • mkPolyfillOverlay : overlayFn -> name -> overlayFn: wraps an overlay (or path; optionally context-taking) for registration alongside package overlays; the name is ignored.
  • types.nixpkgsOverlay, types.nixpkgs: option types.

caisson.caisson-colmena (module class caisson-colmena)

  • Source: lib-overlays/caisson-colmena/default.nix

caisson-colmena is the native colmena integration, and the one we recommend for writing new configurations. Its machines are NixOS configurations declared like any other, so they share modules through the registry and through structural groups, and they take their package sets from caisson’s package configs. The integration named colmena, below, exists for porting a hive written for colmena as it is.

A colmena configuration is a module of the caisson-colmena class, a class caisson defines itself, since colmena’s hive format is not a module class; the class carries the caisson- prefix because the bare name is not caisson’s to claim. The integration is not compatible with colmena’s hive modules (makeHive, defaults, meta.nixpkgs). The word hive names only the output colmena’s CLI reads.

A colmena configuration declares meta (name, description, machinesFile, allowApplyAll), and the machines colmena deploys as NixOS configurations, under caisson.nixos.configurations.<name>, the way any configuration declares them:

caisson.caisson-colmena.configurations.default = lib.caisson.caisson-colmena.mkConfiguration {
  configModule = { lib, ... }: {
    meta.allowApplyAll = false;
    caisson.nixos.configurations.nas-1 = lib.caisson.nixos.mkConfiguration {
      configModule = { deployment.targetHost = "nas-1.lan"; /* ... */ };
    };
  };
};

The nodes are the NixOS configurations the colmena configuration passes up, each under the name it is published under. The default module of the class adds caisson’s NixOS module colmena-deployment to the default selection of the NixOS configurations declared inside it. That module declares colmena’s deployment options and imports colmena’s modules for assertions and keys, from the colmena source the composition declares or pins under the name colmena. A NixOS configuration that passes moduleImports lists the module itself, as caisson/colmena-deployment in a flake that lists caisson in projects.

The integration reads deployment back from each node and builds the hive: __schema, nodes, toplevel, deploymentConfig, evalSelected and the other attributes colmena’s CLI reads. A deployment.targetHost the node leaves at null is the node’s name in the hive. The hive refuses a node that declares no deployment options, and a node whose name colmena’s --on filter could never select: a name containing a comma, starting with @, or empty. A node is one evaluation, so the hive also refuses a NixOS configuration evaluated per system for more than one system, such as one inside a colmena configuration that sets caisson.forChildren.systems to two systems. The message names the configuration and its systems. Names colmena’s hive format reserves (meta, defaults, network) are ordinary names. The schema version is asserted against the makeHive of the colmena source, so a colmena revision that moves it fails at evaluation.

A colmena configuration is published as colmenaHive, an output that holds a single value, so the colmena configuration declared under the name default fills it, and another name is refused there. Its nodes are published as nixosConfigurations.<name> as well, from the same evaluation, so nixos-rebuild and colmena apply build the same machine.

  • mkModule : freeformModule -> module: class-bound mkModule for colmena modules.
  • mkConfiguration : { configModule?, pkgSets?, defaultPkgs?, ecosystemSrc?, moduleImports?, extraModuleImports?, specialArgs? }: a colmena configuration, a function of { name, parent }, finalized by the configuration that declares it under caisson.caisson-colmena.configurations.<name>. configModule defaults to the configuration registered under its name in configs/caisson-colmena. ecosystemSrc is the colmena flake, resolved from the composition when absent. pkgSets.pkgs serves colmena eval (introspect) and nothing else.
  • mkConfigurationWithEcosystemArgs: the twin with ecosystemArgs, merged over the evalModules call (class, modules, specialArgs).
  • metaOptions, hiveOf, exportsTo, ecosystemSrcOf: the meta options, the hive built from an evaluated colmena configuration, how a colmena configuration is published, and the resolution of the colmena source. The upstream-compatible colmena integration builds on them.

caisson.colmena (module class colmena)

  • Source: lib-overlays/colmena/default.nix

colmena is the upstream-compatible colmena integration. It is there so that a hive written for colmena can move to caisson as it is, and so that modules written for colmena’s evaluator can be used with caisson: a project with many of them can port without rewriting them. For a new configuration, we recommend caisson-colmena, which works like the other caisson integrations. It gives up compatibility with Nix code written for colmena to do that: a hive file or a module written for colmena’s evaluator does not work with it. It keeps compatibility with colmena’s CLI, which deploys from colmenaHive either way.

It takes a hive in the format colmena reads: meta (or network, its older name), defaults, and a module or list of modules per node. Each node becomes a caisson NixOS configuration declared beneath the colmena configuration, and is published as nixosConfigurations.<node>:

caisson.colmena.configurations.default = lib.caisson.colmena.mkConfiguration {
  hive = {
    meta.nixpkgs = pkgSets.default;
    defaults = { name, ... }: { networking.hostName = name; };
    nas-1 = ./hosts/nas-1.nix;
  };
};

A node is built as colmena builds it: from colmena’s deployment, assertion and key modules, the defaults of the hive and the modules of the node, with the special arguments name, nodes, meta.specialArgs and meta.nodeSpecialArgs.<node>. With name given, deployment.targetHost defaults to the name of the node, as in colmena. nodes holds every node evaluated again with type checking off, as colmena provides it, made only when a module reads it.

A node runs on meta.nodeNixpkgs.<node>, else meta.nixpkgs, else the package set caisson selects for it. Those values are package sets already built, such as caisson’s package sets; a path to nixpkgs, a nixpkgs function or an uninitialized flake input is refused. The set is handed to NixOS whole, so its overlays and its config are those it was built with. As a caisson NixOS configuration, a node also gets the framework modules and the default selection of caisson’s NixOS integration.

For a hive with nothing caisson adds to it, each node builds the same system as colmena builds from the same hive. The colmena configuration builds its hive from the NixOS configurations it passes up, as caisson-colmena does, and is published as colmenaHive.

  • mkModule : freeformModule -> module: class-bound mkModule for modules of the colmena class.
  • mkConfiguration : { hive, pkgSets?, defaultPkgs?, ecosystemSrc?, moduleImports?, extraModuleImports?, specialArgs? }: a colmena configuration, a function of { name, parent }, finalized by the configuration that declares it under caisson.colmena.configurations.<name>. hive is an attribute set, or a function returning one, as colmena accepts. defaultPkgs selects the package set of a node the hive names none for. pkgSets.pkgs serves colmena eval, else meta.nixpkgs.
  • mkConfigurationWithEcosystemArgs: the twin with ecosystemArgs, merged over the evalModules call of the colmena configuration.

caisson.caisson-disko (module class caisson-disko)

  • Source: lib-overlays/caisson-disko/default.nix

caisson-disko is the native disko integration, and the one we recommend for new configurations. A disko configuration is a disk layout, and the NixOS configurations installed onto it are declared inside it. Those NixOS configurations get the file systems, boot settings and swap the layout produces. The scripts that format and destroy the disks are outputs of the layout. The layout and each machine installed onto it are separate configurations, and one layout can hold several machines. The integration named disko, below, is disko’s two forms, where a machine and its layout are one configuration or the layout stands alone.

A disko configuration is a module of the caisson-disko class. It sets disko.devices, disko’s grammar for the disks, typed with disko’s type library from the disko source the composition declares or pins under the name disko. It declares the NixOS configurations installed onto the layout under caisson.nixos.configurations.<name>, the way any configuration declares them, and it may declare none, as for a data array:

A tree registers the layout as configs/caisson-disko/nas-1/default.nix:

{ ... }:
{ lib, ... }:
{
  disko.devices.disk.main = {
    device = "/dev/disk/by-id/nvme-Samsung_SSD_980_PRO_1TB_S5GXNX0T123456";
    type = "disk";
    content = { type = "gpt"; partitions = { /* ... */ }; };
  };
  # The NixOS configuration registered as `configs/nixos/nas-1`.
  caisson.nixos.configurations.nas-1 = lib.caisson.nixos.mkConfiguration { };
}

and declares it where it belongs, here at the top of its flake. mkConfiguration without configModule takes the configuration registered under the name it is declared under:

caisson.caisson-disko.configurations.nas-1 = lib.caisson.caisson-disko.mkConfiguration { };

The layout is evaluated with an evalModules call in caisson over disko.devices, the framework modules, the selection and the configuration’s module. The class also declares assertions, a list of { assertion, message } as in NixOS. A module of the class refuses a layout with an assertion that is false, and every output of the layout then fails with the messages of those assertions.

The outputs are the layout product:

  • serializableLayout: the device tree as data, with disko’s internal attributes and functions left out.
  • layoutHash: the sha256 of serializableLayout as JSON, the identity of the disks the layout describes.
  • nixosConfig: the NixOS settings the devices produce, fileSystems, boot and swapDevices, each a definition for a NixOS option.
  • meta: disko’s record of which device depends on which.
  • scripts: a function of a package set returning the scripts disko makes from the layout: format, mount, unmount, destroy, formatMount, destroyFormatMount, and each of them without its dependencies (formatNoDeps and so on).
  • packages: a function of a package set returning the packages the scripts run.
  • rootMountPoint: the directory the mount script mounts the layout under.

The default module of the class adds caisson’s NixOS module caisson-disko-layout to the default selection of the NixOS configurations declared inside the layout. That module defines fileSystems, boot and swapDevices from the layout’s nixosConfig, at default priority, and refuses a layout that produces settings under any other key. A NixOS configuration that passes moduleImports lists the module itself, as caisson/caisson-disko-layout in a flake that lists caisson in projects. A module of a NixOS configuration that needs more of its layout reads it as lib.caisson-core.evalManifest.nearest.caisson-disko.

A layout passes up its NixOS configurations under the names they are declared with. So the machine declared as nas-1 inside the layout nas-1 is published as nixosConfigurations.nas-1, and is the node nas-1 of a colmena configuration that declares the layout.

Where a layout holds one machine, we recommend naming the machine after the layout. The layout’s module declares it under the layout’s name, read from the manifest, and caisson’s default NixOS module names the host after the name the machine is declared under:

{ lib, ... }: {
  caisson.nixos.configurations.${lib.caisson-core.evalManifest.name} =
    lib.caisson.nixos.mkConfiguration { };
}

mkConfiguration without configModule takes the NixOS configuration registered under the name the machine is declared under. So the layout nas-1 holds the machine registered as configs/nixos/nas-1, and publishes it as nixosConfigurations.nas-1, whose host name is nas-1 unless its modules set another.

A disko configuration is published as diskoConfigurations.<name>, in the form disko’s CLI reads: { disko.devices = <serializableLayout>; }. disko’s CLI evaluates it again with disko’s evaluator, so disko --mode format --flake '.#nas-1' formats the disks the layout describes.

  • mkModule : freeformModule -> module: class-bound mkModule for modules of the caisson-disko class.
  • mkConfiguration : { configModule?, pkgSets?, defaultPkgs?, ecosystemSrc?, moduleImports?, extraModuleImports?, specialArgs?, rootMountPoint?, checked? }: a disko configuration, a function of { name, parent }, finalized by the configuration that declares it under caisson.caisson-disko.configurations.<name>. configModule defaults to the configuration registered under its name in configs/caisson-disko. ecosystemSrc is the disko flake or source tree, resolved from the composition when absent. rootMountPoint defaults to /mnt. checked says whether the scripts are checked with shellcheck when they are built, and defaults to false.
  • mkConfigurationWithEcosystemArgs: the twin with ecosystemArgs, merged over the evalModules call.
  • ecosystemSrcOf, diskoLibOf, devicesOptions, layoutOf, exportsTo: the resolution of the disko source, disko’s type library from a source, the module declaring disko.devices, the layout product of an evaluated layout, and how a layout is published. The upstream-compatible disko integration builds on them.

caisson.disko (module class disko)

  • Source: lib-overlays/disko/default.nix

disko is the upstream-compatible disko integration. disko has two forms, and caisson supports each as disko has it, so a layout or a machine written for disko is used with caisson as it is.

  • A standalone layout. A disko configuration is a module of the disko class: what a file written for disko’s diskoConfigurations sets, disko.devices, is its module as it is. It is published as diskoConfigurations.<name>, which disko’s CLI formats and mounts with disko --flake .#<name>.
  • A machine that carries its layout. A NixOS configuration that selects caisson’s NixOS module disko and writes its layout as disko.devices is disko’s NixOS form, with the machine and its layout in one configuration. disko-install, the image builder and the install test read this form.

For a machine installed onto a layout that is a separate configuration, we recommend caisson-disko, above.

A tree registers a layout as configs/disko/data-array/default.nix:

{ ... }:
{ ... }:
{
  # As written for disko.
  disko.devices.disk.main = { /* ... */ };
}

and declares it with caisson.disko.configurations.data-array = lib.caisson.disko.mkConfiguration { };.

The layout is evaluated as disko evaluates a standalone layout: evalModules over disko.devices, typed with disko’s type library, with the framework modules, the selection and the configuration’s module beside it. The outputs are the layout product of caisson-disko, without assertions.

The NixOS module disko imports disko’s NixOS module, from the disko source the composition declares or pins under the name disko. A NixOS configuration selects it with extraModuleImports = modules: [ modules.disko ];, or modules."caisson/disko" in a flake that lists caisson in projects, and sets disko.devices and the other options of disko’s module, such as disko.imageBuilder, in its modules. For a layout and a machine module with nothing caisson adds to them, it builds the same system, with the same format script, as NixOS builds from disko’s module and the same modules.

  • mkModule : freeformModule -> module: class-bound mkModule for modules of the disko class.
  • mkConfiguration : { configModule?, pkgSets?, defaultPkgs?, ecosystemSrc?, moduleImports?, extraModuleImports?, specialArgs?, rootMountPoint?, checked? }: a disko configuration, a function of { name, parent }, finalized by the configuration that declares it under caisson.disko.configurations.<name>. configModule defaults to the configuration registered under its name in configs/disko. The other arguments are those of caisson-disko.
  • mkConfigurationWithEcosystemArgs: the twin with ecosystemArgs, merged over the evalModules call.

caisson.caisson-facter (module class caisson-facter)

  • Source: lib-overlays/caisson-facter/default.nix

A caisson-facter configuration is the hardware of a machine: a module of the class with facter.report, the hardware report nixos-facter writes, as data, or facter.reportPath, the facter.json it writes, which facter.report defaults to. It declares the NixOS configurations that run on the hardware under caisson.nixos.configurations. A project can extend the class with further options about the hardware.

caisson evaluates the configuration with evalModules, on the composed library, over the report options, assertions, the framework modules, the selection and the configuration’s module. Its outputs are:

OutputValue
reportthe report, refused unless its version is one of supportedReportVersions (today [ 1 ]) or the report is empty
reportFilethe report as a JSON file
serializableConfigthe configuration as data, without caisson’s options and without functions or attributes whose names begin with _
hashthe hash of serializableConfig

Every output fails with the messages of the assertions that do not hold. The NixOS configurations declared inside it get caisson’s NixOS module caisson-facter-hardware by default. That module sets hardware.facter.report to the report, at mkDefault priority, and nixpkgs’ hardware.facter module turns the report into the settings of the hardware. A module of such a NixOS configuration that needs more of the hardware reads it through lib.caisson-core.evalManifest.nearest.caisson-facter.

A tree registers the hardware as configs/caisson-facter/box-1/default.nix, with facter.reportPath = ./facter.json; and the machine caisson.nixos.configurations.${lib.caisson-core.evalManifest.name} = lib.caisson.nixos.mkConfiguration { };. It is not published under an output attribute set; its outputs are on its manifest.

  • mkModule : freeformModule -> module.
  • mkConfiguration : { configModule?, defaultPkgs?, moduleImports?, extraModuleImports?, specialArgs? } -> configuration. configModule defaults to the configuration registered under its name in configs/caisson-facter.
  • mkConfigurationWithEcosystemArgs: the twin with ecosystemArgs, merged over the evalModules call.
  • supportedReportVersions: the report versions the integration accepts.

caisson.facter (module class facter)

  • Source: lib-overlays/facter/default.nix

A facter configuration is a facter.json as nixos-facter writes it, named by reportPath, the one option of the class, with the NixOS configurations that run on that hardware declared inside it. The NixOS configurations get caisson’s NixOS module facter-hardware by default, which sets hardware.facter.reportPath to that file at mkDefault priority, the line a NixOS configuration written for nixos-facter carries. The outputs are reportPath and report, the content of the file. For a report checked against the versions caisson knows or extended with options of a project, see caisson.caisson-facter.

  • mkModule : freeformModule -> module.
  • mkConfiguration : { configModule?, defaultPkgs?, moduleImports?, extraModuleImports?, specialArgs? } -> configuration. configModule defaults to the configuration registered under its name in configs/facter.
  • mkConfigurationWithEcosystemArgs: the twin with ecosystemArgs, merged over the evalModules call.

caisson’s tests declare a machine inside each kind of facter configuration, from the report of nixpkgs’ facter test. Each machine gets the report and the kernel modules of its initrd that a NixOS configuration setting hardware.facter.reportPath itself gets.

caisson.terranix (module class terranix)

  • Source: lib-overlays/terranix/default.nix

A terranix configuration is a Terraform configuration written as terranix modules, evaluated with terranix’s terranixConfiguration for every system in force where it is declared, on the package set selected for it (defaultPkgs, else the set named default). A tree registers it as configs/terranix/infra/default.nix and declares it with caisson.terranix.configurations.infra = lib.caisson.terranix.mkConfiguration { };, which finds the configuration by its name.

Its value and its output json are the config.tf.json file that Terraform reads, with the evaluated configuration under passthru.config. A top publishes it as terranixConfigurations.infra, the name terranix’s flake-parts module gives its configurations, and a terranix configuration that is a top is that file.

terranix evaluates its modules itself, on the library of the package set, and caisson cannot hand it the composed library as the library of the evaluation. The modules get the composed library as the lib argument, and the package sets of the composition as pkgSets; terranix merges and checks the options with that library. terranix returns only the configuration Terraform reads and its _meta, so caisson reads the caisson options back through _meta.caisson, and a terranix configuration holds no configurations beneath it.

  • mkModule : freeformModule -> module.
  • mkConfiguration : { configModule?, ecosystemSrc?, defaultPkgs?, moduleImports?, extraModuleImports?, specialArgs? } -> configuration. configModule defaults to the configuration registered under its name in configs/terranix. specialArgs becomes terranix’s extraArgs.
  • mkTopConfiguration: the same arguments; finalizes the configuration at a top and returns its config.tf.json, or the files by system where several systems are in force.
  • mkConfigurationWithEcosystemArgs: the twin with ecosystemArgs, merged over the call of terranixConfiguration (pkgs, modules, extraArgs, strip_nulls).

caisson.treefmt (module class treefmt)

  • Source: lib-overlays/treefmt/default.nix

A treefmt configuration is the formatters of a tree, written as treefmt-nix modules (projectRootFile, programs.<name>.enable, settings). It is evaluated per system, once for each system in force where it is declared, on the package set selected for it (defaultPkgs, else the set named default).

treefmt-nix evaluates its modules on the library of a nixpkgs. caisson evaluates them with evalModules, on the composed library, over submodule-modules, the modules treefmt-nix publishes for a configuration embedded in another, with pkgs set to the selected package set. The special arguments those modules need are read from the type treefmt-nix’s submoduleWith returns.

OutputValue
wrappertreefmt with its configuration, the program nix fmt runs
configFilethe treefmt.toml
programsthe formatters by name
checka function from a source tree to a derivation that fails when the tree is not formatted

A tree registers the configuration as configs/treefmt/default/default.nix and declares it with caisson.treefmt.configurations.default = lib.caisson.treefmt.mkConfiguration { };. A top publishes only the configuration named default:

OutputValue
formatter.<system>its wrapper, which nix fmt runs
checks.<system>.treefmtits check over the root of the tree, root on mkLib, which nix flake check builds; it fails when the tree is not formatted

The check is published where mkLib is given a root. caisson’s tests check that the wrapper is the derivation treefmt-nix’s mkWrapper builds from the same module on the same package set, and that the published check is the configuration’s check over the root.

  • mkModule : freeformModule -> module.
  • mkConfiguration : { configModule?, ecosystemSrc?, defaultPkgs?, moduleImports?, extraModuleImports?, specialArgs? } -> configuration. ecosystemSrc is the treefmt-nix flake or source tree, resolved from the composition under the name treefmt-nix when absent.
  • mkTopConfiguration: the same arguments; finalizes the configuration at a top and returns its evaluation, or the evaluations by system where several systems are in force.
  • mkConfigurationWithEcosystemArgs: the twin with ecosystemArgs, merged over the evalModules call.

caisson.nvf (module class nvf)

  • Source: lib-overlays/nvf/default.nix

An nvf configuration is a Neovim, written as nvf modules (vim.*). It is evaluated per system, once for each system in force where it is declared, on the package set selected for it (defaultPkgs, else the set named default).

nvf evaluates its modules with neovimConfiguration, on the library of nixpkgs extended with nvf’s lib.nvim. caisson evaluates the same module list, modules/modules.nix, with the special arguments neovimConfiguration passes, on the composed library extended with lib.nvim from the same nvf source. nvf’s modules read the inputs of the nvf flake, so ecosystemSrc is the nvf flake, resolved from the composition under the name nvf when absent.

The output is neovim, the wrapped Neovim that neovimConfiguration returns. A top publishes the configuration as nvfConfigurations.<system>.<name>, in the shape neovimConfiguration returns: neovim, config and options. caisson’s tests check that the Neovim is the derivation neovimConfiguration builds from the same module on the same package set.

An nvf configuration is kept in a separate Nix profile, and the configuration it is declared inside activates it. See profiles. Its outputs activationPackage and profile are that profile.

  • mkModule : freeformModule -> module.
  • mkConfiguration : { configModule?, ecosystemSrc?, defaultPkgs?, moduleImports?, extraModuleImports?, specialArgs? } -> configuration. configModule defaults to the configuration registered under its name in configs/nvf.
  • mkTopConfiguration: the same arguments; finalizes the configuration at a top and returns its evaluation, or the evaluations by system where several systems are in force.
  • mkConfigurationWithEcosystemArgs: the twin with ecosystemArgs, merged over the evalModules call.

caisson.nixvim (module class nixvim)

  • Source: lib-overlays/nixvim/default.nix

A nixvim configuration is a Neovim, written as nixvim modules (plugins.*, opts, keymaps). It is evaluated per system, once for each system in force where it is declared, on the package set selected for it (defaultPkgs, else the set named default).

nixvim evaluates its modules with evalNixvim, evalModules over modules/top-level on a library extended with the overlay the nixvim flake publishes as lib.overlay. caisson makes the same evaluation on the composed library extended with that overlay, with nixpkgs.pkgs set to the selected package set and the flake as flake. So ecosystemSrc is the nixvim flake, resolved from the composition under the name nixvim when absent.

The output is package, the Neovim (config.build.package). A top publishes the evaluated configuration as nixvimConfigurations.<system>.<name>, the name the nixvim flake publishes configurations under. caisson’s tests check that the package is the derivation evalNixvim builds from the same module on the same package set.

A nixvim configuration is kept in a separate Nix profile, as an nvf configuration is, and the configuration it is declared inside activates it. See profiles.

  • mkModule : freeformModule -> module.
  • mkConfiguration : { configModule?, ecosystemSrc?, defaultPkgs?, moduleImports?, extraModuleImports?, specialArgs? } -> configuration. configModule defaults to the configuration registered under its name in configs/nixvim.
  • mkTopConfiguration: the same arguments; finalizes the configuration at a top and returns its evaluation, or the evaluations by system where several systems are in force.
  • mkConfigurationWithEcosystemArgs: the twin with ecosystemArgs, merged over the evalModules call.

caisson.topology (module class topology)

  • Source: lib-overlays/topology/default.nix

A topology configuration is the diagram of a network: the NixOS configurations declared inside it under caisson.nixos.configurations, whose topology options nix-topology reads, and modules of the class that add nodes and networks. It is evaluated per system, once for each system in force where it is declared, on the package set selected for it, extended with the packages of nix-topology.

caisson evaluates nix-topology’s topology modules with evalModules on the composed library, and sets nixosConfigurations to the NixOS configurations declared inside it, evaluated for its system. Those NixOS configurations get nix-topology’s NixOS module by default, through caisson.forChildren. ecosystemSrc is the nix-topology flake or source tree, resolved under the name nix-topology.

The output is output, the rendered diagram. A top publishes the configuration named default as topology.<system>, the evaluation, where nix-topology’s flake module publishes it, so nix build '.#topology.x86_64-linux.config.output' builds the diagram. Its machines are published as nixosConfigurations like any NixOS configuration. caisson’s tests check that two machines declared inside give the nodes and the diagram nix-topology gives the same machines evaluated with its NixOS module.

caisson.kubenix (module class kubenix)

  • Source: lib-overlays/kubenix/default.nix

A kubenix configuration is Kubernetes resources written as kubenix modules, which import the kubenix modules they use from the kubenix argument (imports = [ kubenix.modules.k8s ];). caisson makes the evaluation kubenix’s evalModules makes, on the composed library extended with kubenix’s upstreamables.nix, with the same kubenix argument, for every system in force on the selected package set. ecosystemSrc is the kubenix flake or source tree, resolved under the name kubenix.

The output is result, the resources as the JSON file kubectl apply -f reads, the kubernetes.result of kubenix’s k8s module. A top publishes the configuration as kubenixConfigurations.<system>.<name>, the evaluation kubenix’s evalModules returns, whose config.kubernetes.result is the result. caisson’s tests check that the result is the one kubenix’s evalModules gives the same module.

caisson.nixidy (module class nixidy)

  • Source: lib-overlays/nixidy/default.nix

A nixidy configuration is an Argo CD environment written as nixidy modules (nixidy.target, applications.*). caisson evaluates it with nixidy’s mkEnv, giving it the composed library as the library of the evaluation and, through mkEnv’s libOverlay, the namespaces of the composed library that nixidy’s library lacks, such as caisson. mkEnv returns no options, so a nixidy configuration holds no configurations beneath it. ecosystemSrc is the nixidy flake, resolved under the name nixidy.

The outputs are environmentPackage, the rendered manifests, activationPackage and bootstrapPackage. A top publishes the configuration as nixidyEnvs.<system>.<name>, which the nixidy CLI reads. caisson’s tests check that the environment is the one mkEnv renders from the same module.

Each of the three takes the arguments of caisson.treefmt:

  • mkModule : freeformModule -> module.
  • mkConfiguration : { configModule?, ecosystemSrc?, defaultPkgs?, moduleImports?, extraModuleImports?, specialArgs? } -> configuration, configModule defaulting to the configuration registered under its name in configs/<class>.
  • mkTopConfiguration, with the same arguments.
  • mkConfigurationWithEcosystemArgs, the twin with ecosystemArgs, merged over the evaluator’s call (mkEnv’s arguments for nixidy).

caisson.system-manager (module class systemManager)

  • Source: lib-overlays/system-manager/default.nix

A system-manager configuration is the system of a machine running another Linux distribution, evaluated with system-manager’s makeSystemConfig for every system in force where it is declared. A tree registers it as configs/systemManager/default/default.nix and declares it with caisson.system-manager.configurations.default = lib.caisson.system-manager.mkConfiguration { };. Its value and its output toplevel are the system, with the evaluated configuration under passthru.config. A top publishes it as systemConfigs.default, which system-manager switch --flake . reads when it is given no name. A system-manager configuration activates the home-manager configurations and the hjem configurations declared inside it, and the profiles of the nvf and nixvim configurations declared inside it, with modules.systemManager."caisson/default".

A system-manager configuration runs on the package set selected for it (defaultPkgs, else the set named default), as every other integration does, and on the source resolved under the nixos key, as a NixOS configuration does (see Ecosystem sources). system-manager’s flake publishes makeSystemConfig from its nix/lib.nix called with the nixpkgs the flake pins. caisson calls the same file with the source resolved under the nixos key and the composed library, as the NixOS integration calls nixos/lib/eval-config.nix. The library and the evaluation come from the composed library; the NixOS modules system-manager imports come from the source resolved under the nixos key.

makeSystemConfig makes pkgs itself and declares nixpkgs.pkgs read-only. caisson passes the selected set as the pkgs special argument, which the module system takes before _module.args, so the selected set is the pkgs of every module and nixpkgs.pkgs. userborn, the user manager system-manager pins, is built from the same source on the selected set. caisson defaults nixpkgs.hostPlatform to the platform of the selected set.

The selected set makes nixpkgs.overlays, nixpkgs.config and the overlays argument of makeSystemConfig ineffective, so a configuration that sets one is refused, as one whose nixpkgs.hostPlatform differs from the platform of the selected set is. Package configs and package overlays are declared on mkLib. The modules get the composed library as lib and the package sets of the composition as pkgSets. caisson adds a compatibility bridge for the current nixos-unstable restructuring of the NixOS nix module (each half self-retires; see the source comments).

  • mkModule : freeformModule -> module.
  • mkConfiguration : { configModule?, ecosystemSrc?, defaultPkgs?, moduleImports?, extraModuleImports?, specialArgs? } -> configuration. configModule defaults to the configuration registered under its name in configs/systemManager.
  • mkTopConfiguration: the same arguments; finalizes the configuration at a top and returns its system, or the systems by system where several systems are in force.
  • mkConfigurationWithEcosystemArgs: the twin with ecosystemArgs, merged over the call of makeSystemConfig (modules, specialArgs, allowUnsupportedNixpkgs; overlays is refused).

Module Options Reference

This reference documents the caisson framework’s module options. All descriptions are sourced from the Nix-native description fields in the module code.

For lib.caisson functions, see Library Reference.

The options are declared by caisson’s core module, which is imported into an evaluation of an integrated class. In an evaluation whose library is not a caisson composition, such as a home-manager configuration that home-manager’s NixOS module embeds, the core module declares nothing.

Options

caisson.manifest

  • Type: caisson.flake-parts.types.manifest (read-only)
  • Default: the composed library’s caisson-core.evalManifest, or its caisson-core.libManifest in an evaluation that carries none
  • Source: modules/generic/core/caisson/manifest.nix

The manifest of this evaluation: its type, name, parent and children, and the registries and declared facts of the composition it is declared under, which are sources, defaultEcosystemSrc, systems and projects as given to mkLib, plus the registered libOverlays and modules dictionaries (project entries under <project>/<name>, locals winning). Structural, flake-parts and NixOS configurations carry a manifest. Reading it type-checks the manifest; the flake.modules and flake.libOverlays projections are drawn from it, and flake-parts’ systems defaults to the manifest’s systems, the empty list when the composition declares none, so such a flake has no per-system outputs (modules/flake/core/caisson/systems.nix).

caisson.<integration>.configurations

  • Type: lazy attribute set of configurations, read back as manifests
  • Default: { }
  • Source: lib.caisson.<integration>.configurationsModule, generated by mkIntegration in lib-overlays/integrations/default.nix

The configurations of an integration declared beneath this configuration, by name. The option is declared by the module of the integration, lib.caisson.<integration>.configurationsModule. For the integrations of caisson, the core module of caisson declares it instead, through one module for all of them (lib.caisson.integrations.configurationsModuleFor). An entry takes what that integration’s mkConfiguration returns, a function of { name, parent }, and reads back as the finished manifest:

{ config, lib, ... }:
{
  caisson.structural.configurations.inner = lib.caisson.structural.mkConfiguration { };
}

Each entry is finalized when it is read, with the attribute it is declared under as its name and the childless manifest of this evaluation as its parent. An entry of an integration that evaluates a configuration per system (nixos) reads back as its evaluations by system, config.caisson.nixos.configurations.laptop.x86_64-linux, a manifest for every system in force. The names are known without finalizing anything, so builtins.attrNames config.caisson.structural.configurations evaluates no configuration.

A configuration declared inside this configuration sees its childless evaluation, which leaves out the configurations declared inside it. In the childless evaluation an entry’s result is not readable, and reading it fails with a message saying so. A definition that reads an entry is therefore written under !lib.caisson.evalManifest.childless:

# an option of this configuration, defined from a configuration beneath it
innerGreeting =
  if lib.caisson.evalManifest.childless then
    null
  else
    config.caisson.structural.configurations.inner.value.config.greeting;

A module that only declares configurations needs no such test. What the configurations beneath export is passed up without that test, through caisson.<integration>.exported below.

An entry that is not a configuration, or is a configuration of another integration, is refused. Structural, flake-parts and nixos configurations can be declared. A configuration of an integration that evaluates a class another integration owns is a configuration of the owner in the tree: lib.caisson.nixos-minimal.mkConfiguration returns a nixos configuration, declared under caisson.nixos.configurations and published with the others. The constructors of the remaining integrations return an evaluated value. A configuration holds configurations of any integration beneath it.

caisson.<integration>.exported

  • Type: function from the configurations declared to an attribute set of them
  • Default: configurations: configurations, every configuration declared
  • Source: lib.caisson.<integration>.configurationsModule, generated by mkIntegration in lib-overlays/integrations/default.nix

Function that selects which of the configurations declared under caisson.<integration>.configurations this configuration passes up. What each selected configuration exports (its outputs.exports: lib, libOverlays, modules, pkgOverlays) is merged into this configuration’s caisson.exports, beside what the registry selectors here choose. A configuration beneath passes up what lies beneath it in turn, so the exports of a tree of structural configurations reach its top with nothing written:

{ lib, ... }:
{
  caisson.structural.configurations.impl = lib.caisson.structural.mkConfiguration { };
}

The configurations themselves are passed up as well, each with its path, and the top publishes them under the output attribute set their integration declares, named from the paths:

{ lib, ... }:
{
  caisson.nixos.configurations.laptop = lib.caisson.nixos.mkConfiguration { };
}

publishes nixosConfigurations.laptop from a flake-parts or structural top. A name that is alone stays bare. Names that collide gain the segments that tell them apart, from the segment nearest the top: host-1 declared in the structural configurations a and b is published as a/host-1 and b/host-1, and a configuration evaluated for several systems as x86_64-linux/laptop and aarch64-linux/laptop. Configurations that still share a name are refused, with their paths. An integration may give its configurations a different name first: a home-manager configuration declared as chris inside the NixOS configuration laptop is published as homeConfigurations."chris@laptop".

caisson.structural.exported = _: { }; passes up none, and a selector returning a subset passes up those. The merge is made in the evaluation that holds the configurations: it is absent from the childless view, and from an evaluation that carries no manifest.

The top records what it published in the export record, exportRecord of the manifest it publishes as caisson.manifest, keyed as the outputs are. The entry for a name holds the path to the configuration by the names declared, the sentences that say how the name came about, and the positions of the code that made it, among them the file that defines this selector when it is not left at its default.

caisson.<integration>.exportNote

  • Type: null or string
  • Default: null
  • Source: lib.caisson.<integration>.configurationsModule, generated by mkIntegration in lib-overlays/integrations/default.nix

A sentence saying what caisson.<integration>.exported does to the configurations it passes up, for a selector that does more than pass every configuration up unchanged. The top adds it to the notes of the export record entry of each name published from those configurations, after the notes of the levels beneath:

{ lib, ... }:
{
  caisson.nixos.configurations = {
    nas-1 = lib.caisson.nixos.mkConfiguration { };
    lab-3 = lib.caisson.nixos.mkConfiguration { };
  };
  caisson.nixos.exported =
    configurations:
    lib.mapAttrs' (name: lib.nameValuePair "office-${name}") (
      lib.filterAttrs (name: _: !lib.hasPrefix "lab-" name) configurations
    );
  caisson.nixos.exportNote = "office machines carry the site prefix; lab machines stay unpublished";
}

publishes nixosConfigurations.office-nas-1, and its entry in the export record holds the note.

caisson.home-manager.activation.usersDeclaredElsewhere

  • Type: listOf str
  • Default: [ ]
  • Source: modules/nixos/home-manager-activation/
  • Declared in: a NixOS configuration that imports caisson/home-manager-activation, which caisson/default for the nixos class does

The users that have an account on this machine although users.users does not declare it.

A NixOS configuration activates the home-manager configurations declared inside it. It expects the account of the user of each home-manager configuration in users.users, and it refuses a home-manager configuration whose user has no account there. Such a home-manager configuration is usually a mistake: a misspelled user name, or a home-manager configuration declared on the wrong machine.

An account that systemd-homed manages is not in users.users. systemd-homed keeps the record of the account, and the machine must not declare it a second time. List the user here:

{ lib, ... }:
{
  services.homed.enable = true;

  caisson.home-manager.activation.usersDeclaredElsewhere = [ "chris" ];

  caisson.home-manager.configurations.chris = lib.caisson.home-manager.mkConfiguration {
    configModule = {
      home.homeDirectory = "/home/chris";
    };
  };
}

The home-manager configuration of a listed user is not refused. It is activated by a user unit that runs when the user logs in, which is when systemd-homed mounts the home directory. The home-manager configuration sets its home directory itself, because the machine has no account to read it from.

A user that is listed here and also declared in users.users is refused, because the two statements contradict each other.

See modules.nixos."caisson/home-manager-activation" for the units.

caisson.drift.warn

  • Type: bool
  • Default: true
  • Source: modules/homeManager/activation-drift/, modules/nvf/default/
  • Declared in: a home-manager configuration that imports caisson/activation-drift, which the home-manager default module imports; an nvf or nixvim configuration that imports the default module of its class

Whether the activation of this home-manager configuration, or activate of this nvf or nixvim configuration, prints a warning when it was built for a version of the configuration it is declared inside, such as its machine or its home-manager configuration, that differs from the version that is applied.

The configuration above records what it applied in a marker file, /etc/caisson/marker.json on a machine. The activation of the home-manager configuration compares the marker with the configuration above as it is in the source the home-manager configuration was built from.

The comparison leaves out the configurations declared inside the configuration above. A change to a home-manager configuration does not cause a warning. The warning does not stop the activation.

{ ... }:
{
  caisson.drift.warn = false;
}

Here the home-manager configuration is activated with no comparison.

See drift detection for what the marker holds and what is compared.

caisson.profiles.selected

  • Type: attrsOf str
  • Default: { }
  • Source: modules/*/profile-activation/
  • Declared in: a NixOS, darwin or system-manager machine, a home-manager configuration or a hjem configuration that imports caisson/profile-activation, which the default module of each of these classes imports

By the variable that chooses among them, the profile a shell runs when it sets no other, as <kind>/<name>. nvf/editor is the nvf configuration declared as editor.

A configuration installs a program for each program of the profiles declared inside it, such as nvim, which runs the profile the variable names, and the default profile when the variable is unset. With one profile for a variable, that profile is the default. With several, this option names it, and the evaluation fails until it does.

{ ... }:
{
  caisson.profiles.selected.CAISSON_NVIM_PROFILE = "nvf/editor";
}

Here a shell that sets no CAISSON_NVIM_PROFILE runs the Neovim of the nvf configuration editor.

See profiles.

caisson.forChildren.extraDefaultModuleImports

  • Type: attrsOf (functionTo (listOf module)), by class
  • Default: { }
  • Source: modules/generic/core/caisson/forChildren.nix

Additions to the default selection of a class for the configurations beneath this configuration: a function of the lib of such a configuration returning registered modules. A module listed here is registered in the composition, on mkLib or by a project.

{ ... }:
{
  caisson.forChildren.extraDefaultModuleImports.nixos = lib: [ lib.caisson.modules.nixos.gaming ];
}

A configuration that passes no moduleImports gets every entry named default followed by these, those of the levels above it first. A configuration that passes moduleImports gets what it selects in place of that default, and one that passes extraModuleImports gets what that selects in addition. Definitions from several modules are concatenated.

The name says that the option adds to what is in force, as extraModuleImports does. The options defaultPkgs and systems below replace what is in force.

A configuration with a configuration beneath it is evaluated in both views, since what it gives is read from the childless view.

caisson.forChildren.defaultPkgs

  • Type: nullOr (functionTo pkgs)
  • Default: null
  • Source: modules/generic/core/caisson/forChildren.nix

The package set the configurations beneath this configuration get by default: a function that receives the package sets available to such a configuration, as an attribute set by package config name, and returns the set to run on.

{ ... }:
{
  caisson.forChildren.defaultPkgs = pkgSets: pkgSets.stable;
}

Here the default beneath this configuration is the set of the package config named stable. A configuration beneath runs on it unless that configuration, or a configuration between the two, is constructed with defaultPkgs or sets this option. This configuration runs on the set it was constructed with. When the option is null, the default beneath is the selection in force at this configuration.

caisson.forChildren.systems

  • Type: nullOr (listOf str)
  • Default: null
  • Source: modules/generic/core/caisson/forChildren.nix

The systems the configurations declared inside this configuration are evaluated for. It applies to configurations that are evaluated per system, such as a NixOS configuration or a home-manager configuration: each gets one evaluation per system listed.

{ ... }:
{
  caisson.forChildren.systems = [ "aarch64-linux" ];
}

Set on a NixOS configuration for x86_64-linux, this evaluates the configurations declared inside it for aarch64-linux. A machine that builds an image for an aarch64-linux device is an example. Set on a structural or flake level, it limits the systems the configurations inside that level are evaluated for. The systems have to be among those allowed where this configuration is declared, and a system that is not among them is refused.

When the option is null, a configuration that is itself evaluated per system has its children evaluated for its system only, so a home-manager configuration inside a NixOS configuration for x86_64-linux is evaluated for x86_64-linux. Any other configuration passes on the list it was given.

caisson.modules

  • Source: modules/generic/core/caisson/modules.nix

Export settings for each module class of the composition. caisson declares these options for each class:

  • export.enabled (bool, default true)
  • exported (function -> attrsOf deferredModule, default: the modules of the class that the composition registers itself)

The classes of the composition are the classes that the integrations declare and the classes that have a registered module. A setting for another class is refused as an option that does not exist, and so is a setting other than export.enabled and exported. A misspelled class name or setting name is therefore an error.

The selected modules are the exported modules. They are in the manifest, and mkLib reads them from there for each entry of projects.

The exported modules are also published under flake.modules.<class>, and caisson.flakeOutputs.modules selects which of them. For the "flake" class, the published modules are also mirrored to flake.flakeModules.

caisson.modules.<class>.export.enabled

  • Type: bool
  • Default: true
  • Source: modules/generic/core/caisson/modules.nix

Whether to export modules for a given class.

caisson.modules.<class>.exported

  • Type: a function from the registered modules to the modules to export
  • Default: the modules of the class that the composition registers itself
  • Source: modules/generic/core/caisson/modules.nix

Function that selects which registered modules of a class to export. Receives the modules registered for the class and returns the modules to export, which are exported as the function returns them. A module that came from an entry of projects, registered as <project>/<name>, is exported only when the function names it.

The two settings of every class are held in one option, caisson.modules, an attribute set by class of lazyAttrsOf raw values, so a class that no module sets costs nothing to evaluate. Each setting is a plain value: a priority such as mkDefault on exported takes effect, and two definitions of exported for one class conflict rather than merge. A class with no registered module, or a key other than export and exported, is refused when the exports are read.

caisson.flakeOutputs.modules

  • Type: function -> attrsOf (attrsOf deferredModule)
  • Default: modules: modules
  • Source: modules/flake/core/caisson/exports.nix

Function that selects which exported modules are also published in the modules output. It receives the exported modules, as an attribute set of classes that each hold modules by name, and returns the modules to publish in the same shape. The option exists in a flake-parts configuration.

The exported modules are the modules that the caisson.modules.<class>.exported selectors chose. They are in the manifest, and mkLib reads them from there for each entry of projects. mkLib reads every exported module, whatever this function returns.

The modules output serves an import by hand, as inputs.<input>.modules.<class>.<name>. Leave a module out of the output when it only works inside an evaluation that caisson makes, because it reads the options under caisson or the library under lib.caisson.

caisson.flakeOutputs.modules =
  modules: modules // { nixos = removeAttrs modules.nixos [ "needs-caisson" ]; };

caisson sets the option to _: { }. Each module that caisson exports works only inside an evaluation that caisson makes. The modules output of caisson holds the empty default entry of the flake class and nothing else.

caisson.libOverlays.export.enabled

  • Type: bool
  • Default: true
  • Source: modules/generic/core/caisson/libOverlays.nix

Whether to enable lib overlay export. When enabled, the lib overlays selected by caisson.libOverlays.exported are exported. They are in the manifest. mkLib reads them from there for each entry of projects, and registers each as <project>/<name>. A lib overlay is an entry in the shape caisson registers, so there is no libOverlays output.

caisson.libOverlays.exported

  • Type: function -> libOverlays, an attribute set of lib overlays merged as one attribute set, each entry checked when it is read
  • Default: the lib overlays that the composition registers itself
  • Source: modules/generic/core/caisson/libOverlays.nix

Function that selects which registered library overlays to export. Receives the set of overlays registered via mkLib and returns the overlays to export. A lib overlay that came from an entry of projects, registered as <project>/<name>, is exported only when the function names it.

Repository Layout

caisson mandates nothing about layout: registration takes paths, and any arrangement evaluates. We recommend the conventions below because they have proven to work for us, they resolve ambiguity about where a thing belongs, and they make it easier for someone new to a repository to come up to speed. A repository that keeps them registers by naming the directories, as in modules = lib: lib.caisson.mkModules ./modules;. A repository with another layout writes its registrations by hand. This repository and its integrations use them, and the documentation and the examples/literate-flake example assume them.

flake.nix

Wiring only: mkLib and lib.caisson.flake-parts.mkTopConfiguration. The substance lives in the directories below.

configs/

configs/<class>/<config>/

Configurations, grouped by module class and named for what they configure. A flake-parts config configures the flake itself, so there is typically exactly one, named after the flake (this repository uses configs/flake/caisson/) or default. In other classes, a config is named for the thing it describes: a machine, a home-manager configuration, a deployment.

lib-overlays/

lib-overlays/<overlay>/

Library overlays by overlay name; most flakes start with a single overlay called default. Each is a file taking the closure arg list and returning { imports ? [ ], overlay }; see Library Overlays.

modules/

modules/<class>/<module>/default.nix
modules/<class>/<module>/<flake-name>/*.nix

Reusable modules, keyed first by module class (directory names are the class names: flake, nixos, homeManager, and generic for a module any class may import), then by the module’s name. The names core and default carry meaning in every class: core is forced into every evaluation of the class, and default is the default default (see Module classes); the conventional exported module is modules/<class>/default/. default.nix is the module’s entry point, and its implementation files sit under a directory named for the defining flake, grouped by the option namespace they declare, in this repository, modules/generic/core/caisson/lib.nix declares the caisson.lib.* options.

pkgs/

pkgs/<package>/            # or, in flakes with many packages:
pkgs/<flake-name>/<package>/

Package definitions. How package sets and package overlays are composed and surfaced as outputs is the domain of caisson-nixpkgs (in active use, not yet published); until its documentation is available, pkgs/ is best read as the conventional home for package expressions.

Package overlays

Package overlays follow the same safety ideas as library overlays (namespacing, input closure) but their tooling belongs to caisson-nixpkgs, not to caisson itself. See Library Overlays for the shared principles.

tests/

tests/unit/           # pure evaluation tests, wired into checks
tests/integration/    # nested flakes that consume this flake
tests/dependencies/   # a small flake whose lock pins test-only inputs

Unit tests are pure Nix expressions evaluated as a check. Integration tests are nested flakes that take the project as an input and assert that composition behaves as documented: consumption tested from the outside, the way a consumer would experience it. tests/dependencies/ is a lock-bearing flake that pins inputs used only by the test and formatter machinery, so the main flake.lock stays free of test-only pins (it feeds the checks partition via pins.flake-compat). The Testing page covers how the nested flakes are evaluated.

Other directories

examples/ (worked examples; examples/literate-flake/ here) and docs/ (these pages) appear where a repository has use for them.

Closed Inputs

Overview

Nix flake modules often need access to the defining flake’s inputs, but flake-parts does not provide a built-in mechanism for closing over them. This means every module and library overlay would need inputs threaded explicitly through its call site, a tedious and error-prone pattern.

caisson solves this with an explicit closure convention: everything registered through mkModule or mkLibOverlay takes a closure attrset as its first arg list. Plain modules and already-built overlays are registered directly instead.

The Problem

In a standard flake-parts setup, accessing inputs from a module requires either:

  • Passing them via specialArgs (fragile, global)
  • Using config._module.args (implicit, hard to trace)
  • Threading them manually through every import

None of these compose well when modules are re-exported for downstream consumption.

How caisson Handles It

mkModule

A flake registers a module with the mkModule of the integration that owns its class (lib.caisson.nixos.mkModule, lib.caisson.flake-parts.mkModule, and so on). Each is lib.caisson.mkModule, a function of the class, applied to that integration’s class:

mkModule = class: freeformModule: ...
caisson.flake-parts.mkModule = mkModule "flake"

Everything passed to the class-specific normalizer takes the closure attrset as its first arg list, followed by an ordinary module:

# Uses closure values
{ closure-inputs, closure-lib, mkModule, ... }:
{ config, lib, ... }:
{ ... }

# Ignores the closure but still takes the arg list
{ ... }:
{ config, lib, ... }:
{ ... }

The closure attrset contains:

KeyValue
closure-inputsThe defining flake’s pinned sources, the sources it passed to mkLib: its inputs under their names, with no self (distinct from the flake-parts inputs module arg, which belongs to the consuming flake)
closure-libThe defining flake’s composed lib (distinct from the lib module arg); its registry, closure-lib.caisson.modules.<class>, is how a module imports a sibling by name
mkModuleA normalizer bound to the same class, for nested composition

Path modules are wrapped with _file for error locations and key = toString path, so a file passed through mkModule at two sites deduplicates exactly like importing the same path twice.

Passing a non-function (attrset, path to a plain module, null) is an error: plain modules are imported or registered directly rather than wrapped in mkModule.

mkLibOverlay

mkLibOverlay follows the same convention. A registered overlay takes { closure-inputs, closure-lib, mkLibOverlay, ... } as its first arg list and returns an { imports ? [ ], overlay } attrset: the final: prev: function under overlay, and the overlays it depends on under imports. closure-lib is the composed library of the composition that registered the overlay, bound lazily (read it inside the overlay function, never while the overlay is being registered); an integration reaches the registry of the composition that registered it through it:

{ closure-inputs, ... }:
{
  imports = [ "some-flake/default" ];
  overlay = final: prev: { ... };
}

The string names an entry of the registry. some-flake/default is the lib overlay default that the project some-flake exports, which is in the registry when projects lists some-flake.

Key Functions

FunctionPurpose
mkModuleCreates class-specific module normalizers with closed inputs
mkLibOverlayApplies the closure to registered library overlays
mkModules, mkLibOverlaysDerive the registrations from modules/<class>/<name> (also configs/) and lib-overlays/<name>, applying mkModule and mkLibOverlay
importApplyApplies static arguments to a module through the import chain
mkLibBootstraps a composed library with closed overlays
caisson.flake-parts.mkTopConfigurationCreates flake outputs with closed modules

Further Reading

Module Classes

Overview

caisson models modules as class-keyed sets. A class is a string key used to group related modules and control where they are exported in flake outputs.

  • Registered modules live under modules.<class>.<name>
  • Exported modules are published under flake.modules.<class>.<name>

This builds on flake-parts’ flake.modules output, which publishes modules under any class name, and adds closed-inputs module normalization.

Registering a module

A flake laid out as modules/<class>/<name>/default.nix registers its modules by naming the directory. lib.caisson.mkModules reads the directory into the class-keyed registration, and it reads configs/<class>/<name> the same way:

modules = lib: lib.caisson.mkModules ./modules;
configs = lib: lib.caisson.mkModules ./configs;

The first directory level is the class, whatever its name, and each entry is registered with lib.caisson.mkModule, given the name of that directory as the class. The integration that evaluates a class names it in its declaration (the class argument of mkIntegration), and caisson keeps no list of classes beside that. The class-free generic group holds modules any class may import; the name comes from flake-parts, whose export leaves those modules unstamped. An entry is a directory holding a default.nix, a symlink to such a directory included; anything else in a directory being read is an error, so a stray file cannot silently vanish from a registry.

A tree with another layout writes the registration by hand, with the mkModule of the integration that owns the class applied to the module’s path:

modules = lib: {
  flake.default = lib.caisson.flake-parts.mkModule ./modules/flake/default;
  nixos.my-service = lib.caisson.nixos.mkModule ./modules/nixos/my-service;
  homeManager.shell = lib.caisson.home-manager.mkModule ./modules/home-manager/shell;
  hardware.tpmFacts = lib.caisson.mkModule "hardware" ./modules/hardware/tpmFacts;
};

Each integration’s mkModule is lib.caisson.mkModule bound to that integration’s class:

mkModule = class: freeformModule: ...

The class-string form is written out for a class no integration covers.

Core and default

The entry names core and default carry meaning in every class, for every project a composition lists (caisson among them, no differently):

  • core: the framework module of the class. An integration forces every entry named core (core, <project>/core) into every evaluation of its class, before anything the evaluation selects. Registering a core is the big hammer, for a module the class cannot function without; the core module of caisson declares the caisson.* options every evaluation carries (the manifest, the registry selectors, caisson.exports).
  • default: the default default. When an evaluation passes no moduleImports, every entry named default (default, <project>/default) applies; an evaluation that selects by name replaces that default with the list it names. The default of caisson for the flake class carries the nixpkgs integration’s module layer.

The core module of caisson lives as the generic entry core; modules/structural/core is a symlink to it and modules/flake/core imports it and adds the flake mechanics, so each class that forces it registers it as core in that class.

The class-specific normalizer applies the closure attrset ({ closure-inputs, closure-lib, mkModule, ... }) as the module’s first arg list. The mkModule closure member is bound to the same class, so nested use of mkModule stays in that class.

Registration APIs

The class-keyed registry is in the manifest of the library, and lib.caisson.modules reads it from there. Each module there is the registered module inside a module that imports it and carries the key modules.<class>.<name>@<project>, the registry key and the project that registered it. The module system imports the modules that share a key once, so a registry entry an evaluation selects twice is imported once. The key is also how the history of an evaluation names the registry entry each of its modules is (declaration events). Modules enter the registry in these ways:

  • Local registration. The modules argument of mkLib is a function that receives the composed lib and returns the class-keyed registration. lib.caisson.mkModules derives that registration from the conventional layout, and helpers such as lib.caisson.flake-parts.mkModule build entries by hand. This is for the flake’s modules.
  • Project consumption, mkLib’s projects hook: registering a whole upstream contribution (projects.my-dep = inputs.my-dep) places its exported modules in the registry under <project>/<name> per class, beside its overlays in the overlay dictionary. Selection stays per item at each use site, and a local registration beats a same-named project entry.

The registry is a shared, class-keyed space per composition. Names within a class are a flat space: the entries of a project carry its name as a prefix (my-flake/my-service), and short names are for the registrations made in the mkLib call itself. Precedence is deterministic: those local registrations apply last, so a local entry wins over a project entry of the same name.

Use class flake for flake-parts modules and other class keys for other module ecosystems. The shipped integrations (caisson.nixos, caisson.darwin, caisson.home-manager, caisson.hjem, caisson.terranix, caisson.caisson-colmena, caisson.colmena, caisson.caisson-disko, caisson.disko, caisson.caisson-facter, caisson.facter, caisson.system-manager, caisson.treefmt, caisson.nvf, caisson.nixvim, caisson.topology, caisson.kubenix, caisson.nixidy, and caisson.nixpkgs) each name their class in their declaration (the class of caisson-colmena is caisson-colmena, and the class of caisson-disko is caisson-disko; the nodes of a colmena configuration and the machines of a disko configuration are NixOS configurations); see the library reference.

Consuming Exported Modules

A downstream flake can import modules published under any class via the upstream flake’s modules output:

# In a downstream NixOS configuration:
imports = [ inputs.my-upstream.modules.nixos.myModule ];

# In a downstream home-manager configuration:
imports = [ inputs.my-upstream.modules.homeManager.myModule ];

The exported modules have their inputs already closed over, so importing them is possible without threading the upstream’s dependencies.

Export Controls

Per-class export controls live under:

  • caisson.modules.<class>.export.enabled
  • caisson.modules.<class>.exported

The exported modules are in the manifest. mkLib reads them from there for each entry of projects.

The exported modules are also published under flake.modules.<class>.<name>, for an import by hand. caisson.flakeOutputs.modules selects which exported modules that output holds, and it defaults to every exported module. A module that only works inside an evaluation that caisson makes can be exported and left out of the output:

caisson.flakeOutputs.modules =
  modules: modules // { nixos = removeAttrs modules.nixos [ "needs-caisson" ]; };

For flake-parts compatibility, flake.flakeModules mirrors flake.modules.flake, and the flake class always exports a default entry (an empty module unless the selection provides it) so flakeModules.default exists for consumers that import it by convention.

Relationship to flake-parts

The flake.modules output is provided by flake-parts’ modules extra module. When caisson wires exported modules into flake.modules.<class>.<name>, flake-parts stamps each module with _class and _file metadata. This means exported modules carry their class identity and source location, which module systems can use for diagnostics and class-checking (e.g., preventing a nixos module from being accidentally imported into a homeManager evaluation).

Library Overlays

The Problem People Actually Had

Nix overlays have a reputation problem. Package overlays in nixpkgs caused real pain (attribute collisions, silent shadowing, unpredictable evaluation order), and the community learned to be cautious. That caution has grown into a broader skepticism that prevented the community from really embracing library overlays, out of fear that a worse version of the same problems might occur.

The skepticism is understandable but misdirected. The problems were never inherent to overlays as a mechanism. They came from how overlays were used: global modifications to shared namespaces, no convention for scoping additions, no way to declare dependencies between overlays, and no isolation between unrelated consumers. Fix those problems and overlays become a safe, composable extension mechanism.

caisson fixes those problems for library overlays. The result is something the Nix ecosystem has been missing: composed, layered lib extensions that multiple flakes can contribute to without stepping on each other.

What Makes Overlays Safe

The following properties, applied together, address the collision and ordering risks that gave overlays a bad name:

Namespacing

Every overlay adds its functions under a dedicated attribute path rather than mixing into the top-level lib. A project called myProject puts its functions at lib.myProject.*:

overlay = final: prev: {
  myProject = (prev.myProject or {}) // {
    helper = x: x + 1;
  };
};

This means two independent projects do not collide unless the project names do: lib.projectA.helper and lib.projectB.helper coexist without interference, as they would in any language with a module system. caisson’s convention helps here: a project declares its name as name on mkLib, and contributes to the composed library under that name, so if every project uses its canonical name, collisions are unlikely in practice. Choose a distinctive name for your flake; generic names like utils or helpers invite collisions, while project-specific names like caisson or acme-infra make them vanishingly rare. This is a convention, not an enforcement mechanism: if two upstream flakes happen to choose the same name, their lib contributions will merge into the same namespace.

prev-Based Merging

The (prev.myProject or {}) // { ... } pattern ensures that if multiple overlays contribute to the same namespace (e.g., a base overlay and an extension overlay within the same project), their contributions are merged rather than one silently replacing the other. This is the standard Nix overlay contract, and it matters: it means overlays compose additively.

Input Closure

caisson’s mkLibOverlay automatically closes over the flake’s inputs when registering an overlay. This binds each overlay to the specific set of inputs it was written against, rather than relying on the leaf flake to do the right thing. The result is that overlays from different upstream flakes don’t interfere with each other’s inputs, even when composed into the same final lib.

See Closed Inputs for the full mechanism.

Dependency Tracking

Library overlays sometimes need to call functions defined by other library overlays. Without explicit dependency management, this requires manually ensuring that overlays are applied in the right order, a fragile arrangement that breaks when overlays are reorganized or new overlays are added.

caisson solves this. A registered overlay is an { imports ? [ ], overlay } attrset, and imports is where its dependencies go. Entries are built overlays; the closure’s mkLibOverlay member exists exactly so a dependency can be built in place:

{ mkLibOverlay, ... }:
{
  overlay = final: prev: {
    myProject = (prev.myProject or {}) // {
      fullName = person: "${prev.myProject.greet person} (${person})";
    };
  };
  imports = [ (mkLibOverlay ./greet-overlay.nix) ];
}

When mkExtendedLib encounters this structure, it recursively applies the imports before applying the overlay itself. This guarantees that prev.myProject.greet exists by the time fullName is evaluated, regardless of the order overlays were listed in libOverlays.

The resolution is recursive: imported overlays can themselves declare imports, and the framework handles the full dependency graph.

Package Overlays and Library Overlays

The safety techniques described here (namespacing, input closure, dependency tracking) apply equally to package overlays and library overlays. The underlying mechanism is the same: both are functions of final: prev: that extend an attribute set.

caisson provides the tooling for safe library overlays. The same principles apply to package overlays, but package overlay tooling is the domain of caisson-nixpkgs (not yet published), which builds on caisson’s foundation.

Tradeoffs

The safety mechanisms described here aren’t free. Import chains are flattened depth-first with duplicates preserved (an overlay’s imports are applied before it, every time it appears) and folded through the caisson-core, so evaluation cost grows with the number of overlays and the depth of the dependency graph. A contract worth knowing: the base library is contributed as an opaque attribute set, so overriding one of its attributes changes what readers of the composed library see, without re-tying the base’s internal references.

For most flakes the overhead is negligible, but it’s worth being aware of, especially if you’re composing a large number of upstream library overlays. The eval-weight harness (see the guides) is the tool for holding it to a measured ceiling.

Practical Patterns

A Simple Library Overlay

The most common case: adding namespaced functions to lib.

# lib-overlays/default/default.nix
{ closure-inputs, ... }:
{
  imports = [ ];
  overlay = final: prev: {
    myProject = (prev.myProject or {}) // {
      greet = name: "Hello, ${name}!";
      double = x: x * 2;
      upstreamVersion = closure-inputs.some-flake.lib.version;
    };
  };
}

The closure arg list always comes first: closure-inputs here is the defining flake’s pinned sources, so some-flake resolves against the pins this overlay was written with, no matter which downstream flake eventually composes it. An overlay that needs nothing from the closure still takes the arg list, as { ... }:.

Register it in your flake’s mkLib call:

libOverlays = lib: {
  default = lib.caisson.mkLibOverlay ./lib-overlays/default;
};

After composition, lib.myProject.greet "world" returns "Hello, world!".

An Overlay With Dependencies

When one overlay needs functions from another, declare the dependency:

# lib-overlays/extended/default.nix
{ mkLibOverlay, ... }:
{
  overlay = final: prev: {
    myProject = (prev.myProject or {}) // {
      greetLoud = name: final.toUpper (prev.myProject.greet name);
    };
  };
  imports = [ (mkLibOverlay ../default) ];
}

The imports list ensures default is applied first, so prev.myProject.greet is available. Register the extended overlay normally:

libOverlays = lib: {
  default = lib.caisson.mkLibOverlay ./lib-overlays/default;
  extended = lib.caisson.mkLibOverlay ./lib-overlays/extended;
};

Common Mistakes to Avoid

  • Top-level additions. Don’t add attributes directly to lib (e.g., { helper = ...; }). Always namespace under a project-specific attribute.
  • Forgetting prev merge. Writing myProject = { helper = ...; } instead of myProject = (prev.myProject or {}) // { helper = ...; } will silently discard any functions added to myProject by earlier overlays.
  • Implicit ordering assumptions. If overlay B uses a function from overlay A, declare the dependency with { overlay = ...; imports = [...]; } rather than hoping the registration order is correct.

Further Reading

  • How lib is composed: the whole composition pass
  • Closed Inputs: how inputs are closed over in overlays and modules
  • examples/literate-flake/: a working example with a custom library overlay

Ecosystem sources

Each integration takes its ecosystem as an argument, the ecosystem source, and calls the evaluator inside that source; it pins nothing itself. A caisson revision therefore works with any nixpkgs, home-manager, or colmena revision with a compatible evaluation contract, and two consumers of that same caisson revision can pin different revisions of each ecosystem.

What a source is

An ecosystem is a community library outside of caisson at the center of an extensible Nix abstraction framework. Most ecosystems are built on NixOS modules, but some use other abstractions (a package set, a lib ecosystem). The ecosystem source is that project’s source tree or flake, in whatever shape its evaluator expects. Each integration documents the shape it takes; in practice:

  • caisson.nixos takes the NixOS module collection under the nixos key (it evaluates nixos/lib/eval-config.nix from it); caisson.nixos-minimal takes the same key and evaluates nixos/lib’s evalModules from it.
  • caisson.nixpkgs takes a nixpkgs source tree under the nixpkgs key, for the package set and its configuration.
  • caisson.darwin takes the nix-darwin flake or source tree (it evaluates eval-config.nix from it).
  • caisson.home-manager takes a home-manager source tree.
  • caisson.hjem takes the hjem flake or source tree (it evaluates the user module modules/common/user.nix from it, with hjem’s library from lib.nix).
  • caisson.caisson-colmena and caisson.colmena take the colmena flake: they read the hive schema from its lib.makeHive and the deployment options from its nixosModules.
  • caisson.caisson-disko and caisson.disko take the disko flake or source tree: they build disko’s type library from its lib/default.nix, and caisson.disko gives its machines the NixOS module in its module.nix.
  • caisson.terranix and caisson.system-manager take their project’s flake (they call lib.terranixConfiguration and lib.makeSystemConfig on it). caisson.system-manager also takes the nixos key, for the NixOS module collection it mixes into the evaluation.
  • caisson.flake-parts takes a flake-parts source tree and calls its flake.nix with the composed library as nixpkgs-lib.

The three parts of a nixpkgs checkout

A nixpkgs checkout holds three separate parts, and each is resolved from a separate key, with no fallback between keys:

  • nixpkgs-lib: the library (lib/: the module system lib.evalModules, lib.types, lib.mkOption, and the rest), which the nixpkgs-lib entry composes into the lib.
  • nixpkgs: the package set and its configuration (pkgs/top-level).
  • nixos: the NixOS module collection (nixos/modules, nixos/lib/eval-config.nix), read by NixOS configurations and by system-manager for its NixOS modules. It is the module collection, not the Linux distribution; the distribution is one use of the collection.

A key a tree does not declare resolves to the source pinned under exactly that name in sources. A tree that takes several parts from one checkout names that checkout under each key. Every tree uses nixpkgs-lib; a tree with package sets uses nixpkgs; a tree with NixOS or system-manager configurations uses nixos. The parts may come from different sources on purpose: the nixpkgs.lib mirror supplies the library, and one revision of the module collection runs on the library of another revision.

A tree with a single nixpkgs checkout, pinned under the input name nixpkgs, names that checkout under the other two keys with defaultEcosystemSrc:

caisson.lib.caisson.mkLib {
  inherit sources root;
  defaultEcosystemSrc = sources: {
    nixpkgs-lib = sources.nixpkgs;
    nixos = sources.nixpkgs;
  };
};

The nixpkgs key needs no entry here: the source pinned under the input name nixpkgs already resolves there, through the exact-name fallback below.

The library of a project that other flakes list

We recommend that a project other flakes list in projects take its library from the nixpkgs.lib mirror, a repository that holds only the lib/ directory of nixpkgs:

inputs.nixpkgs-lib.url = "github:nix-community/nixpkgs.lib";

A flake that lists a project reads the outputs of that project, and reading them builds the lib of the project. Building that lib fetches the library source the project pins. The mirror is about 1.4 MB in the store, and a nixpkgs checkout is about 210 MB. A flake that lists several projects fetches the library source of each of them, once per distinct revision.

The mirror follows the master branch of nixpkgs and is updated about once a week. Its revision is therefore rarely the revision of the nixpkgs a package set comes from. A project that only publishes modules and overlays runs only its registrations and its top configuration on its library. The flakes that list it evaluate its modules and overlays on their own lib, so the difference does not reach them. A project that also builds packages or configurations runs them on general utilities and a module system of the mirror revision.

nixpkgs’ library holds two kinds of library. The general utilities (strings, lists, attrsets and the rest) and the module system (modules, options, types) serve every evaluation, and a tree supplies them from its nixpkgs-lib key. The package-set libraries describe and build packages:

  • systems and platforms, the platform model;
  • meta, licenses, sourceTypes, maintainers and teams, the package metadata;
  • customisation, which holds callPackageWith and makeOverridable;
  • derivations, fetchers and versions.

Inside a package set, the nixpkgs integration takes the package-set libraries from the source of that package set, together with the names nixpkgs publishes from them at the top level of its library, such as lib.getExe and lib.callPackageWith. The packages of a set are written against those libraries at the revision of the set. The general utilities still come from the mirror, which follows the unstable branch, so a package set of a stable release meets the general utilities of a later revision wherever its packages call them. nixpkgs reads maintainers and teams from its maintainers directory beside lib/, so a package set needs them from the source of the package set in any case.

The module system still comes from nixpkgs-lib. A NixOS configuration whose nixos key names a stable release runs that module collection on the module system of the mirror.

Which integration uses which ecosystem

One ecosystem may be wrapped by several integrations, each with a different evaluator over the same source. The name in the second column is what the integration resolves its source by, in the places listed below.

IntegrationEcosystem nameSource shape
caisson.flake-partsflake-partsflake-parts source tree
caisson.nixpkgsnixpkgsnixpkgs source tree (package set and configuration)
caisson.nixosnixosnixpkgs source tree (the NixOS module collection)
caisson.nixos-minimalnixosnixpkgs source tree (the nixos class under the minimal evaluator)
caisson.darwinnix-darwinnix-darwin flake or source tree
caisson.home-managerhome-managerhome-manager source tree
caisson.hjemhjemhjem flake or source tree
caisson.caisson-colmenacolmenacolmena flake
caisson.colmenacolmenacolmena flake
caisson.caisson-diskodiskodisko flake or source tree
caisson.diskodiskodisko flake or source tree
caisson.terranixterranixterranix flake
caisson.system-managersystem-manager, and nixos for its NixOS modulessystem-manager flake
caisson.treefmttreefmt-nixtreefmt-nix flake or source tree
caisson.nvfnvfnvf flake
caisson.nixvimnixvimnixvim flake
caisson.topologynix-topologynix-topology flake or source tree
caisson.kubenixkubenixkubenix flake or source tree
caisson.nixidynixidynixidy flake
caisson.structuralnoneno source: the empty integration evaluates caisson’s core module alone

The nixpkgs library that the integrations call is loaded by the nixpkgs-lib integration of caisson, from the source resolved under the nixpkgs-lib key.

How does caisson get access to ecosystem sources?

A source comes from one of these places, in priority order:

  1. Explicit argument. ecosystemSrc at the call site always wins. It is a function of the tree’s sources, returning the source for that call; a value given in place of the function is refused. ecosystemSrc = sources: sources.nixpkgs; names an input called nixpkgs explicitly. It replaces the source for that evaluation and nothing else: a level cannot set sources for the levels beneath it. A package config on a different nixpkgs revision writes ecosystemSrc = sources: sources.nixpkgs-stable;.
  2. Composition default. defaultEcosystemSrc on mkLib is a function of sources, returning the source of each ecosystem by its exact key: defaultEcosystemSrc = sources: { nixpkgs-lib = sources.nixpkgs; nixos = sources.nixpkgs; };. An attribute set given in place of the function is refused.
  3. Exact-name source. As a final fallback, the source named exactly like the key (nixpkgs, home-manager, and the rest) in the sources passed to mkLib is used. For a flake that declares that input anyway, this is the common case.

If none of these places can provide a needed ecosystem source, it triggers an evaluation error.

A NixOS configuration that selects another module collection with ecosystemSrc still runs its evaluation on the library of the tree; only the source under the nixos key changes.

Where each source came from

The manifest of every configuration records ecosystems: for each part of the stack its evaluation used, which of the three places supplied the source and which pinned source it is.

{
  nixpkgs-lib = { from = "defaultEcosystemSrc"; source = "nixpkgs"; };
  nixos = { from = "ecosystemSrc"; source = "nixpkgs"; };
}

from is ecosystemSrc, defaultEcosystemSrc or sources, in the order above. source is the name of the source among the pinned sources, where the manifest records its revision under sources.<name>.pin. It is null when a selection builds the value in another way, such as a directory inside a source. A package config also lists, under nixpkgs.libraries, the names its sets take from the source of the package set (ecosystemAccountOf has the details).

How lib is composed

A caisson flake’s lib is canonically built by a call to caisson-core.mkLib. This page traces what happens between that call and the finished attrset: what goes in, the order things apply, the rules that decide conflicts, and how to drive the same machinery without mkLib when you want to.

What goes in

mkLib is given a set of declarations, and nothing to compose over: the seed is the empty attribute set, and nixpkgs’ library arrives as an entry like everything else.

  • defaultEcosystemSrc: a function of the tree’s sources, returning the tree’s default source per ecosystem key (see Ecosystem sources). An attribute set given in place of the function is refused. A tree declares nixpkgs-lib under this key for a mirror such as nix-community/nixpkgs.lib, for a flake that carries no nixpkgs, or for a nixpkgs checkout pinned under another input name. The nixpkgs-lib integration of caisson, at lib-overlays/nixpkgs-lib in the caisson source tree, is the entry that imports the lib directory of the source resolved under the nixpkgs-lib key, with the fixed point upstream computes. It loads the source declared by the flake whose mkLib call composes it, including when that flake consumes caisson as a project. The caisson integrations import the entry. An overlay outside caisson that calls upstream’s functions imports it by key from the caisson source tree: (mkLibOverlay (closure-inputs.caisson.outPath + "/lib-overlays/nixpkgs-lib")) // { key = "nixpkgs-lib"; }. A flake that declares no source fails only where that entry is composed, with a message naming the declaration.
  • libOverlays: the flake’s overlay registrations, built with lib.caisson-core.mkLibOverlay of the core lib the function receives, or registered directly when already built (another flake’s export, for example).
  • modules: the flake’s class-keyed module registrations.
  • projects: whole upstream contributions, each carrying exported overlays and modules that register under <project>/<name>.

The registry also holds the entries caisson-core publishes, caisson-core (the machinery) and nixpkgs-lib, under those names. Neither is selectable, and a registration under either name replaces the entry for every overlay that imports it, so a polyfill over upstream’s library or a replacement of the core is an ordinary registration.

Registration and application are separate steps: libOverlayImports selects which of the registered overlays apply to this flake’s lib (default: all of them), and registration also feeds export, so a flake can register overlays for downstream consumers that it does not apply to itself.

The stages

mkLib builds the library in stages, each a new fixpoint over the seed, because some of its arguments are functions of a library and can only see what is built before them:

  • The core lib is the caisson-core entries with the lib overlay registry recorded on its manifest. libOverlayImports receives it and picks entries from that registry.
  • The bootstrap lib is the core entries plus the selection, the sequence below without the module registrations. modules and configs receive it, so they can use every integration’s constructors, and its manifest lacks what they register.
  • The registered lib is the whole sequence below, with the module, configuration and package overlay registries on its manifest. pkgSets receives it, because a package config selects from those registries, and its manifest lacks pkgSets.
  • The full lib adds the package configs to the manifest, and mkLib returns it.

Each stage records a manifest at lib.caisson-core.libManifest, and the history in each manifest begins with the history of the stage before it. Entries registered at the earlier stages still close over the full lib, so a module reaches its siblings through closure-lib.caisson.modules, which reads the registry from the manifest of that lib.

The sequence

The selected overlays are applied in one pass, wrapped by entries caisson adds itself:

{ }
  -> caisson-core namespace injection (the `caisson-core` entry)
  -> selected overlays (flattened, imports first; the `nixpkgs-lib`
     entry wherever an overlay imports it)
  -> consumed projects' modules
  -> the flake's module registrations
  -> the manifest

The first added overlay injects the machinery and the empty module registry under caisson-core. The last records the manifest, the composition’s self-description, at lib.caisson-core.libManifest (beside pkgsManifest and evalManifest, the manifests of the other evaluation phases, null until a package set or a module evaluation fills them in). Module registrations apply after every selected overlay so that a local name always beats a same-named contribution from an overlay or a consumed project.

Before application, each selected overlay is flattened: a built overlay is an { imports, overlay } value, and the flattening walks depth-first with imports before the overlay itself. This guarantees that anything an overlay depends on has already applied when its overlay function runs, regardless of registration order. Within mkLib, imports guarantee order, not uniqueness: an overlay that two registrations both import is applied once per appearance, which is harmless for overlays that follow the merge conventions.

The fold

Application is the standard Nix overlay contract, folded over the sequence above. Each overlay = final: prev: { ... } receives prev, everything accumulated so far, and final, the finished fixpoint. For an attribute defined by two overlays, the later definition wins, and prev gives it the earlier definition to build on, which is what the my-flake = (prev.my-flake or { }) // { ... } merge convention relies on.

These consequences of the fixpoint are worth knowing:

  • An overlay’s output attribute names must not depend on final; a fixpoint whose shape depends on itself diverges.
  • The base library is contributed as an opaque value. Overriding one of its attributes changes what readers of the composed library see, and does not change what the base’s internals call; a function patched for everything downstream of the base has to be patched in the base source you pass in.

Composing without mkLib

mkLib is a convenience over caisson-core.compose, which works on entries, overlay-shaped pieces with identity:

{
  key = "example.base";   # stable identity: a string, or null
  imports = [ ];          # entries this entry depends on
  overlay = final: prev: { greet = name: "hello, ${name}"; };
}

Keys change the rules. A keyed entry applies once no matter how many entries import it; the first occurrence of a key fixes its position and the last occurrence supplies its value, so mentioning a key again replaces that entry wholesale; replacement is the only override mechanism. An entry with key = null cannot be imported, applies after the whole keyed world in list order, and can never be replaced by another entry, because replacement addresses keys and it has none; keyless entries are a consumer’s private patch layer. Import cycles terminate (a key already on the walk’s path is skipped) and grant the cycle’s members no ordering relative to each other.

Identity is what makes patching a dependency reliable. A polyfill imports the entry it patches, which guarantees the target is present and already applied when the polyfill reads prev:

polyfill = {
  key = "example.backport";
  imports = [ base ];
  overlay = final: prev: {
    concatLines = prev.concatLines or (lines: prev.concatStringsSep "\n" lines + "\n");
  };
};

The prev.concatLines or ... shape adds the function only where the base does not already provide it, so the same entry composes correctly over old and new bases.

The exact contract (walk order, the position a replacement takes, metadata) is specified in caisson-core, where the code lives.

caisson exports its contributions in entry form through lib.composition.entriesFor:

caisson.lib.composition.entriesFor {
  # a directory importable as nixpkgs' lib, e.g. "${nixpkgs}/lib"
  # or "${nixpkgs-lib}/lib" for the nixpkgs.lib mirror
  ecosystemSrc = "${inputs.nixpkgs-lib}/lib";
}
# => { base, caisson-lib,
#      flake-parts, tooling,
#      nixpkgs, nixos, nixos-minimal, home-manager,
#      colmena, terranix, system-manager }

base contributes the nixpkgs library the composition builds on; caisson-lib imports it and contributes the caisson-core machinery, the same injection mkLib performs; the rest are the integrations and tooling, each importing caisson-lib.

Where the composed lib goes

lib.caisson.flake-parts.mkTopConfiguration passes the composed library to flake-parts as specialArgs.lib, so modules receive it as their ordinary lib argument.

The lib output of the flake is the composed library. By convention, the lib overlays of a flake named my-flake define their values under my-flake, and the lib overlays of other flakes do not define values there (see Library overlays). The values of my-flake are in the output at lib.my-flake.

The manifest of the flake is in the output too, at lib.caisson-core.evalManifest. The manifest is published in lib because the other flake outputs each have a fixed shape. The manifest holds what the flake exports, as outputs.exports: the lib overlays, package overlays and modules that the exported selectors of the flake chose.

producer is a flake that exports a lib overlay. consumer is a flake that lists producer in projects.

consumer reads what producer exports from the manifest of producer, and ignores the flake outputs of producer. consumer adds the lib overlay to the library that consumer composes.

The lib overlay was built with mkLibOverlay, in the flake whose source holds the overlay. mkLibOverlay gave the overlay closure-inputs and closure-lib: the pinned sources and the composed library of the flake that called mkLibOverlay. The overlay keeps both in the library of consumer.

By default, producer exports the lib overlays that producer registers itself. For those, the flake that called mkLibOverlay is producer.

producer can also list a flake upstream in projects, and name a lib overlay of upstream in the exported selector of producer. For that lib overlay, the flake that called mkLibOverlay is upstream. In the library of consumer, the overlay keeps the pinned sources and the composed library of upstream.

Inspecting the result

nix repl .
# :p lib.my-flake                     -- your namespaced additions
# :p lib.my-flake.helper 41           -- call a function

nix eval .#lib --apply builtins.attrNames

How inputs are closed over

Every file registered through caisson takes a closure attrset as its first argument list. This page traces the mechanism behind that convention: where the closure comes from, when it is applied, what it contains for each kind of registration, and what it means when a registered file is evaluated inside another flake’s composition.

The convention

A registered file is a function of two argument lists: the closure attrset first, then whatever the file ordinarily takes.

{ closure-inputs, ... }:          # the closure arg list
{ config, lib, pkgs, ... }:      # the ordinary module arg list
{
  services.foo.package = closure-inputs.foo-flake.packages.x86_64-linux.default;
}

A file that needs nothing from the closure still takes the arg list ({ ... }:), so every registered file has the same shape and a reader always knows what the first line is.

Where the closure comes from

The flake defines the closure by calling mkLib:

lib = caisson.lib.caisson.mkLib {
  inherit (caisson.lib.caisson.pins.flake inputs) sources root;
  libOverlays = lib: { ... };
  modules = lib: { ... };
};

mkLib builds registration helpers closed over the sources it was given, the flake’s pinned inputs as the flake pin reader returns them, and hands them to the registration arguments inside the lib each receives. libOverlays receives the core lib, which contains the closed lib.caisson.mkLibOverlay, and modules receives the bootstrap lib, whose helpers (lib.caisson.flake-parts.mkModule, lib.caisson.nixos.mkModule, and the lib.caisson.mkModule they are bound from) are closed the same way. There is no ambient lookup anywhere in this chain: the only sources a registration can see are those the registering flake passed to mkLib. They do not include self. A registration reads the identity of its flake from closure-lib.caisson.libManifest.root and a sibling module from closure-lib.caisson.modules.

When the closure is applied

At registration time, not at evaluation time. mkLibOverlay and mkModule call the registered function with the closure attrset immediately and keep the result, so what sits in the registry, and what the flake exports, is an ordinary overlay or module with the closure values already baked in.

This timing is what makes export work. A consumer who imports my-flake.modules.nixos.my-service receives a plain NixOS module; the closure was applied when my-flake registered the file, so the module references my-flake’s inputs without the consumer declaring, follows-pinning, or even knowing about them.

What the closure contains

The contents differ by registration kind, because the kinds are evaluated at different times.

A library overlay’s closure contains registration helpers, sources and the library of the defining composition:

  • closure-inputs: the pinned sources of the defining flake.
  • closure-lib: the composed library of the defining flake, bound lazily. Inside the overlay function, final and prev are the composition being built, which may belong to a consumer; closure-lib is the composition of the definer, so an integration reaches the registry of the composition that registered it (its core module, for example) wherever it is composed. It is read inside the overlay function or a function the overlay defines, never while the overlay is being registered.
  • mkLibOverlay: the same helper, for building nested overlays.
  • mkModule: the module normalizer bound to the defining composition, so modules the overlay makes close over the inputs and library of the definer.

A module’s closure contains the definer’s finished world:

  • closure-inputs: the pinned sources of the defining flake.
  • closure-lib: the composed library of the defining flake. This is not the lib module argument; see the next section. Its registry, closure-lib.caisson.modules.<class>, is how a module imports a sibling by name.
  • mkModule: a normalizer bound to the same class, so nested module composition stays in that class.

Plain values bypass the mechanism: an already-built overlay (another flake’s export) and a plain module are registered directly, because their closures were applied by whoever built them.

Two worlds in one file

Inside a registered module, two sets of similar-looking values are in scope, and they answer different questions:

ValueWhose world
closure-inputsThe flake that registered the file
closure-libThe flake that registered the file
inputs module argThe flake being evaluated
lib module argThe flake being evaluated

While a flake consumes its registrations the distinction is invisible, because both worlds are the same flake. It starts to matter the moment a module is exported: a consumer evaluates the module inside the consuming composition, so the ordinary lib argument is the consumer’s composed library, while closure-lib remains the definer’s. A module that formats a string with a helper from the namespace of the flake that registered it wants closure-lib; a module that inspects the configuration it is being evaluated into wants the ordinary arguments.

The same split governs version skew. An exported module built against closure-inputs.nixpkgs uses the definer’s nixpkgs pin even when the consumer runs a different pin. Each flake’s files run against the pins that flake tested with, the pins are visible in each flake’s lock, and nothing forces every repository to upgrade in lockstep. Where a consumer does want to override a definer’s pin, flake-level follows on the definer’s input still works, because closure-inputs holds the definer’s inputs as its flake resolved them and follows rewrites what that resolution contains.

importApply

Closure application composes with module imports through lib.caisson.importApply, which applies static arguments to a module without losing its file identity: a path is imported and wrapped with its _file, wrapper modules produced by registration are walked rather than replaced, and the arguments are applied to the innermost function.

imports = [
  (closure-lib.caisson.importApply ./listener.nix { port = 8080; })
];

Here listener.nix begins { port }: and receives the arguments as its first arg list. Wrapping the module in a plain lambda instead would work, but the module system would see an anonymous function: error messages would no longer point at the file.

Seeing it in the example

examples/literate-flake/ wires all of this in a working flake: its overlay reads closure-inputs, and its modules take the two argument lists. Step 5 of Getting started shows the consuming side, a second flake that imports the exports without declaring the definer’s dependencies.

Testing

The conventions and tools for testing a caisson flake, as used by the test suite of this repository (tests/ here is a worked example of all of it).

Unit tests

tests/unit/ holds pure evaluation tests, wired into checks; this repository runs them with nix-unit. Anything that can be asserted by evaluating lib belongs here.

When nix-unit runs as a flake check, it runs inside a Nix build. The check is a derivation. The builder of that derivation runs nix-unit, and nix-unit evaluates the flake that holds the tests.

Nix runs every builder in a sandbox. Inside the sandbox, the builder can see a path in the Nix store only when the derivation names that path as a dependency, and the builder has no network access. So Nix cannot find a flake input that the tests need, and it cannot download the input either. The check fails with the message unable to download.

The option nix-unit.inputs prevents that. It is an attribute set of input names and store paths. nix-unit reads each input it lists from the store path given, and the builder can see those store paths. Every flake input that the tests read has to be in the set.

A flake that the tests compose as a project has inputs too. Those inputs also have to be in the set, under names such as caisson/caisson-core. lib.caisson.realizeInputs builds the whole set from the inputs of the test flake:

perSystem.nix-unit.inputs = lib.caisson.realizeInputs inputs;

With this, the test flake does not need follows to point the inputs of its inputs back at inputs it already lists. The reference entry says when to give the function fewer inputs.

Integration tests: consumption from the outside

The strongest test of a flake framework is what a consumer experiences, so integration tests are nested flakes under tests/integration/ that take the project as an input and assert that composition behaves as documented. They are written as completely normal flakes:

{
  inputs = {
    # Standalone equivalent (without shared deps infrastructure):
    #   nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    #   flake-parts.url = "github:hercules-ci/flake-parts";
    #   parent.url = "github:example/my-flake";

    deps.url = "path:../../dependencies";
    parent.url = "path:../../..";

    nixpkgs.follows = "deps/nixpkgs";
    flake-parts.follows = "deps/flake-parts";
  };

  outputs = inputs@{ parent, ... }: {
    # consume `parent` the way any downstream flake would
  };
}

tests/dependencies

A small lock-bearing flake whose only job is to pin the inputs that tests (and formatters) need, so the main flake.lock stays free of test-only pins. The checks partition takes its inputs as (lib.caisson.pins.flake-compat ./tests/dependencies).sources, the flake-compat pin reader, which resolves the lock without evaluating the flake.

Evaluating the nested flakes

Hand-threading a nested flake’s inputs (the recursive self fixpoint, the resolved input graph) is the genuinely hard part, and callConsumerFlake owns it. In the parent’s checks:

let
  consumerPool = {
    inherit (inputs) flake-parts nixpkgs;
    deps = inputs.self;      # the tests/dependencies flake
    parent = self;           # the flake under test
  };

  minimalConsumer = lib.my-flake.callConsumerFlake {
    path = self.outPath + "/tests/integration/minimal-consumer";
    pool = consumerPool;
  };
in
{
  checks = minimalConsumer.checks.${system};
}

The nested flake’s declared inputs resolve by name (overrides first, then follows chains, then the pool), and an unresolvable input throws an error naming it and what to do. The contract is deliberately explicit, in the same spirit as closed inputs: nothing is fetched, no lock is read, and you supply exactly the input graph you mean the test to see. One consumer’s outputs can feed another’s overrides, so chains of consumers (a flake consuming a flake that consumes yours) are plain data flow.

See callConsumerFlake for the full signature.

Gating evaluation cost

Beyond correctness, checks can gate what evaluation costs; see Evaluation weight.

Evaluation Weight

lib.caisson.eval-weight measures what an evaluation costs and can gate that cost in checks, so a framework regression is caught by CI rather than noticed as slowness later. This repository uses it to gate its overhead; the numbers quoted in these docs come from it.

How it measures

A scenario runs a pinned Nix evaluator inside a derivation sandbox against explicitly wired inputs and captures the evaluator’s statistics. The deterministic counters (thunks, values, environments, function and primop calls, total allocations) are reproducible for a fixed lock set and Nix version, so they can be gated. CPU and wall-clock time are machine-dependent, so they are always reported but not gated.

Semantic counters are derived from the same run, keyed to stable anchors in the evaluated source rather than line numbers:

  • nixpkgsEvals: full nixpkgs instantiations
  • nixpkgsLibEvals: nixpkgs-lib bootstraps (distinct lib sources)
  • moduleSystemEvals: evalModules runs, including submodules

These are gated exactly, with no growth allowance: one extra nixpkgs instantiation is the regression.

mkCheck

checks.eval-weight = lib.caisson.eval-weight.mkCheck {
  inherit pkgs;
  name = "my-flake";

  scenarios = {
    raw-flake-parts = {
      entry = self.outPath + "/tests/eval-weight/raw-flake-parts.nix";
      args = { /* store paths and system for the entry */ };
    };
    minimal-consumer = {
      entry = self.outPath + "/tests/eval-weight/minimal-consumer.nix";
      args = { /* ... */ };
    };
  };

  gates = [
    # framework overhead, isolated from ecosystem churn
    {
      name = "my-flake-overhead";
      minuend = "minimal-consumer";
      subtrahend = "raw-flake-parts";
    }
    # loose ceiling on the whole consumer
    {
      name = "minimal-consumer-total";
      scenario = "minimal-consumer";
      maxGrowth = 0.25;
    }
  ];

  baseline =
    builtins.fromJSON (builtins.readFile ./tests/eval-weight/baseline.json);
};
  • scenarios: entry is a self-contained file imported inside the sandbox and applied to args (store paths arrive as absolute-path strings); the resulting value is forced strictly, so the entry decides exactly what evaluation gets measured. Entries that need to evaluate a flake import the shared call-flake.nix kernel from a store path in args, the same kernel under callConsumerFlake.
  • gates: one per scenario by default. A subtraction gate measures the difference between two scenarios, which is the important trick: a 2× regression in framework machinery is invisible in a whole-nixpkgs total but obvious in the delta.
  • baseline: null runs in measure-only bootstrap mode: metrics are printed, including a paste-ready baseline, and the check passes. Commit the pasted baseline (this repository keeps it at tests/eval-weight/baseline.json) and subsequent runs gate against it: deterministic metrics may grow up to maxGrowth (10% by default; exact metrics not at all), and marked shrinkage logs a note suggesting the baseline be tightened.

The workflow

  1. Write an entry per scenario under tests/eval-weight/.
  2. Run once with baseline = null; paste the printed baseline into tests/eval-weight/baseline.json.
  3. Wire mkCheck into checks with the committed baseline.
  4. When a gate fails, the report shows which metric moved and by how much; either fix the regression or, for intended changes, update the baseline in the same change, where review can see both.