Skip to content

v0.9.0

Choose a tag to compare

@vic vic released this 20 Feb 08:00
· 426 commits to main since this release

Den v0.9.0 — Declarative Context Definitions and new documentation website!

This release introduces den.ctx, a declarative system for defining how context (data) is transformed and which aspects are applied at every stage of the configuration pipeline.

We now have a new documentation with more diagrams, the docs are now part of the repo to aid AI tools looking at the repo to also read the docs.

I also re-wrote all tests to be self contained and isolated. Previous to this, we all tests shared the same aspects and hosts, making it very difficult for people to use CI tests as practial examples. Now each test is a self contained den configuration and works or fails by itself. Read templates/ci/modules !

Upgrading

If you had any custom context like { OS, host } it now becomes { host }, same for { HM, user, host } -> { user, host }

What's Changed

  • Update Den at npins to make noflake work from checkout by @vic in #162
  • feat: forward classes by @vic in #164
  • update den and flake-aspects by @vic in #166
  • Example tests and documentation for forwarding classes. by @vic in #167
  • Fix usage of obsolete options and remove passthru requirement (#169) by @jhakonen in #171
  • den.ctx. Den declarative context definitions. by @vic in #175

New Contributors

Full Changelog: v0.8.0...v0.9.0


Context flows (our new dependency system)

Before this release, den.default served two purposes:

  • A place to define global or generic includes for host, user, and home entities.
  • The backbone of context propagation — moving data from { host } to { host, user } to { host, user } in the HM pipeline, etc.

As a consequence, den.default.includes was abused by many of us, including Den itself, because it was where context transformation happened [2]. This "dependency system" — parametric aspects installed unconditionally at den.default.includes — was hard to reason about, hard to document, and hard for people to understand.

The symptoms were duplicate configuration values, caused by lax parametric functions matching too many pipeline stages.

What den.ctx Provides

  • Keep den.default for what it's good at: global settings. You can still use den.default.includes, but there are better alternatives now.
  • Move the dependency system out of den.default.includes: those parametric aspects were not individually testable, and you couldn't change how data flows. They were Den's hardcoded backbone.
  • Declarative data stages: context transformations are now explicit. Given a host, you declare how to enumerate users, detect HM support, etc.
  • Named contexts: previously we identified contexts only by their attrNames{ host }, { host, user }. Now they have names: ctx.host, ctx.hm-host. Names allow different contexts with the same structural shape but different semantic guarantees.
  • Extensible context flows: one core principle of Den is not getting in your way. You can create alternative flows, or use Den purely as a library.

Context Transformations: Parse don't Validate principle

Named contexts carry semantic meaning beyond their structure. ctx.host { host } and ctx.hm-host { host } hold the same data, but hm-host guarantees that home-manager support was validated:

  • inputs.home-manager exists (or the host has a custom hm-module)
  • The host has at least one user with class = "homeManager"

You cannot obtain an hm-host context unless these conditions hold. This follows the transform-don't-validate principle.

How a Context Type Works

A context type has four components: desc, conf, includes, and into.

den.ctx.foo.desc = "The foo context requires { foo } data.";

den.ctx.foo.conf = { foo }: my-aspects.${foo.name};

When ctx.foo is applied — it works like a function taking { foo } — it locates the responsible aspect via conf. For example, ctx.foo { foo.name = "bar"; } uses my-aspects.bar. The aspect's owned config, static includes, and parametric includes matching { foo } all contribute to whatever ctx.foo is being used to configure.

Context types are independent of NixOS. Den can be used as a library for network topologies, declarative cloud infrastructure, or anything describable as data transformations.

How a NixOS Configuration Is Built

The initial data for nixosConfigurations.igloo is the host itself:

# Nothing NixOS-specific yet — just a graph of dependencies.
aspect = den.ctx.host {
  host = den.hosts.x86_64-linux.igloo;
};

The result of ctxApply is a new aspect that includes den.aspects.igloo plus the entire transformation chain — user enumeration, HM detection, defaults.

# This is where things enter the NixOS domain.
nixosModule = aspect.resolve { class = "nixos"; };

nixosConfigurations.igloo = lib.nixosSystem {
  modules = [ nixosModule ];
};

These two steps can be adapted for any class, for anything Nix-configurable.

Context Propagation

Context transformation is declarative. If your data fans out to other contexts, you specify the transformations using .into:

den.ctx.foo.conf = { foo }: ...;
den.ctx.moo.conf = { moo }: ...;

den.ctx.foo.into.moo = { foo }: lib.singleton { moo = deriveMoo foo; };

All <source>.into.<target> transformations are taken into account by ctxApply.

Why Lists?

Transformations have the type source → [ target ]. This enables:

  • Fan-out: one host produces many { host, user } contexts (map)
  • Conditional propagation: zero or one contexts (lib.optional)
  • Pass-through: identity transformation (lib.singleton)

For example, HM detection uses conditional propagation:

den.ctx.host.into.hm-host = { host }:
  lib.optional (isHmSupported host) { inherit host; };

Same data, but the named context guarantees validation passed.

Contexts as Aspect Cutting-Points

Contexts are aspect-like themselves. They have owned configs and .includes:

# Owned config — only for validated HM hosts:
den.ctx.hm-host.nixos.home-manager.useGlobalPkgs = true;

# Scoped includes — only for validated HM hosts:
den.ctx.hm-host.includes = [
  ({ host, ... }: { nixos.home-manager.backupFileExtension = "bak"; })
];

This is like den.default.includes but scoped — it only activates for hosts with validated home-manager support.

Extending the Context Flow

You can add new transformations to any existing context type:

den.ctx.hm-host.into.foo = { host }: [ { foo = host.name; } ];
den.ctx.foo.conf = { foo }: ...;
den.ctx.foo.includes = [ ({ foo, ... }: ...) ];

The module system merges these definitions. You can extend the pipeline without modifying any built-in file.

Custom Context Flows

Each host has a mainModule option that defaults to:

(den.ctx.host { host }).resolve { class = "nixos"; }

You can override mainModule to use a completely alternative context flow, independent of ctx.host. Custom flows can be designed and tested in isolation — Den's CI uses a funny.names class that has nothing to do with NixOS to verify context mechanics independently.

What Happened to den.default?

den.default stays and is still useful for truly global settings. The issue was abusing den.default.includes as the context propagation backbone.

Internal Changes

Previously, all host, user, and home aspects had:

includes = [ den.default ]

Now they no longer include den.default directly. Including den.default explicitly is discouraged.

How Defaults Are Applied Now

Each context type transforms into default:

den.ctx.host.into.default = lib.singleton;  # passes { host }
den.ctx.user.into.default = lib.singleton;  # passes { host, user }
den.ctx.home.into.default = lib.singleton;  # passes { home }

den.default is now an alias for den.ctx.default. The data that flows into den.default.includes comes from these declarative transformations, not from direct aspect inclusion.

Best Practices

Instead of Use
den.default.includes = [ hostFunc ] den.ctx.host.includes = [ hostFunc ]
den.default.includes = [ hmFunc ] den.ctx.hm-host.includes = [ hmFunc ]
den.default.nixos.x = 1 den.ctx.host.nixos.x = 1

den.default remains the right place for values that genuinely apply everywhere — like stateVersion. Use context-specific includes for anything that belongs to a particular pipeline stage.