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 three 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 one. 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";
  };

  outputs =
    inputs@{ caisson, ... }:
    let
      lib = caisson.lib.caisson-core.mkLib {
        inherit inputs;
        projects = {
          inherit caisson;
        };
      };
    in
    lib.caisson.mkFlake {
      name = "my-flake";
      configModule = lib.caisson.mkFlakeModule ./configs/flake-parts/my-flake;
    };
}

caisson-core.mkLib composes a library: nixpkgs’ lib, the machinery under lib.caisson-core, and the overlays you register. Consuming caisson as a project registers everything it exports, its integrations included, which contributes lib.caisson (mkFlake and friends); mkFlake then evaluates flake-parts with that library and your config module, using caisson’s own flake-parts pin, so your flake declares none.

The config module is the flake’s own top-level configuration. Create configs/flake-parts/my-flake/default.nix:

{ ... }:
{ pkgs, ... }:
{
  systems = [ "x86_64-linux" ];

  caisson.configInfo.configName = "my-flake";

  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.lib.caisson-core.mkLib {
        inherit inputs;
        projects = {
          inherit caisson;
        };
        libOverlays = mkLibOverlay: {
          default = mkLibOverlay ./lib-overlays/default;
        };
      };
  caisson = {
    configInfo.configName = "my-flake";
    libOverlays.exported = libOverlays: { inherit (libOverlays) default; };
    lib.export.enabled = true;
  };

Now lib.my-flake.greet is available everywhere the composed library flows: in the config module, in registered modules, and (with the export enabled) to consumers as flake.lib. 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. Register a flake-class module:

      lib = caisson.lib.caisson-core.mkLib {
        inherit inputs;
        projects = {
          inherit caisson;
        };
        modules = lib: {
          flake.default = lib.caisson.mkFlakeModule ./modules/flake-parts/default;
        };
        libOverlays = mkLibOverlay: {
          default = mkLibOverlay ./lib-overlays/default;
        };
      };

modules/flake-parts/default/default.nix:

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

mkFlake applies the selected flake-class modules alongside the config module (moduleImports returns the list to apply, like libOverlayImports; the default is all of them). 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 NixOS system, in the config module’s perSystem or at the top level:

  flake.nixosConfigurations.example = lib.caisson.nixos.mkSystem {
    ecosystemSrc = inputs.nixpkgs;
    pkgSets.pkgs = import inputs.nixpkgs { system = "x86_64-linux"; };
    configModule =
      { ... }:
      {
        boot.loader.grub.enable = false;
        fileSystems."/" = {
          device = "none";
          fsType = "tmpfs";
        };
        system.stateVersion = "25.05";
      };
  };

Instead of passing ecosystemSrc at every call, a flake can set a flake-level default at mkLib (ecosystems.nixpkgs = inputs.nixpkgs) and drop the argument; an explicit argument still wins, and an input named exactly nixpkgs is the last fallback.

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 = overlays: [
          overlays."caisson/flake-parts"
          overlays."caisson/nixos"
          overlays.default
        ];

Registering a single overlay by hand (nixos = caisson.libOverlays.nixos) remains the way to cherry-pick or rename one. 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-core.mkLib {
        inherit inputs;
        projects = {
          inherit caisson my-flake;
        };
      };
    in
    lib.caisson.mkFlake {
      name = "consumer";
      configModule = lib.caisson.mkFlakeModule ./configs/flake-parts/consumer;
    };
}

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. Overlays that contribute modules via contributeModules (see Module classes) deliver them the same way. A consumer who wants only part of your project selects with libOverlayImports, or registers single overlays from my-flake.libOverlays.<name> by hand; the my-flake.modules.<class>.<name> flake outputs remain for 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 composing with caisson-core directly.
  • 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 those projects’ own documentation. The honest summary first: all five 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, three 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 single 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 do two jobs today: acquisition (fetching, pinning, integrity) and composition (deciding which copy of each dependency an evaluation actually uses, via the follows pin bucket). caisson separates the two. 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 flake schema’s only freeform slot, the lib output: 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 two 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 one flat namespace.

What is closure-inputs, and who sets it?

closure-inputs is the inputs attrset your flake passes to mkLib, threaded in by the caisson framework: mkLibOverlay and mkModule apply it to each registered file as the file’s first argument list. A file always receives the inputs of the flake that registered it: an overlay or module consumed from another flake sees the inputs of the flake it came from, not the inputs 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. mkFlake wraps flake-parts’ own mkFlake, and plain flake-parts modules work unchanged. It is recommended to start by composing a library with caisson-core.mkLib. You can hand your existing top-level module to mkFlake and let the conventions spread file by file from there.

What does mkFlakeModule 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 own 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 (ecosystems.nixpkgs = inputs.nixpkgs). But it’s also possible to explicitly pass it on each caisson call. The explicit argument wins, and an input named exactly like the ecosystem 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 two framework namespaces. 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 flake-level lib output mirrors both namespaces (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/ (lifecycle.nix for the machinery, default.nix for keyed composition and the resolver)

mkLib

mkLib :
  { inputs            : attrs                              # the defining flake's inputs
  , baseLib           : lib                                # the base library, a plain argument
  , modules           ? (lib: { })
                      : lib -> attrsOf (attrsOf module)    # class -> name -> module
  , libOverlays       ? (mkLibOverlay: { })
                      : (freeformOverlay -> libOverlay) -> attrsOf libOverlay
  , libOverlayImports ? builtins.attrValues
                      : attrsOf libOverlay -> listOf libOverlay
  , ecosystems        ? { } : attrs                        # declared ecosystem sources, by exact name
  , projects          ? { } : attrs                        # consumed upstream contributions, by project name
  } -> lib

Builds a composed library by extending baseLib with the caisson-core namespace injection and the selected registered overlays, then two synthetic overlays: the local module registrations (so local names win over overlay-borne contributions) and the manifest. Nothing is looked up by input name: baseLib is a plain argument, and the caisson-core.mkLib found in a composed library defaults it to that composition’s own base.

  • modules receives the composed lib (usable through the fixpoint) and returns the class-keyed registration, typically built with helpers like lib.caisson-core.mkModule and lib.caisson.mkFlakeModule.
  • libOverlays receives the input-closed mkLibOverlay helper and returns the registered overlays. Both arguments take exactly the function shape shown; passing anything else is an error.
  • libOverlayImports selects which registered overlays apply to this flake’s own lib; registration also feeds export, so the two can differ.
  • ecosystems declares default ecosystem sources for this composition ({ nixpkgs = inputs.nixpkgs; ... }), keyed by the exact names the integrations resolve. mkLib only captures them into the manifest; the integrations interpret them.
  • projects consumes whole upstream contributions ({ my-dep = inputs.my-dep; }): each value carries libOverlays and class-keyed modules dictionaries, which a caisson-built flake’s outputs already do. A project’s overlays join the registered dictionary 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 stays the way to cherry-pick or rename one.

mkLibOverlay

mkLibOverlay : freeformOverlay -> libOverlay

freeformOverlay = path | (closure -> { imports ? listOf libOverlay
                                     ; overlay : overlayFn })
closure = { closure-inputs     : attrs
          ; mkLibOverlay       : freeformOverlay -> libOverlay
          ; mkModule           : string -> freeformModule -> module
          ; contributeModules  : attrs -> attrsOf (attrsOf module) -> attrs
          }

Applies the closure attrset to an overlay given as a function or a path to one, 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 mkModule is bound to the defining composition, so modules contributed by an overlay close over the definer’s inputs and library. contributeModules prev { <class>.<name> = module; } returns the caisson-core.modules registry merge for the overlay’s output (merge its result with any namespace contributions); it is passed through the closure rather than the composed library because an overlay’s output attribute names must not depend on final. Qualify contributed names with your project prefix (my-flake/my-service); the composing flake’s local registrations apply last and win over same-named contributions. See Module classes for the ways modules enter the registry.

mkModule

mkModule : string -> freeformModule -> module

freeformModule = path | (closure -> module)
closure = { closure-inputs        : attrs    # the defining flake's inputs
          ; closure-lib           : lib      # the defining flake's composed lib
          ; closure-self-modules  : attrs    # the defining flake's registrations
                                             #   in the same class
          ; 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 one; 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.

modules

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

The class-keyed module registry of this composition: the flake’s own registrations merged with every overlay-borne contribution, locals winning on name conflicts. Integration adapters read their class from here (caisson-core.modules.<class>) as the default module selection.

manifest

manifest : { inputs : attrs; modules : attrsOf (attrsOf module);
             libOverlays : attrsOf libOverlay; ecosystems : attrs;
             projects : attrs }

The composition’s self-description, injected as its final overlay. inputs, ecosystems, and projects are the mkLib arguments as given; libOverlays and modules are the registered dictionaries, so consumed projects’ entries appear under <project>/<name> beside the local registrations, with a local winning a name collision. An mkLib composition self-describes: a consumer’s composed library carries the consumer’s own manifest. Checks live on the export side only (the flake-parts integration type-checks it and projects the flake.libOverlays and flake.modules outputs from it, so an exported selection can re-export a project-borne entry the same way as a hand-registered one); producers validate their own manifests in their own CI.

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.

callConsumerFlake

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

Evaluates a consumer-style flake from source with explicitly supplied inputs: the heart of integration testing. The flake’s declared inputs resolve by name: overrides first, then follows chains through the other resolved inputs, then pool; an unresolvable input throws an error naming it. The self fixpoint and decoration are handled by the shared call-flake kernel (also used by the eval-weight harness). Nothing is fetched: locks are not read, and sourceInfo attrs appear only if supplied. See Testing.

compose, resolve, partitionExtraInputs

Keyed composition (compose), the layered ecosystem-source resolver (resolve), and the read-only-eval-safe partition extra-inputs loader, re-exposed from caisson-core. See How lib is composed and caisson-core’s own documentation.

The caisson namespace

mkFlake

  • Source: lib-overlays/flake-parts/default.nix
mkFlake :
  { configModule  : module                                  # flake class
  , moduleImports ? builtins.attrValues
                  : attrsOf module -> listOf module          # selection from the flake class registry
  , name          ? null : nullOr string                    # rev-independent module identity
  , ...                                                     # forwarded to flake-parts mkFlake
  } -> flakeOutputs

Builds final flake outputs via flake-parts using the composed lib: the flake’s inputs come from lib.caisson-core.manifest (so mkFlake requires a manifest-carrying, mkLib-built composition), and moduleImports selects over the flake class of lib.caisson-core.modules, the same registry every adapter selects from, so modules arriving by local registration, overlay contribution, or consumed project are all selectable. The flake-parts pin is caisson’s own, closed over at the integration’s definition; consumers declare no flake-parts input. name sets flake-parts’ moduleLocation (so exported modules deduplicate across revs) and defaults caisson.configInfo.configName.

mkFlakeModule

  • Source: lib-overlays/flake-parts/default.nix
mkFlakeModule : freeformModule -> module    # = caisson-core.mkModule "flake"

Convenience form of mkModule "flake" for flake-parts modules.

modules.flake."caisson/partitions"

flake-parts’ partitions module, registered and exported in caisson’s flake class so a consumer selects it from the registry (moduleImports = modules: [ modules."caisson/partitions" ... ]) rather than declaring a flake-parts input for it.

modules.flake."caisson/nixpkgs", modules.flake."caisson/nixpkgs-interface"

  • Source: modules/flake-parts/nixpkgs/, modules/flake-parts/nixpkgs-interface/

The nixpkgs integration’s flake modules. nixpkgs-interface declares only the overlay registry, caisson.nixpkgs.overlays.all: an attrset of named overlay-producing functions (each takes the flake’s configName and returns an overlay; mkPackagesOverlay and mkPolyfillOverlay below build them). Registering an overlay does nothing by itself; a sibling flake module imports the interface to make an overlay available and leaves selection to the consumer.

nixpkgs imports the interface and adds the package-set machinery, the caisson.nixpkgs.* options:

  • pkgSets.<name>: a package-set definition: pkgFunction (a nixpkgs-style entry point, e.g. import inputs.nixpkgs) and overlayImports (a selection function from the registry to the overlays to apply, default all). Each set is reified per system and handed to perSystem modules as the pkgSets argument; pkgSets.pkgs also becomes the default perSystem pkgs.
  • config: the nixpkgs config applied to every generated package set.
  • overlays.exported and overlays.export.enabled: the selection from the registry published as the flake’s overlays output.
  • pkgs.export.enabled, packages.export.enabled: whether to export legacyPackages, and the flake’s own package scope (pkgs.<configName>) as packages.

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.

Types

types.libOverlay

  • Source: lib-overlays/flake-parts/default.nix

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

  • Source: lib-overlays/flake-parts/default.nix

A structural option type for the caisson-core manifest ({ inputs, modules, libOverlays }). The export-side check: the core flake-parts module reads lib.caisson-core.manifest through an option of this type before projecting the flake.libOverlays and flake.modules outputs.

Integration namespaces

Each integration is a library overlay exported by this flake (libOverlays.<ecosystem>) and available as a keyed entry via lib.composition.entriesFor. Composing one contributes its lib.caisson.<ecosystem> namespace, documented below (the flake-parts integration contributes directly under lib.caisson, plus the lib.flake-parts mirror of flake-parts’ own library). Each entry point takes its ecosystem as an ecosystemSrc argument, and the integrations pin nothing themselves, with one exception: flake-parts, whose pin is caisson’s own hidden input.

An adapter’s ecosystem source resolves in layers: the explicit ecosystemSrc argument first, then the composition’s declared ecosystems.<name> (an mkLib argument, carried by the manifest), then an input of the composing flake named exactly <name>. The names are nixpkgs (the nixos integration), home-manager, colmena, terranix, and system-manager. A full miss throws at the adapter, naming the three places; a composition built without mkLib (no manifest) accepts only the explicit argument. Common conventions:

  • pkgSets: an attrset of package sets; pkgSets.pkgs is required where present and becomes the evaluation’s package set (also passed through in specialArgs/extraSpecialArgs).
  • moduleImports: a selection function over the corresponding class registry (lib.caisson-core.modules.<class>), returning the list of modules to apply; the default, builtins.attrValues, applies all registered modules. 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.nixos (module class nixos)

  • Source: lib-overlays/nixos/default.nix
  • mkNixosModule : freeformModule -> module: class-bound mkModule.
  • mkSystem : { ecosystemSrc, pkgSets, configModule, moduleImports?, specialArgs?, ... } -> nixosSystem: evaluates <ecosystemSrc>/nixos/lib/eval-config.nix (a nixpkgs source tree) with the selected class modules, the config module, and a framework module pinning nixpkgs.pkgs to pkgSets.pkgs. Extra arguments pass through to eval-config.nix.
  • mkSystemFull: as mkSystem, additionally passing nixpkgs’ module-list.nix as baseModules.
  • mkSystemMinimal : { ecosystemSrc, prefix?, ... }: bare evalModules from <ecosystemSrc>/nixos/lib; no NixOS base modules, so the config module declares any options it uses.

caisson.home-manager (module class homeManager)

  • Source: lib-overlays/home-manager/default.nix
  • mkHomeManagerModule : freeformModule -> module.
  • mkHomeConfiguration : { ecosystemSrc, pkgSets, configModule, moduleImports?, extraSpecialArgs?, osConfig?, check?, minimal?, sourceMeta? } -> homeConfiguration: runs home-manager’s own evaluator (<ecosystemSrc>/modules). Source metadata defaults derive from what actually composes: homeManagerOutPath from ecosystemSrc and nixpkgsOutPath from pkgSets.pkgs.path (schemaVersion 3).
  • mkHomeConfigurationMinimal: mkHomeConfiguration with minimal = true.
  • mkStandaloneAdapter : { moduleImports?, ... } -> { homeModules, buildHome }: the selected class modules as a list plus a buildHome closure over the same arguments.
  • mkNixosAdapter : { users, ecosystemSrc, hostName?, hostKind?, baseSystem?, sourceMeta?, moduleImports?, sharedModules?, useGlobalPkgs?, useUserPackages?, activationMode?, extraSpecialArgs?, ... } -> module (nixos class): embeds home-manager in a NixOS generation. activationMode = "upstream" uses home-manager’s own NixOS module; "user-service" embeds standalone activation packages behind a ConditionUser user unit and leaves users.users untouched, which keeps it safe for systemd-homed hosts (one hosted user). Both write /etc/caisson-home-manager/source.json for the drift check.
  • mkSourceMeta, assertSourceCoherence: source-provenance records and the fingerprint comparison used by the drift machinery.

caisson.nixpkgs

  • Source: lib-overlays/nixpkgs/default.nix
  • 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.colmena (module class colmena)

  • Source: lib-overlays/colmena/default.nix
  • mkColmenaModule : freeformModule -> module.
  • mkColmenaHive : { ecosystemSrc, modules?, moduleImports?, specialArgs?, ... } -> hive: ecosystemSrc.lib.makeHive over the passthrough arguments, with the selected class modules and framework specialArgs merged into meta and defaults.

caisson.terranix (module class terranix)

  • Source: lib-overlays/terranix/default.nix
  • mkTerranixModule : freeformModule -> module.
  • mkTerranixConfiguration : { ecosystemSrc, modules?, moduleImports?, extraArgs?, ... } -> derivation: ecosystemSrc.lib.terranixConfiguration with the selected class modules and framework extraArgs.

caisson.system-manager (module class systemManager)

  • Source: lib-overlays/system-manager/default.nix
  • mkSystemManagerModule : freeformModule -> module.
  • mkSystemConfig : { ecosystemSrc, modules?, moduleImports?, specialArgs?, ... } -> systemConfig: ecosystemSrc.lib.makeSystemConfig with the selected class modules, plus a compatibility bridge for the current nixos-unstable restructuring of the NixOS nix module (each half self-retires; see the source comments).

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.

Options

caisson.configInfo.configName

  • Type: nullOr str
  • Default: null
  • Source: modules/flake-parts/default/caisson/configInfo.nix

The canonical name of this flake. Used in doc/version strings and as a default namespace name for exports. Some export options (e.g. caisson.lib.export.enabled) require this to be set.

caisson.lib.export.enabled

  • Type: bool
  • Default: false
  • Source: modules/flake-parts/default/caisson/lib.nix

Whether to enable lib export. When enabled, publishes the selection made by caisson.lib.exported as flake.lib.

caisson.lib.exported

  • Type: function -> lazyAttrsOf raw
  • Default: composedLib: composedLib.${configName} (requires configInfo.configName)
  • Source: modules/flake-parts/default/caisson/lib.nix

Function that selects which parts of the composed library to publish as the flake’s lib output. The default exports the flake’s own namespace; caisson itself sets composedLib: { inherit (composedLib) caisson caisson-core; } so flake-level and composed-level addresses match.

caisson.manifest

  • Type: caisson.types.manifest (read-only)
  • Default: the composed library’s caisson-core.manifest
  • Source: modules/flake-parts/core/caisson/manifest.nix

The composition’s manifest: inputs, ecosystems, and projects as given to mkLib, plus the registered libOverlays and modules dictionaries (project entries under <project>/<name>, locals winning). Reading it type-checks the manifest; the flake.modules and flake.libOverlays projections are drawn from it.

caisson.modules

  • Type: attrsOf (submodule { export.enabled; exported; })
  • Default: {}
  • Source: modules/flake-parts/core/caisson/modules.nix

Export settings for each registered module class. Each class key defines:

  • export.enabled (bool, default true)
  • exported (function -> attrsOf deferredModule, default modules: { })

The selected modules are published under flake.modules.<class>. For the "flake" class specifically, the same modules are also mirrored to flake.flakeModules.

caisson.modules.<class>.export.enabled

  • Type: bool
  • Default: true
  • Source: modules/flake-parts/core/caisson/modules.nix

Whether to export modules for a given class.

caisson.modules.<class>.exported

  • Type: function -> attrsOf deferredModule
  • Default: modules: { }
  • Source: modules/flake-parts/core/caisson/modules.nix

Function that selects which modules in a class to publish under flake.modules.<class>.

caisson.libOverlays.export.enabled

  • Type: bool
  • Default: true
  • Source: modules/flake-parts/core/caisson/libOverlays.nix

Whether to enable lib overlay export. When enabled, publishes the overlays selected by caisson.libOverlays.exported under flake.libOverlays.

caisson.libOverlays.exported

  • Type: function -> attrsOf libOverlay
  • Default: overlays: { }
  • Source: modules/flake-parts/core/caisson/libOverlays.nix

Function that selects which registered library overlays to export as flake outputs. Receives the set of overlays registered via mkLib and returns the subset to publish under flake.libOverlays.

Repository Layout

caisson mandates nothing about layout beyond the repository being a flake: 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. This repository and its integrations use them, and the documentation and the examples/literate-flake example assume them.

flake.nix

Wiring only: mkLib and mkFlake. 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-parts/caisson/) or default. In other classes, a config is named for the thing it describes: a machine, a home, 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 use the ecosystem’s name: flake-parts, nixos, home-manager), then by the module’s own name; 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/flake-parts/default/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 partitionExtraInputs). 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

mkModule is a factory:

mkModule = class: freeformModule: ...

You first choose a module class, then use the returned class-specific normalizer. For flake-parts modules:

mkFlakeModule = 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 inputs (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)
closure-self-modulesThe defining flake’s registered modules in the same class
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, 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-inputs, ... }:
{
  imports = [ closure-inputs.some-flake.libOverlays.default ];
  overlay = final: prev: { ... };
}

Already-built overlays (for example another flake’s exported libOverlays.default) are registered directly rather than wrapped in mkLibOverlay.

Key Functions

FunctionPurpose
mkModuleCreates class-specific module normalizers with closed inputs
mkLibOverlayApplies the closure to registered library overlays
importApplyApplies static arguments to a module through the import chain
mkLibBootstraps a composed library with closed overlays
mkFlakeCreates 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’ generic flake.modules support while adding closed-inputs module normalization.

mkModule Factory

lib.caisson-core.mkModule is class-parameterized:

mkModule = class: freeformModule: ...

Example:

modules = {
  flake = {
    default = lib.caisson.mkFlakeModule ./modules/flake-parts/default;
  };

  generic = {
    helper = lib.caisson-core.mkModule "generic" ./modules/generic/helper;
  };
};

The returned 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

Modules enter the class-keyed registry (lib.caisson-core.modules) in three ways:

  • Local registration, mkLib’s modules hook: a function lib: { ... } receiving the composed lib (whose helpers, like lib.caisson.mkFlakeModule, build the entries) and returning the class-keyed registration. This is for the flake’s own modules.

  • Overlay contribution, for modules contributed by a library overlay: the overlay closure contains mkModule and contributeModules, and the overlay merges its entries into the registry:

    { mkModule, contributeModules, ... }:
    {
      imports = [ ];
      overlay =
        final: prev:
        contributeModules prev {
          nixos."my-flake/my-service" = mkModule "nixos" ./modules/my-service.nix;
        }
        // {
          my-flake = (prev.my-flake or { }) // { ... };
        };
    }
    

    mkModule here is bound to the defining flake’s composition, so the contributed module closes over the definer’s inputs and library, not the consumer’s. A consumer who registers the exported overlay gets its library namespace and its modules together, transitively through the overlay’s imports chain; no re-registration is involved.

  • 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, so two rules keep multiple contributors coherent. Names within a class are a single flat space: qualify contributed names with your project prefix (my-flake/my-service), the same discipline as top-level library namespaces; short names are for the composing flake’s own registrations. And precedence is deterministic: the composing flake’s local registrations apply last, so a local entry always wins over a same-named contribution.

Use class flake for flake-parts modules and other class keys for other module ecosystems. The shipped integrations (caisson.nixos, caisson.home-manager, caisson.terranix, caisson.colmena, caisson.system-manager, and caisson.nixpkgs) each register their own class this way; 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 one 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

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 one) 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

Four 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 configInfo.configName convention helps here: if every project uses its canonical flake name as the namespace, 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 configName, 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 ones 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. One 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 own 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 inputs, so some-flake resolves against the inputs 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 = mkLibOverlay: {
  default = 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 = mkLibOverlay: {
  default = mkLibOverlay ./lib-overlays/default;
  extended = 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

Integrations do not pin their ecosystems: caisson.nixos has no nixpkgs pin, and caisson.home-manager has no home-manager pin. You pass the ecosystem in, as an argument called the ecosystem source, and the integration calls the evaluator inside that source. A single 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 (e.g. package sets, lib ecosystems). 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 a nixpkgs source tree (it evaluates nixos/lib/eval-config.nix from it).
  • caisson.home-manager takes a home-manager source tree.
  • caisson.colmena, caisson.terranix, and caisson.system-manager take their project’s flake (they call lib.makeHive, lib.terranixConfiguration, and lib.makeSystemConfig on it).

How does caisson get access to ecosystem sources?

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

  1. Explicit argument. ecosystemSrc = inputs.nixpkgs at the call site always wins.
  2. Flake-level default. ecosystems.nixpkgs = inputs.nixpkgs at mkLib declares the composition’s default for that name.
  3. Exact-name input. As a final fallback, an input of the composing flake named exactly like the ecosystem (nixpkgs, home-manager, …) is used. Handy for leaf flakes that declare the input anyway.

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

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 base library and a set of declarations:

  • baseLib: the library everything else extends, passed as a plain value. Nothing is looked up by input name. When you call the caisson-core.mkLib found inside a composed library, baseLib defaults to that composition’s own base, which is why a typical flake passes only the arguments below.
  • libOverlays: the flake’s own overlay registrations, built with the input-closed mkLibOverlay helper or registered directly when already built (another flake’s export, for example).
  • modules: the flake’s own class-keyed module registrations.
  • projects: whole upstream contributions, each carrying exported overlays and modules that register under <project>/<name>.
  • ecosystems: declared default ecosystem sources (see Ecosystem sources).

Registration and application are separate steps: libOverlayImports selects which of the registered overlays apply to this flake’s own 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 sequence

The selected overlays are applied over the base in one pass, wrapped by overlays caisson adds itself:

baseLib
  -> caisson-core namespace injection
  -> selected overlays (flattened, imports first)
  -> consumed projects' modules
  -> the flake's own module registrations
  -> the manifest

The first added overlay injects the machinery and the empty module registry under caisson-core. The last one records the manifest, the composition’s self-description, at lib.caisson-core.manifest. 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 one to build on, which is what the my-flake = (prev.my-flake or { }) // { ... } merge convention relies on.

Two 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 own 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 own 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, replacement slots, metadata) is specified in caisson-core, where the code lives.

caisson exports its own 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, 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

mkFlake passes the composed library to flake-parts as specialArgs.lib, so modules receive it as their ordinary lib argument. flake.lib publishes a selection of it when caisson.lib.export.enabled is set; the default selection is the flake’s own namespace only, and that is the convention: exporting the full composed library would make all of nixpkgs-lib, at your pin, part of your public contract. Consumers build their own composed library against their own inputs instead.

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-core.mkLib {
  inherit inputs;
  libOverlays = mkLibOverlay: { ... };
  modules = lib: { ... };
};

mkLib builds registration helpers closed over the inputs it was given, and hands them to the registration arguments: libOverlays receives the input-closed mkLibOverlay, and modules receives the composed lib, whose helpers (lib.caisson.mkFlakeModule, lib.caisson-core.mkModule) are closed the same way. There is no ambient lookup anywhere in this chain: the only inputs a registration can see is the attrset its own flake passed to mkLib.

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 two kinds are evaluated at different times.

A library overlay’s closure contains registration helpers and inputs:

  • closure-inputs: the defining flake’s inputs.
  • mkLibOverlay: the same helper, for building nested overlays.
  • mkModule: the module normalizer bound to the defining composition, so modules contributed by the overlay close over the definer’s inputs and library.
  • contributeModules: merges class-keyed module contributions into the registry from inside an overlay. It is threaded through the closure rather than read from final because an overlay’s output attribute names must not depend on final (see How lib is composed).

The composed lib is deliberately absent: an overlay runs inside the composition that builds lib, so it reads the library through its final and prev arguments instead.

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

  • closure-inputs: the defining flake’s inputs.
  • closure-lib: the defining flake’s composed library. This is not the lib module argument; see the next section.
  • closure-self-modules: the defining flake’s registrations in the same class, for modules that import their siblings.
  • 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 own 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 their own 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 its own flake’s namespace 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 one. That skew is a designed property, not an accident: each flake’s files run against the pins that flake tested with, the pins are visible in each flake’s lock, and nothing forces the fleet 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 is the definer’s inputs attrset and follows rewrites what that attrset contains.

importApply

Closure application composes with module imports through lib.caisson-core.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-core.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 this repository’s own test suite (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.

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. partitionExtraInputs feeds it to the checks partition.

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 own 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 own 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.

Three 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.