Skip to content

The DOM

Nest’s DOM walk distinguishes between two kinds of attrset:

Namespace — an attrset without is. Acts as a container that passes scalar attributes down to child nodes. The namespace itself is not a node.

Node — an attrset with is = [ … ]. Becomes a DOM node with a path, parent, and traits.

nest.prod.system = "x86_64-linux"; # namespace attr — inherited by children
nest.prod.env = "prod"; # namespace attr — inherited by children
nest.prod.web = { # NODE — has is = [...]
is = [ nest.host nest.web ];
addr = "10.0.0.2";
};

The prod attrset has no is, so it is a namespace. Every scalar attribute in prod (system, env) flows down to web automatically.

When multiple sources define the same attribute, they merge in this order (lowest to highest):

  1. Inherited namespace attrs — propagated from ancestor namespaces
  2. Node own attrs — defined directly on the node
  3. Synthesized attrs — values derived from querying other nodes

A node’s own attribute always wins over what was inherited:

nest.prod.env = "prod";
nest.prod.web = {
is = [ nest.host ];
env = "override"; # wins over inherited "prod"
};
# web.env == "override"

To scope siblings, nest your environments under a parent node labeled with a marker trait.

nest.prod.router = { is = [ nest.cluster ]; };
nest.prod.router.web-1 = { is = [ nest.host ]; }; # __parentPath = "prod.router"
nest.prod.router.web-2 = { is = [ nest.host ]; }; # __parentPath = "prod.router"
# Now web-1 and web-2 are true siblings, scoped to router

Every node carries a context object used by selectors and rule functions. The context provides:

ctx.children # [ node ] — direct DOM children (parentPath == node.__path)
ctx.ancestors # [ node ] — all ancestors up to root, nearest first
ctx.allNodes # [ node ] — every node in the DOM
ctx.select # select API (see Select API reference)

Contexts are built from the fully synthesized node list — after trait expansion, neededBy injection, and synth runs. Selectors and rule functions always see the complete picture.

A node with only marker traits (no class) is still a valid DOM node. It has no output (processNode returns null), but it is fully visible to select:

nest.users.alice = {
is = [ nest.admin ]; # admin has no class — alice produces no nixosConfiguration
sshKeys = [ "ssh-ed25519 …" ];
};
# In a rule:
nest.rules = [{
is = nest.host;
synth = { select, ... }: {
node.children = map (u: { inherit (u) name sshKeys; is = [ nest.user ]; })
(select nest.admin); # finds alice
};
}];

This is how user registries work in Nest — define users as marker nodes, synthesize them as typed children on each host.

Trait’s synth and Rule’s synth can inject virtual children into a node. These children are indistinguishable from static DOM children once injected:

  • They get __path = "${parent.__path}.${child.name}"
  • Their traits are fully expanded (needs + neededBy)
  • Rules are matched and applied to them
  • collectChildFrags picks their class contributions up into the parent

See Traits for the full synth API.

Contribute Community Sponsor