Skip to content

Context Pipeline

import { Aside } from ‘@astrojs/starlight/components’;

When Den evaluates a host configuration, data flows through a pipeline of context transformations. Here is the complete picture:

graph TD
  Host["den.hosts.x86_64-linux.igloo"]
  Host -->|"creates"| CtxHost["ctx.host { host }"]

  CtxHost -->|"conf"| HA["den.aspects.igloo<br/>(fixedTo { host })"]
  CtxHost -->|"into.default"| CtxDef1["ctx.default { host }"]
  CtxHost -->|"into.user<br/>(per user)"| CtxUser["ctx.user { host, user }"]
  CtxHost -->|"into.hm-host<br/>(if HM detected)"| CtxHM["ctx.hm-host { host }"]

  CtxUser -->|"conf"| UA["den.aspects.tux<br/>(fixedTo { host, user })"]
  CtxUser -->|"into.default"| CtxDef2["ctx.default { host, user }"]

  CtxHM -->|"conf"| HMmod["Import HM module"]
  CtxHM -->|"into.hm-user<br/>(per HM user)"| CtxHMU["ctx.hm-user { host, user }"]

  CtxHMU -->|"forward homeManager<br/>into host"| FW["home-manager.users.tux"]

  CtxDef1 -->|"includes"| DI1["den.default.includes<br/>(host-context funcs)"]
  CtxDef2 -->|"includes"| DI2["den.default.includes<br/>(user-context funcs)"]

Here is the concrete path from a host declaration to a final NixOS configuration:

# 1. The initial data is the host itself — nothing NixOS-specific yet.
aspect = den.ctx.host {
host = den.hosts.x86_64-linux.igloo;
};
# 2. ctxApply produces an aspect that includes den.aspects.igloo
# plus the entire transformation chain (users, HM, defaults).
# 3. We enter the NixOS domain by resolving for the "nixos" class.
nixosModule = aspect.resolve { class = "nixos"; };
# 4. Standard nixosSystem with the resolved module.
nixosConfigurations.igloo = lib.nixosSystem {
modules = [ nixosModule ];
};

This same pattern works for any class — replace "nixos" with "darwin", "homeManager", or any custom class name.

Den reads den.hosts.x86_64-linux.igloo and creates the initial context:

ctx.host { host = den.hosts.x86_64-linux.igloo; }

ctx.host.conf locates den.aspects.igloo and fixes it to the host context. All owned configs and static includes from the host aspect are collected.

ctx.host.into.default produces { host } for ctx.default, which activates den.default.includes functions matching { host, ... }.

ctx.host.into.user maps over host.users, producing one ctx.user { host, user } per user.

ctx.user.conf locates both the user’s aspect (den.aspects.tux) and the host’s aspect, collecting contributions from both directions.

ctx.user.into.default activates den.default.includes again, this time with { host, user } — functions needing user context now match.

ctx.host.into.hm-host checks if the host has users with homeManager class and a supported OS. If so, it activates ctx.hm-host.

ctx.hm-host.conf imports the Home-Manager NixOS/Darwin module.

For each HM user, ctx.hm-user uses den._.forward to take homeManager class configs and insert them into home-manager.users.<name> on the host.

ctx.host.into.hm-host does not always activate. It checks three conditions (see hm-os.nix):

  1. OS class is supported — the host’s class is nixos or darwin
  2. HM users exist — at least one user has class = "homeManager"
  3. HM module availableinputs.home-manager exists, or the host has a custom hm-module

All three must be true. Hosts without users, or with only non-HM users, skip the entire HM pipeline.

den.default (alias for den.ctx.default) is included at every context stage — once for the host context and once per user context. This means:

  • Owned configs and static includes from den.default can appear multiple times in the final configuration
  • For mkMerge-compatible options (most NixOS options), this is harmless
  • For list options, you may get duplicate entries

To avoid duplication, use den.lib.take.exactly to restrict which context stages a function matches:

den.default.includes = [
(den.lib.take.exactly ({ host }: { nixos.x = 1; }))
];

This function runs only in the { host } context, not in { host, user }.

For den.homes, the pipeline is shorter:

graph TD
  Home["den.homes.x86_64-linux.tux"]
  Home -->|"creates"| CtxHome["ctx.home { home }"]
  CtxHome -->|"conf"| HomeA["den.aspects.tux<br/>(fixedTo { home })"]
  CtxHome -->|"into.default"| CtxDef["ctx.default { home }"]

den.default is an alias for den.ctx.default. When you write:

den.default.homeManager.home.stateVersion = "25.11";
den.default.includes = [ den._.define-user ];

You are actually setting den.ctx.default.homeManager... and den.ctx.default.includes. This means den.default is a full context type — it has conf, into, includes, and owned attributes.

Host, user, and home aspects do not include den.default directly. Instead, 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 }

This means den.default is reached through the declarative context pipeline, not by direct inclusion. The data flowing into den.default is whatever the source context provides.

den.default is useful for global settings like home.stateVersion. However, prefer attaching parametric includes to the appropriate context type instead:

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

Each context type is independent and composable. You can:

  • Add new context types without modifying existing ones
  • Attach aspects to any stage of the pipeline
  • Create custom transformations for domain-specific needs
  • Override any built-in context behavior

The pipeline is not hardcoded — it’s declared through den.ctx definitions that you can inspect, extend, and customize.