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
libcomposition, 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
libis composed: the whole composition pass, and composing withcaisson-coredirectly. - Testing, including
callConsumerFlakefor 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 attrsetmodule: a module for some module class’s module systemoverlayFn:final: prev: attrs, the standard overlay functionlibOverlay:{ imports : listOf libOverlay; overlay : overlayFn }, the built overlay produced bymkLibOverlay(both keys always present)patharguments are imported before the rules below apply
The caisson-core namespace
- Source: caisson-core’s
lib/(lifecycle.nixfor the machinery,default.nixfor 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.
modulesreceives the composedlib(usable through the fixpoint) and returns the class-keyed registration, typically built with helpers likelib.caisson-core.mkModuleandlib.caisson.mkFlakeModule.libOverlaysreceives the input-closedmkLibOverlayhelper and returns the registered overlays. Both arguments take exactly the function shape shown; passing anything else is an error.libOverlayImportsselects which registered overlays apply to this flake’s ownlib; registration also feeds export, so the two can differ.ecosystemsdeclares 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.projectsconsumes whole upstream contributions ({ my-dep = inputs.my-dep; }): each value carrieslibOverlaysand class-keyedmodulesdictionaries, 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:libOverlayImportsdecides 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) andoverlayImports(a selection function from the registry to the overlays to apply, default all). Each set is reified per system and handed toperSystemmodules as thepkgSetsargument;pkgSets.pkgsalso becomes the defaultperSystempkgs.config: the nixpkgs config applied to every generated package set.overlays.exportedandoverlays.export.enabled: the selection from the registry published as the flake’soverlaysoutput.pkgs.export.enabled,packages.export.enabled: whether to exportlegacyPackages, and the flake’s own package scope (pkgs.<configName>) aspackages.
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.pkgsis required where present and becomes the evaluation’s package set (also passed through inspecialArgs/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 matcheslibOverlayImports; for order-sensitive list-typed options, prefermkOrderover 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-boundmkModule.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 pinningnixpkgs.pkgstopkgSets.pkgs. Extra arguments pass through toeval-config.nix.mkSystemFull: asmkSystem, additionally passing nixpkgs’module-list.nixasbaseModules.mkSystemMinimal : { ecosystemSrc, prefix?, ... }: bareevalModulesfrom<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:homeManagerOutPathfromecosystemSrcandnixpkgsOutPathfrompkgSets.pkgs.path(schemaVersion3).mkHomeConfigurationMinimal:mkHomeConfigurationwithminimal = true.mkStandaloneAdapter : { moduleImports?, ... } -> { homeModules, buildHome }: the selected class modules as a list plus abuildHomeclosure 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 aConditionUseruser unit and leavesusers.usersuntouched, which keeps it safe for systemd-homed hosts (one hosted user). Both write/etc/caisson-home-manager/source.jsonfor 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: amakeScopewrapper handing the scope function itscallPackage.mkPackagesOverlay : pkgsFn -> name -> overlayFn: turns a scope function (or path; optionally context-taking{ callPackage, inputs, lib }) into an overlay that merges the scope under attributename.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.makeHiveover the passthrough arguments, with the selected class modules and frameworkspecialArgsmerged intometaanddefaults.
caisson.terranix (module class terranix)
- Source:
lib-overlays/terranix/default.nix mkTerranixModule : freeformModule -> module.mkTerranixConfiguration : { ecosystemSrc, modules?, moduleImports?, extraArgs?, ... } -> derivation:ecosystemSrc.lib.terranixConfigurationwith the selected class modules and frameworkextraArgs.
caisson.system-manager (module class systemManager)
- Source:
lib-overlays/system-manager/default.nix mkSystemManagerModule : freeformModule -> module.mkSystemConfig : { ecosystemSrc, modules?, moduleImports?, specialArgs?, ... } -> systemConfig:ecosystemSrc.lib.makeSystemConfigwith 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}(requiresconfigInfo.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, defaulttrue)exported(function -> attrsOf deferredModule, defaultmodules: { })
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:
| Key | Value |
|---|---|
closure-inputs | The defining flake’s inputs (distinct from the flake-parts inputs module arg, which belongs to the consuming flake) |
closure-lib | The defining flake’s composed lib (distinct from the lib module arg) |
closure-self-modules | The defining flake’s registered modules in the same class |
mkModule | A 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
| Function | Purpose |
|---|---|
mkModule | Creates class-specific module normalizers with closed inputs |
mkLibOverlay | Applies the closure to registered library overlays |
importApply | Applies static arguments to a module through the import chain |
mkLib | Bootstraps a composed library with closed overlays |
mkFlake | Creates flake outputs with closed modules |
Further Reading
- How inputs are closed over: the mechanism behind this convention
- How
libis composed: the whole composition pass - Module Classes: class-keyed module registration and export
examples/literate-flake/: a working example demonstrating closed input wiring
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’smoduleshook: a functionlib: { ... }receiving the composedlib(whose helpers, likelib.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
mkModuleandcontributeModules, 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 { }) // { ... }; }; }mkModulehere 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’simportschain; no re-registration is involved. -
Project consumption,
mkLib’sprojectshook: 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.enabledcaisson.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
prevmerge. WritingmyProject = { helper = ...; }instead ofmyProject = (prev.myProject or {}) // { helper = ...; }will silently discard any functions added tomyProjectby 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
libis 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.nixostakes a nixpkgs source tree (it evaluatesnixos/lib/eval-config.nixfrom it).caisson.home-managertakes a home-manager source tree.caisson.colmena,caisson.terranix, andcaisson.system-managertake their project’s flake (they calllib.makeHive,lib.terranixConfiguration, andlib.makeSystemConfigon it).
How does caisson get access to ecosystem sources?
A source comes from one of three places, in priority order:
- Explicit argument.
ecosystemSrc = inputs.nixpkgsat the call site always wins. - Flake-level default.
ecosystems.nixpkgs = inputs.nixpkgsatmkLibdeclares the composition’s default for that name. - 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 thecaisson-core.mkLibfound inside a composed library,baseLibdefaults 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-closedmkLibOverlayhelper 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’sinputs.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 fromfinalbecause an overlay’s output attribute names must not depend onfinal(see Howlibis 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’sinputs.closure-lib: the defining flake’s composed library. This is not thelibmodule 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:
| Value | Whose world |
|---|---|
closure-inputs | The flake that registered the file |
closure-lib | The flake that registered the file |
inputs module arg | The flake being evaluated |
lib module arg | The 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 instantiationsnixpkgsLibEvals: nixpkgs-lib bootstraps (distinct lib sources)moduleSystemEvals:evalModulesruns, 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:
entryis a self-contained file imported inside the sandbox and applied toargs(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 sharedcall-flake.nixkernel from a store path inargs, the same kernel undercallConsumerFlake. - 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:
nullruns 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 attests/eval-weight/baseline.json) and subsequent runs gate against it: deterministic metrics may grow up tomaxGrowth(10% by default; exact metrics not at all), and marked shrinkage logs a note suggesting the baseline be tightened.
The workflow
- Write an entry per scenario under
tests/eval-weight/. - Run once with
baseline = null; paste the printed baseline intotests/eval-weight/baseline.json. - Wire
mkCheckintocheckswith the committed baseline. - 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.