Skip to content

Fixpoints and dependencies

In NixOS, config is a fixpoint: every module reads the final config, and Nix laziness computes each value when something reads it. A yen fleet is a fixpoint too, in two layers:

  • Declarations. Every module gets yen, which is computed from the declarations of all modules, its own too. A module reads the data of any file through yen, as a NixOS module reads config.x.
  • Results. An element reads the reduced value of a sink through an arg. That value can hold elements that read other sinks. Sinks feed sources and sources feed sinks; laziness closes the loop.

The order of the files and of the definitions never matters.

This fleet has two hosts. Each sink plays the part of one NixOS option:

modules/fleet.nix
# Two hosts. Each sink is one NixOS option: many files give to it, the engine reduces it.
{ yen, ... }:
{
yen.kinds.fleet = {
spawns.host = _: yen.host;
sinks.addrs.reduce = yen.lib.reduce.merge; # (1) a fleet-level sink: every host gives, every host reads
};
yen.kinds.host = {
fields.addr = yen.lib.is builtins.isString;
fields.fqdn = yen.lib.derive (yen.lib.is builtins.isString) (i: "${i.name}.lan"); # (2) a default from the instance
sinks = {
ports.reduce = yen.lib.reduce.set;
firewall.reduce = yen.lib.reduce.one;
settings.reduce = yen.lib.reduce.merge;
motd.reduce = es: {
# (3) the lowest priority number wins, like mkForce over mkDefault
value = (builtins.head (builtins.sort (a: c: a.value.prio < c.value.prio) es)).value.text;
errors = [ ];
};
peers.reduce = yen.lib.reduce.one;
};
};
yen.fleet.main = { };
yen.flake =
{ addrs, ... }:
{
inherit addrs;
}; # (4) the flake outputs read a sink too
yen.host = {
a.addr = "10.0.0.1";
b.addr = "10.0.0.2";
};
}

At (1), a sink of the fleet: every host gives to it, every host reads it. At (2), yen.lib.derive makes a default from the instance, as a NixOS default reads config. At (3), a reduce you write picks one value. At (4), the flake outputs read a sink too. The aspects give the elements:

modules/aspects.nix
# Each aspect is one NixOS pattern. None of them knows the files that read it.
{ yen, ... }:
{
yen.aspects = {
fw = {
at = yen.kinds.host;
firewall = { ports, ... }: builtins.sort builtins.lessThan ports; # (1) reads another option: config.ports
};
ssh = {
at = yen.kinds.host;
ports = 22;
};
web = {
at = yen.host.a;
ports = 80;
};
tls = {
at = yen.aspects.web; # (2) mkIf: active where web is
ports = 443;
};
base = {
at = yen.kinds.host;
settings.log.level = "info";
motd = {
prio = 1000;
text = "welcome";
};
};
tuned = {
at = yen.host.b;
settings.log.file = "/var/log/b";
motd = {
prio = 50;
text = "maintenance";
};
};
addrs = {
at = yen.kinds.host;
addrs = # (3) every host gives its address to the fleet
{ host, ... }:
{
${host.name} = host.addr;
};
peers = { host, addrs, ... }: removeAttrs addrs [ host.name ]; # (4) the other nodes, through the fleet
};
};
}

At (1), firewall reads the sink ports, as a NixOS option reads another option. At (2), at names an aspect, so tls is active where web is. At (3) and (4), each host gives its address to the fleet, and peers reads what the other hosts gave. This table maps each pattern to its name in this fleet:

NixOS yen Here
a list option many modules define a sink; each aspect gives an element ports
mkIf config.services.web.enable at refers to an aspect tls.at = yen.aspects.web
an option read from another option a sink arg firewall = { ports, ... }: …
a default computed from config yen.lib.derive fqdn
attrsets merged from many modules reduce.merge settings
mkForce and mkDefault a reduce that picks by priority motd
nodes.<other>.config give to a sink above both hosts, read it back addrs, peers
result
{
addrs = {
"fleet:main" = {
a = "10.0.0.1";
b = "10.0.0.2";
};
};
firewall = {
"fleet:main/host:a" = [
22
80
443
];
"fleet:main/host:b" = [
22
];
};
motd = {
"fleet:main/host:a" = "welcome";
"fleet:main/host:b" = "maintenance";
};
peers = {
"fleet:main/host:a" = {
b = "10.0.0.2";
};
"fleet:main/host:b" = {
a = "10.0.0.1";
};
};
ports = {
"fleet:main/host:a" = [
22
443
80
];
"fleet:main/host:b" = [
22
];
};
settings = {
"fleet:main/host:a" = {
log = {
level = "info";
};
};
"fleet:main/host:b" = {
log = {
file = "/var/log/b";
level = "info";
};
};
};
}

In NixOS, any module can read any option, so a dependency is a read hidden in a function. Nothing lists it, and “what changes if I change this?” has no answer before you evaluate.

In yen, an element reads only what reaches its scope: its own scope, the nearest scope of a kind, and the sinks of its receivers. A value goes from one scope to another only as a routed element, and each routed element is an edge of the graph, with the aspect that gave it. Here are the edges of the sink addrs. peers on a reads what b gave to the fleet:

result
[
{
from = "fleet:main/host:a";
origin = "aspect addrs";
sink = "addrs";
to = "fleet:main";
}
{
from = "fleet:main/host:b";
origin = "aspect addrs";
sink = "addrs";
to = "fleet:main";
}
]

Because the edges are the dependencies, these questions are queries, not a search:

  • result.impact scope { }: the scopes a scope’s elements reach, by depth. See History.
  • result.explain aspect scope depth: why an aspect is or is not active at a scope.
  • A grant limits the scopes and sinks a change may give to. It can check a change because every new dependency is a new edge. See Stage a change.
  • Labels: an element carries the trust of the files that declare its aspect. See Agent surface.

A read of any value of any scope would add dependencies that are not edges, and these answers would miss them. So yen has no such read. To read another host’s value, give it to a sink of a scope above both hosts, as addrs does.

NixOS resolves two values for one option with priorities: mkForce wins. yen never overrides data: two different values for one declared leaf are an error, and you keep one. For values that many files give, the sink’s reduce decides, as motd does above.

variants/override.nix
# A second, different value for one declared leaf. NixOS: mkForce wins. yen: two answers.
{ yen, ... }:
{
yen.host.a.addr = "10.0.0.9";
}
result
[
{
code = "two-answers";
path = [
"host"
"a"
"addr"
];
}
]
ContributeCommunitySponsor