caisson-core composes library overlays: identity, replacement, and deterministic order, implemented over plain Nix builtins.
caisson-core has zero flake inputs and its library code references
nothing but builtins. It is the foundation layer of the caisson
family; it is useful on its own to anyone who wants to compose an
extensible library out of overlay-shaped pieces without depending on
nixpkgs, flake-parts, or any other flake.
The unit of composition is an entry:
{
key = "example.base"; # stable identity: a string, or null
imports = [ ]; # entries this entry depends on
overlay = final: prev: { greet = name: "hello, ${name}"; };
}compose takes a list of entries and produces the composed library
plus composition metadata:
let
core = (builtins.getFlake "github:nix-caisson/caisson-core").lib.caisson-core;
base = {
key = "example.base";
imports = [ ];
overlay = final: prev: { greet = name: "hello, ${name}"; };
};
loud = {
key = "example.loud";
imports = [ base ];
overlay = final: prev: { greet = name: "${prev.greet name}!"; };
};
in
(core.compose { entries = [ loud ]; }).lib.greet "world"
# => "hello, world!"- Imports are reachability. Listing an entry pulls its transitive imports into the composition. Entries are collected by a depth-first, post-order walk, so an entry's imports precede it.
- The key is identity. A keyed entry appears once no matter how many entries import it. The first occurrence of a key fixes its position; the last occurrence supplies its value, so mentioning a key again replaces that entry wholesale. A replacement inherits the replaced entry's slot: its own imports are pulled into the composition, but they land later, guaranteeing reachability rather than precedence.
- Cycles terminate. The walk skips a key that is already on its own path. Members of a cycle get no ordering guarantee relative to each other; everything else is unaffected.
- Keyless entries are a local tail. An entry with
key = nullcannot be imported. Keyless entries apply after the entire keyed world, in list order, and stack when listed repeatedly. They are the consumer's private patch layer: having no key, they can never be replaced by another entry. - Application is a classic overlay fold.
previs everything accumulated so far; references throughfinalsee the finished fixpoint. One law follows from the fixpoint itself: an overlay's output attribute names must not depend onfinal. composealso returnsmeta(key order, winning entries, tail length) so tooling can inspect and lint a composition;composeitself does not warn, because linting belongs to that tooling.
resolve implements layered lookup for handing ecosystem sources
(such as a nixpkgs lib directory) to higher layers:
core.resolve {
name = "nixpkgs-lib";
explicit = null; # highest priority when non-null
defaults = { }; # the client repository's declared defaults
inputs = { }; # matched by exact name only
}Priority is explicit argument, then declared default, then an input
with exactly the declared name. A full miss returns null; resolve
can never throw or format an error message, because interpreting a
miss is deliberately the calling layer's job.
mkLib builds a composed library from registered overlays and
modules over the empty seed, and injects the caisson-core namespace
(machinery, module registry, manifest) into the result:
core.mkLib {
inputs = inputs; # the composing flake's inputs, closed over
# by registered overlays and modules
defaultEcosystemSrc = { nixpkgs = inputs.nixpkgs; };
# the tree's default source per ecosystem, by
# exact name; `nixpkgs` supplies the nixpkgs-lib
# part unless `nixpkgs-lib` names its own source
modules = composedLib: { }; # class-keyed local registrations
libOverlays = mkLibOverlay: { }; # named overlay registrations
libOverlayImports = builtins.attrValues; # selection for this library
projects = { }; # consumed upstream contributions,
# by project name
systems = [ "x86_64-linux" ]; # the platforms the tree builds on,
# declared once; null when absent
}Nothing is composed over. nixpkgs' library arrives as the published
nixpkgs-lib entry, which imports the lib directory of the source
supplying that part (defaultEcosystemSrc.nixpkgs-lib, else
.nixpkgs, else an input named exactly so) as that source fixes it;
a polyfill composed later overrides a name for readers of the
composed library, not for upstream's own internal references, since
nixpkgs' lib/default.nix exposes no way to re-tie its fixpoint. An
overlay that needs upstream's functions imports the
entry from its closure ({ entries, ... }: { imports = [ entries.nixpkgs-lib ]; ... });
a composition that declares no source fails only where that entry is
composed, with a message naming the declaration. The core entry
(caisson-core) and the nixpkgs-lib entry sit in the registry under
those names like any registration, so a same-name registration
replaces either.
The composed library carries, under caisson-core: mkLib,
mkLibOverlay, mkModule (class-parameterized),
mkNixpkgsLibEntry, the class-keyed modules registry, the three
manifest slots (libManifest, pkgsManifest, evalManifest), plus
compose, resolve, importApply, callConsumerFlake, and
partitionExtraInputs. Overlays contribute
modules through their closure (mkModule, contributeModules); the
composing flake's local registrations apply last and win over
same-named contributions. mkCoreOverlay exposes the same namespace
injection as a built overlay for compositions assembled with
compose directly.
A projects value is an attrset with libOverlays and class-keyed
modules dictionaries, the outputs a flake built on this machinery
already publishes. Its entries join the registered dictionaries under
<project>/<name>, so the existing selections keep per-item choice
and a local registration wins a name collision.
The manifest is the composition's self-description, recorded at
caisson-core.libManifest: inputs, defaultEcosystemSrc,
systems, the raw projects capture, and the registered libOverlays and
modules dictionaries (project entries prefixed, locals winning). It
is not passed anywhere; readers pull it back out of the composed
library. There is a slot per evaluation phase: libManifest is
filled here, and pkgsManifest and evalManifest are present and
null, for the layers that build package sets and module evaluations
to fill on the libraries they hand out. Higher
layers project a flake's libOverlays and modules outputs from it,
and the projects argument consumes those projections one level
down, which is how dictionaries populate across flakes. The manifest
carries no checks here: producers validate their own manifests, and
consuming integrations type-check on the export side.
Two self-contained companions ship alongside compose:
callFlake { src, inputs, sourceInfo ? { } }applies a flake's outputs function to explicitly provided, already-wired inputs. No lock handling and no fetching; every input is a constructed flake or a plain source path.partitionExtraInputs <dir>loads a lockfile'd subflake directory and returns its inputs, safely under read-only evaluation (via the patched copy of flake-compat in vendor/).
Both keep the builtins-only rule; the vendored flake-compat carries its own license and provenance header.
The test suite is hermetic pure evaluation:
nix eval -f tests summaryPre-release. The contract described above is intended to freeze; until the first release it may still change. The caisson framework builds on this repository, and caisson-compat tests the family against pinned upstreams.
MIT. See LICENSE.
Despite the org name, caisson-core is an independent project, not affiliated with or endorsed by the NixOS Foundation. Nix and NixOS are trademarks of the NixOS Foundation.