Usage examples: Fleet Template - vic/vix - Ren ports by LLM
Ren is a small surface language for aspect-oriented, context-driven Nix fleets. You declare your own domain (kinds and instances), and Ren puts each piece of configuration at the correct place in it. vic designed Ren and Den inspired it: if you know Den, you know the ideas, and Ren gives them a smaller grammar.
This README is the design. The fleet template is its executable example. The top part is a short tour. The specification and the reference parts are collapsed below it.
One root kind, one host, one channel, one class and one aspect. The full fleet is the template.
{ ren, lib, inputs, ... }:
{
ren.kinds.fleet.below = [ ren.kinds.host ]; # root kind: hosts are its children
ren.kinds.host.addr = lib.types.str; # a typed field; no default = required
ren.fleet.main = { };
ren.host.lb.addr = "10.0.1.1"; # an instance; one scope: fleet:main/host:lb
ren.channels.persist = {
fold = ren.lib.fold.set; # a receiver gets the emissions as a set
at = ren.kinds.host; # emitters: the host scopes
to = e: e.host; # receiver: the host that emitted
};
ren.classes.nixos = {
at = ren.kinds.host; # one nixos module system per host scope
into = { host, ... }: ms: { # ms: the nixos modules that landed at this host
nixosConfigurations.${host.name} = inputs.nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = ms;
};
};
};
ren.aspects.motd = {
at = ren.kinds.host; # a predicate: the scopes where the aspect is active
persist = { ... }: "/var/lib/motd"; # an emission into the persist channel
nixos = { persist, ... }: { # the folded pool arrives as a content arg
environment.etc.motd.text = "managed by ren";
environment.etc.persisted.text = lib.concatStringsSep "\n" persist;
};
};
}host in into is a binding: the scope binds each instance on its path under its kind name.
Here it is ren.host.lb. ms is the list of modules that the aspects landed at this scope.
The persist pool at lb is [ "/var/lib/motd" ].
One design, two twin implementations. They exist to measure Gen against nixpkgs.lib.
A difference between them must come from the library, never from the fleet.
- Ren is built only on Gen. The pinned rev is in ren/default.nix.
Ren is not the next Den. It checks that Gen can implement every Den capability.
ren/lib does not use nixpkgs. The realized configs still go through
lib.nixosSystemand friends. - Stimpy is built only on
nixpkgs.lib. It never uses Gen code. - Both run one fleet template.
ren/templates/fleetandstimpy/templates/fleetare the same tree. Onlyflake.nixandflake.lockdiffer (the inputrenorstimpy). - Ren knows nothing about hosts, users or NixOS. You declare your own domain. Ren wires it together.
All kind names in this README (
environment,host,mac) belong to the template, not to Ren.
Ren has few concepts. Each one has a full rule set in the specification.
- Kinds and instances: a kind is a typed record (
ren.kinds.host). An instance is one value of a kind (ren.host.lb). - Scopes: a scope is one instance at one place in the scope tree.
belowandspawnsmake the tree. - Classes: a class is a module system (
nixos,homeManager). Itsintoturns the modules of a scope into outputs or forwards them. - Channels: a channel moves data between scopes. Emitters write into it, and each receiver gets a folded pool.
- Aspects: an aspect is one concern. It holds class content and channel emissions.
at,includesandexcludesactivate it. - Predicates: a predicate selects scopes (
and,anyOf,not,when,within,has,is,same). - Folds: a fold says how a channel combines its emissions (
list,set,attrs,one).
flowchart LR
D["declarations<br/>kinds, instances, aspects,<br/>classes, channels"] --> T["scope tree<br/>below + spawns"]
T --> A["activation<br/>at, includes, excludes"]
A --> C["class content<br/>modules per scope"]
A --> P["channel pools<br/>folded emissions"]
P --> C
C --> F["forwards<br/>class to class"]
F --> O["outputs<br/>nixosConfigurations, packages, ..."]
P --> O
Each step reads only the steps before it. The evaluation order gives the exact strata.
Flakes:
outputs = inputs: inputs.ren.lib.mkFlake inputs (inputs.import-tree ./modules);Stimpy uses the same line with inputs.stimpy. Put one concern (aspect) in each file.
Directory names are for humans. Evaluation never reads paths. Paths with /_ are not loaded.
No flakes: (import ren-src { }).mkFlake inputs module, with locked inputs.
flake-parts: import inputs.ren.flakeModules.default and set the option fleet = inputs.import-tree ./modules;.
All entry points are in the specification.
modules/
topology/ fleet, environments, machines, access, channel
platforms/ nixos, darwin, droid, home-manager, microvm, os, checks
hosts/ lb, inventory (web-1, web-2), web-2, stage, ci, studio, pixel
users/ alice, bob
features/ web, deploy, dns, sync, kernel, monitoring, ...
Run all checks at the repository root:
nix-shell -p just --run 'just ci'
just twinchecks that both templates are the same tree:diff -r --exclude=flake.nix --exclude=flake.lock ren/templates/fleet stimpy/templates/fleet.just renandjust stimpyrun one implementation: itsjust ci(treefmt check + nix-unit) and itsjust e2e(realized configs from the templateflake.lock).- The template tests are the same for both. The lib suites differ: ren/templates/ci, stimpy/tests.
- In your own fleet,
evalFleet inputs modulereturns the RESULT for tests. See entry points.
Evaluation order: the four strata
Ren evaluates in four strata, numbered 0 to 3. A stratum reads only the strata before it. The rules below use these numbers.
| stratum | computes | reads | a cycle inside it is |
|---|---|---|---|
| 0 scopes | below/spawns tree, is, parent, within, has K |
instances, same, when on bindings |
an instance twice on a path is an error (see kinds) |
| 1 activation | at, includes, excludes, aspect refs |
stratum 0 | an error "not stratified" through not/excludes |
| 2 content | aspect values, channel folds, is P x |
strata 0-1, pools (lazy) | Nix infinite recursion, never a wrong value |
| 3 classes | into: forwards, outputs; channel into |
strata 0-2; outputs of classes this branch does not forward into |
an error from a static check on one graph of classes and channels |
Stratum 3 edges: a K branch returns channel key c (K to c) or class key K' (K to K'). A channel into returns class key K (c to K).
Keys are probed without forcing ms, the pool or outputs. The error names the cycle: ren: stratum 3 cycle: class `K` -> channel `c` -> class `K` .
Vocabulary: the 22 reserved words
Reserved words (22). Ren uses a Gen term only where Gen means the same thing.
| group | words |
|---|---|
| keys | at into below spawns is to fold includes excludes |
| predicates | and anyOf not when within has is same |
| folds | list set attrs one |
| bindings | parent outputs |
at means "which scopes" on every noun. A kind, instance or aspect ref is also a predicate.
Two words have two meanings. Both are tracked in #68, which proposes a rename.
isis a key and a predicate. As a key,is = Kin a kind makes a subkind (see kinds). As a predicate,is P xtests the scope ofxon the current path (see predicates).whenis a predicate and a kind gate. As a predicate,when ftests the scope bindings. In a function kind,{ db, region }: when (…) { }decides which args make an instance.
Kinds: typed data, child kinds, subkinds
- A kind is typed, strict data. Every field has a type. An unknown or missing field is an error. Freeform is not allowed.
below = [ K … ]lists the child kinds. This is gen-scope's direction: the parent lists its children.belowmay form a cycle (recursive kinds); an instance appears at most once on a path.spawns.<K> = s: { <name> = <instance>; }picks the K children of scopes. The default is every instance of K.- A
spawnskey that is not inbelowis an error. - A root kind has a non-empty
below, and nobelowlists it. Each of its instances is a root scope. - Any other kind that no
belowlists has no scopes. It is data (channel,group,db,region) or abstract (machine). is = Kmakes a subkind. It matches K and has K's fields.belowandspawnsare not inherited.- A function kind
{ db, region }: when (…) { }takes instances as args.whendecides which args make an instance. - One kind in several files merges:
belowconcatenates (no duplicates),spawnsmerges by child, fields must agree. - A field must not have the name of a module key (
key,imports,config, …), an instance internal (name,kind, …),liborpkgs.
Scopes: instances at places in the tree
- A scope is one instance at one place in the tree. One instance can have many scopes (alice has eight).
- An instance appears at most once on a path. A second time is an error.
- A scope binds each instance on its path under its kind name and every superkind name (through
is). The nearest one wins: atci,hostis ci. - A scope also binds
parent, the arg fields of a function kind, and every channel pool it receives.
Instances: names, refs, data
- An instance is
ren.<kind>.<name>. A name is unique across a kind family:ren.host.ciandren.guest.citogether are an error. - A ref field takes a ref, never a string.
environment = "prod"is refused. ren.lib.data xgives the kind fields of x alone, so x can go into a module option.ren.<kind>exists for every declared kind. It is empty when the kind has no instances.
Classes: module systems, into, forwards
- A class is a module system:
{ at; into; }, or a list of disjoint branches. Overlapping branches are an error. - A class without
atmatches every scope. into = { <bindings>, <channels>, outputs, ... }: ms: { … }gets the modules that landed at the scope.- A returned key that names a class forwards
msto the nearest ancestor-or-self scope with a branch of that class. No such scope is an error. - A returned key that names a channel is an emission at that scope. The scope must match the channel's
at. - The class's own name, and any other key, is a top-level output. Outputs merge by the
attrslaw. - Forwards chain (class to class to class). A forward cycle is an error.
- Class content gets the
libof its own module system (for examplelib.hmin home-manager), not Ren's. msare modules ({ imports = [ fn ]; _file; }).- An
intothat writes into a config-only submodule (users.users.<n>) usesren.lib.flatten args ms.flattendoes three things:- It calls each content function with
args.argsare the module args of the module that holds the write. If content needspkgs, namepkgsthere. - It drops
imports,_fileandkey. An empty remainder adds no definition, so list content (for example alistOf packageoption) works. - It returns one
mkMergevalue.
- It calls each content function with
- Class names and channel names are disjoint.
Channels: emitters, receivers, pools
- A channel is data:
{ fold; type; at; to; into; }. In gen-aspects a class key is a module, and a channel key israw. atgives the emitter scopes. Noatmeans any scope, the root too.to = e: <predicate>gives the receivers of an emission from scopee.type(optional) is alib.typestype. Each emission is checked when it is emitted. The error names the emitter scope, the aspect and the channel.into = { <bindings>, ... }: pool: { … }runs once per receiving scope with at least one emission.- An
intokey that names a class forwards as class content, like a class forward. A key that names another channel is an error that names both channels. Any other key is a top-level output.
Aspects: activation, includes, content
- An aspect is a unit of configuration.
atactivates it. An aspect withoutatfires only where it is included. includesandexcludestake refs to declared aspects only. An inline aspect or a string is an error.includesandexcludeswork in the aspect's scopes and their subtrees.excludeswins overincludes.- An aspect lands once per scope. This is also true when several include paths reach it (a diamond), or when includes and its own
atreach it. - The identity of an aspect is its declared path (gen-aspects
key, oneid_hash). An include is a reference, never a copy. - Example: a host and its N users all include one aspect. The aspect lands once on the host and once per user.
- A key that names a class is config. A key that names a channel is an emission. Any other key is an error.
- A key of an active aspect that lands in none of its scopes is an error.
- One aspect in several files merges like a module option: class content merges as modules,
includes/excludesconcatenate,atand channel values must agree. - Content is a module, or a function with an attrset pattern (
{ ... }: v). A barefinal: prev:is an error: wrap it. - Content args are bindings, channel pools and module args. A function gets only the args it names as formals, also with an
@pattern. - A binding or pool name that clashes with a module arg is an error.
- Content never reads realized config. Only
intoreads it, throughoutputs. - A check of realized config is a class
intothat readsoutputsof a class it does not forward into, and forwards a derivation intochecks.drvs.
Predicates: how to select scopes
- Refs: a kind ref matches scopes of that kind or a subkind. An instance ref matches the scopes of that instance. An aspect ref matches scopes where that aspect is active.
andtests left to right and stops at the first false. So a kind test can guard a field read:and [ ren.kinds.host (when (r: … r.host …)) ].anyOf,not. Ren never exportsany: gen-select makes it a tombstone, and it clashes withlib.any.when fsees the scope bindings only.within P: a proper ancestor matches P.has P: a child matches P.same a bis identity (gen-schemaid_hash). It needs no scope, so it works on data kinds and insidespawns.is P x: the scope of x on the current path (nearest ancestor-or-self) matches P. No such scope is an error.nearest K s: the instance of the nearest ancestor-or-self scope ofsthat matches K (a kind ref followsis: a guest is a machine), or null.smust be a scope.upTo K eisand [ K (anyOf [ e (has e) ]) ]: e itself if it is a K, and the K that e is a child of. A guest in a host gives both.- Aspect refs are stratified. A cycle through
notorexcludesis an error ("not stratified").
Folds: how a channel combines emissions
list: in scope-path order, never in attribute or include order.set: no duplicates.attrs: recursive merge. Equal leaves merge. Different leaves are an error.one: exactly one emission. Zero or two is an error.
Helpers: values that fleet files share
ren.helpers.<name>is a value that several fleet files share (lazyAttrsOf raw: one name, one file). It can be a predicate, a function or a type.ren.lib.opt t dismkOption { type = t; default = d; }, andren.lib.maybe tisopt (nullOr t) null. Kind files use them for fields with defaults.- The fleet adds exactly two module args,
renandinputs. A shared helper never goes in_module.args. A helper for one file is a privatelet.
Entry points: flakes, no flakes, flake-parts, tests, RESULT
- Flakes:
ren.lib.mkFlake inputs module. - No flakes: a plain
importwith lockedinputs. - flake-parts:
flakeModules.default, optionfleet. - Plain
lib.evalModules:lib.flakeModule, optionfleet. Ren also accepts Gen modules there. - Tests:
evalFleet inputs modulereturnsscopes active landed pools emitted branches outputs forwarded channels forwardsin both twins.channelsisname -> { emitters = [ id ]; receivers = emitter -> [ id ]; }.forwardsis[ { class; from; to; } ](class forwards and channelintoforwards). - Inside the fleet:
ren.lib.resultis the same RESULT withoutoutputs. It is lazy and read-only. Read it in aninto(stratum 3). Deciding kinds, activation oratfrom it is a cycle: a Nix eval error, never a wrong value. - Ren only:
ren.lib.inspectis a gen-inspect inspector over the scope graph (edgeschild,<class>,<channel>):.render.mermaid,.query,.select,.why. The template never uses it. See debugging.
The fleet walkthrough and the reference tables.
Fleet walkthrough: each template file and the rule it shows
Each entry says what the file shows and which rule it exercises. Links point at the Ren copy. The Stimpy copy is identical.
optandmaybe(kind field types) are part of Ren:ren.lib.opt,ren.lib.maybe(see helpers).- topology/fleet.nix: the root kind
fleet. It is a root kind because itsbelowis not empty.spawnsdefaults to every instance.systemscopes carry per-system outputs. - topology/environments.nix: parent-side
spawns.inEnvusessamebefore any scope exists (stratum 0). L31-L42:staginginherits the settings ofprodthrough a ref field. - topology/machines.nix: the predicate helper
metaland the abstract kindmachine(nobelowlists it). L27-L33:basereads theoverlayspool as a content arg. - topology/access.nix: a group closure with
sameon data kinds. A user lands on a machine when its environment grants a group in the user's closure. alice is inops,opsis inadmin, andprodgrantsadmin. - topology/channel.nix:
channelis a data kind of flake inputs.pkgsOfreads pkgs from any instance withchannelandsystem.
- platforms/nixos.nix: two disjoint branches. The metal branch builds the system and returns
colmenaandage-manifestkeys. These keys name channels, so they are emissions. The guest branch forwards into the host'smicrovm.vms. L52-L62:hostspawns users and the guests that run on it. - platforms/microvm.nix:
guest is hostdeclares its ownbelow, so no guest runs in a guest. L24-L30:hasselects hosts with a guest child. - platforms/darwin.nix:
homeDarwinis a sub-class, forwarded in place intohomeManager. L40-L43:tilingisatevery user, but its key lands only wherehomeDarwinhas a branch. - platforms/os.nix: one class key for either OS. Each branch forwards.
- platforms/home-manager.nix: one class, four homes, picked by
within. L56-L61: a bundle; each key lands where its class lives. L62-L77:hm-stateandhm-standaloneset what home-manager requires (stateVersion;usernameandhomeDirectoryin standalone homes). - platforms/droid.nix:
phonespawns only the users withphone = true. - platforms/checks.nix: a class that writes its own name. Each content key is one
runNixOSTest;drvs.<name>is a ready derivation.
- hosts/lb.nix: an activator. It is
atone instance andincludesincluded-only aspects. - hosts/inventory.nix: hosts from _inventory.json. Names become refs. A string where a ref belongs is refused.
- hosts/web-2.nix: substitute =
excludes+includes. - hosts/stage.nix: a host on the
edgechannel, with a desktop.syncreadsdesktopwithis. - hosts/ci.nix: a guest on stage, with its own
stablechannel. - hosts/studio.nix, hosts/pixel.nix: a mac and a phone in staging.
- users/alice.nix: a host-qualified aspect (Den
tux@igloo). - users/bob.nix:
has ren.user.bobreads the same spawns result that put bob there. It cannot drift from the grant rule.
ren.aspects.docker = {
at = and [ ren.kinds.host (has ren.user.bob) ]; # the hosts bob lives on
nixos.virtualisation.docker.enable = true;
persist = { ... }: "/var/lib/docker";
};- features/web.nix: a channel
tobuilt fromand,when,sameand an aspect ref. The kind test guardsr.host. L39-L45: an aspect ref inat. L48-L60: a VM check. L63-L70: the activator. - features/headless.nix: class
noGuireads lb's realizedenvironment.systemPackagesthroughoutputsand forwards arunCommandcheck intochecks.drvs. Content is data: host name to forbidden package names. - features/deploy.nix:
fold = one,tagsas a plain emission, and thecolmenachannel withinto. L33-L47: one aspect emits into two channels. - features/dns.nix: a value-gated class (
when). L26-L27:basegets a second key from another file. The two definitions merge. - features/sync.nix:
devicesthroughparent. L41:is ren.aspects.desktop parentin content. L38-L40: a placeholder device id. A real fleet keeps the id in a kind field. - features/kernel.nix: the shared type helper
settingsT. L31-L36: the nearest non-null setting wins. - features/monitoring.nix: a function kind. L18-L23: a typed channel.
- features/impermanence.nix:
to = e: e.hostneeds ahostbinding. Users under mac, phone and home have none, soatkeeps only the users within a host.
at = anyOf [ ren.kinds.host (and [ ren.kinds.user (within ren.kinds.host) ]) ];
to = e: e.host;- features/flake-inputs.nix: a channel
intothat returns a class key. It forwards to theflake-fileclass at the root. - features/packages.nix: per-system outputs through a channel
into.tools.packagesis data, not a module. - features/secrets.nix, features/vars.nix: fleet-wide collectors with
to = _: ren.fleet.main. A channel withoutatalso takes root emissions. - features/overlays.nix: a function value is wrapped in
{ ... }:. - features/dev.nix:
anyOfover two kinds.osusesenvironment.systemPackages, which exists in NixOS and nix-darwin. - features/motd.nix:
environment.etc.motd.textexists in both OSes.motd-shortis included only. - features/ssh-agent.nix:
within machinekeeps it out of standalone homes. - features/facter.nix: config gated on a file that exists.
Expected results: what must and must not appear, with tests
T = template tests, E = e2e tests, L = lib tests (Ren, Stimpy). Test names drop the test- prefix.
| row | what appears | what must not appear | tests |
|---|---|---|---|
| fleet docs from the result (#48) | docs.mermaid lists every scope id and the forward edges, for example guest:ci --> |
nixos | host:stage |
| fold one | deploy.nodes.lb.hostname = "10.0.1.1" |
two deployment emitters on one host |
T deployment-lb-address, error-two-deployment-emitters, E fold-one |
| fold list order | lb's haproxy lists web-1, then web-2 | order from attribute or include order | T backends-lb-exact, E fold-list-order |
| guest channel | ci (stable) built with stable pkgs inside stage (edge) |
ci built with the pkgs of stage | T guest-ci-channel-stable, E guest-ci-stable-pkgs |
| chained forward | bob's home in ci, in ci's nixos, in stage's microvm.vms.ci.config |
bob's ci home in stage's own home-manager.users |
T forwarded-stage-not-ci-users, E chained-forward-ci-bob |
darwin + os |
darwinConfigurations.studio: hostName, motd, git, dock; alice and bob homes |
studio in nixosConfigurations or colmena; kernel or impermanence on studio |
T outputs-nixos-no-studio-no-ci, kernel-not-on-studio, E darwin-dock, darwin-hm-users |
| standalone home | homeConfigurations.alice-laptop (edge HM): git, syncthing |
ssh-agent in alice-laptop | T ssh-agent-not-standalone, E home-edge-hm, home-no-ssh-agent |
| host-qualified | htop in alice's home on web-1 only | htop elsewhere, or for bob | T htop-only-alice-web1, landed-hm-htop-web1-only |
| substitute | web-2 motd "ren"; others "managed by ren" | both motd values on web-2 | T landed-os-web2-short, E substitute-web-2 |
has |
docker on stage and ci (bob lives on both) | docker on prod hosts | T docker-active |
parent |
alice's syncthing devices = her other parents | branching on host ? null |
T devices-alice-lb, devices-excludes-own-parent |
| per-system | packages.<3 systems>.hello |
packages keyed by host | T tools-three-systems, E packages-hello |
| overlays | every machine has the helix overlay | the overlay twice on ci | T overlays-ci-once, E overlays-helix-every-machine |
impermanence (set) |
stage persists the nginx, docker and both syncthing dirs, once each | ci's docker dir on stage; a duplicate | T persist-stage, persist-ci-has-own-docker, E persist-no-dups |
| name uniqueness | — | ren.host.ci and ren.guest.ci both |
T error-host-ci-duplicates-guest-ci |
| output merge | equal leaves merge | two scopes writing different values at one output | T output-merge-equal-leaf, output-merge-conflict |
| content-function rule | — | a bare final: prev: as content |
T error-bare-overlay-function, error-bare-class-function |
homeDarwin |
aerospace in alice's and bob's homes on studio only | aerospace in a linux, phone or standalone home | T landed-homedarwin-tiling, E aerospace-studio-not-lb |
| tags = emission | colmena tags for stage include web |
a tags key reserved by Ren |
T deployment-stage-tags-web |
| flake-file | flake-file.inputs.{agenix,microvm} once, from the root class |
an input of an inactive aspect; two different agenix urls | T outputs-flake-file-inputs, emitted-flake-inputs-only-active, error-agenix-url-conflict |
recursive attrs |
deploy.nodes and colmena merged from four host scopes |
a conflict between hosts on deploy |
T outputs-deploy-nodes, outputs-colmena-nodes |
| root emission | a flake-inputs emission at ren.fleet.main reaches flake-file.inputs |
root emissions dropped | T flake-inputs-root, emitted-overlays-root |
attrs in into |
packages.x86_64-linux from two aspects |
two different hello values |
T into-attrs-equal-leaf, into-attrs-conflict |
| per-branch stratum 3 | a branch reads outputs of a class it does not forward into |
a branch that reads outputs of its forward target |
L Ren, Stimpy. Template: features/headless.nix reads outputs.nixosConfigurations and forwards into checks |
parent-side spawns |
users on each machine from usersOn; alice on pixel and alice-laptop |
spawns.user on a kind without user in below |
T spawn-pixel-only-alice, error-spawns-user-without-below |
| no guest in a guest | — | a guest on a guest that makes a scope |
T extra-guest-on-guest-no-scope |
| identity without scopes | closure and granted compare groups with same |
a scope for a data kind | T data-kinds-have-no-scopes, spawn-lb-only-alice |
is P x on the path |
stage users' syncthing tray on | is that picks another scope; is on a bare instance |
T is-bare-instance-throws, E tray-stage-alice, tray-lb-alice |
anyOf |
ren.lib.anyOf |
ren.lib.any |
T anyOf-not-any |
channel into |
packages, colmenaHive, ageManifest |
tools.packages evaluated as a module |
T packages-pool-receivers, E channel-into-colmena-age |
channel into forward |
flake-file.inputs forwarded to the root class |
a forward with no target scope | T flake-file-forwarded-lands, L Ren, Stimpy |
| channel-to-channel key | — | a channel into that returns the key of another channel |
L Ren, Stimpy |
| class-channel cycle | — | class K emits c, and c forwards to K | L Ren, Stimpy |
| hosts from JSON | host:web-1, host:web-2 under prod |
environment = "prod" (a string for a ref) |
T inventory-hosts-scoped, inventory-string-ref-refused, E inventory-web-1-built |
| generated values | vars.shared.ca; vars.per-machine.<metal>.deploy-key |
entries for ci or studio; values made at eval | T vars-shared-ca, vars-per-machine-metal, E vars-plan |
| VM checks | checks.x86_64-linux.web with nodes client, web |
a check on another system | T checks-names, E checks-web-nodes |
| realized-config check (#60) | checks.x86_64-linux.lb-no-gui reads lb's realized environment.systemPackages; its script is touch $out |
firefox on lb: the same check's script ends in exit 1; a check that names inputs.self |
T checks-names, E check-no-gui-lb, check-no-gui-lb-firefox-fails |
buildHost |
"10.0.1.1" for web-2; null for lb, web-1, stage |
a record for ci or studio | T buildhost |
| facter | active on lb, web-1, web-2, stage | reportPath without a report file |
T facter-metal, E facter-no-report |
channel type |
lb's scrape pool holds r1's { job; target; } |
a bad emission; an error without scope, aspect and channel | T typed-scrape-good-emission-passes, typed-scrape-nested-bad-field-refused |
| diamond | nginx reached through web and status lands once on web-1 and stage; lb's haproxy names web-1 once |
nginx's content twice in one scope; web-1 twice in a list fold or in haproxy's config | T diamond, E diamond |
| merge across files | base with keys from two files |
— | T base-merged-across-files |
| helpers | every shared helper in ren.helpers |
a helper in _module.args |
T helpers-in-ren-helpers, helpers-not-module-args, opt-maybe-in-ren-lib |
| instances as data | ren.lib.data x in a module option |
a field named key, or an internal |
T data-into-option, field-key-refused |
| inline include | a ref include lands | an inline aspect in includes |
T error-inline-include |
class lib |
lib.hm in home-manager content |
Ren's lib in class content |
E class-content-sees-class-lib |
| not stratified | — | a cycle through not/excludes |
L Ren, Stimpy |
| recursive kinds (#49) | r:y/c1:a/c2:x/c1:b from a c1/c2 cycle in below |
an instance twice on one path: ren: instance a:x twice on path r:y/a:x/a:x |
L Ren below-cycle-nested-ids, twice-on-path, Stimpy below-cycle-nested-ids, twice-on-path |
nearest (#57) |
bob on ci gets guest:ci as machine, environment:staging as environment; null at the root |
stage for bob on ci; a value for a bare instance | T nearest, L Ren, Stimpy |
upTo (#58) |
a user on stage reaches stage; stage reaches itself; ci reaches ci and stage | a user's host for its sibling host | T upTo-machine, L Ren, Stimpy |
flatten (#59) |
an account class writes users.users.alice on lb: content fn with the class lib plus a plain value |
"The option `users.users.alice.imports' does not exist" | E flatten, L Ren, Stimpy |
Debugging: why is aspect X active at scope Y? Real error messages
Which scopes is aspect X active at? Read the RESULT. In both twins, evalFleet inputs module returns it (see entry points).
r.active.<aspect> lists the scope ids. r.landed shows what landed where. r.pools shows what each receiver got.
Example, from the example above: r.active.motd is [ "fleet:main/host:lb" ].
Why is an edge in the scope graph? Ren only. ren.inspect r (inside a fleet: ren.lib.inspect) is a gen-inspect inspector over the scope graph.
(ren.inspect r).why "top:root:r/box:b:root:r"
# => { kind = "declaration"; site = "relations.top.root:r/box:b"; }- The argument is an edge key:
<label>:<from id>:<to id>. The label ischild, a class name or a channel name. - The answer names the declaration that made the edge. A key that is no edge is an error from gen-inspect.
.whyexplains edges, not activation. It does not say whichat,includesorexcludesmade an aspect active. User.activeand readatfor that.- Tests: inspect.nix (
test-inspect-why,test-inspect-why-unknown-edge). Other inspector calls:.render.mermaid,.query "SELECT …",.select.
Error gallery. These are Ren's messages, copied from ren/lib. <…> marks a name that Ren fills in. Stimpy words some of them in its own way.
| message | cause |
|---|---|
ren: aspects not stratified: <atoms> |
An aspect at, includes or excludes forms a cycle through not or excludes. |
ren: aspect `<a>`: key `<k>` lands in none of its scopes |
The aspect is active, but no scope has a class branch or channel for that key. |
ren: aspect `<a>`: key `<k>` is neither class nor channel |
A key of an aspect names no declared class and no declared channel. |
ren: aspect `<a>`: includes[<i>] is not a ref to a declared aspect (inline aspects are not supported, see #21) |
An includes element is an inline aspect or a string. Use ren.aspects.<name>. |
ren: bare function emission; wrap it in `{ ... }:` |
Content is a bare function such as final: prev:. Give it an attrset pattern. |
ren: instance <id> twice on path <path> |
A below cycle reached the same instance a second time on one path. |
ren: name `<n>` used by both kind `<k1>` and kind `<k2>` |
Two kinds of one family share an instance name (ren.host.ci and ren.guest.ci). |
ren: class `<c>` at <id> forwards to `<k>` but no scope has a `<k>` branch |
An into returned a class key, and no ancestor-or-self scope has a branch of that class. |
ren: class `<c>` reads `outputs.<o>` produced by a class it forwards into (stratum 3) |
An into reads the outputs of its own forward target. See evaluation order. |
ren: channel `<c>` fold attrs: conflict at `<path>` |
Two emissions set different leaves at one path of an attrs channel. |
Gen mapping: which Gen part each Ren construct uses
What ren/lib calls today. The table follows the output of rg -o 'gen\.[a-z]+' ren/lib | sed 's/.*://' | sort -u.
Stimpy calls no Gen code. The Gen rev is pinned in ren/default.nix.
| Gen part | called | Ren construct | status |
|---|---|---|---|
gen.aspects |
keyCategory, hasClassContent, aspectsType (view.nix) |
class and channel keys, aspect typing | direct |
gen.assemble |
mkId (scopes-node.nix, kinds-check.nix) |
scope and instance ids | direct |
gen.bind |
wrap (content.nix) |
content args = bindings | direct |
gen.graph |
ancestorsOf, reachableFrom, cyclePaths (kinds.nix, strata.nix) |
is chains; the stratum 3 cycle check |
direct |
gen.inspect |
fromGraph (inspect.nix) |
ren.lib.inspect (Ren only) |
direct |
gen.merge |
evalModuleTree, types, moduleSyntax (gen-lib.nix) |
typed strict kinds | glue (not gen-schema registries) |
gen.prelude |
list and attr helpers | lib without nixpkgs; gen-lib.nix shims seven helpers |
gap (sini/gen-prelude#1) |
gen.program |
program, model (activation.nix) |
aspects, includes, excludes as rules; Gen has no exclude |
glue |
gen.schema |
evalSchema, mkValidator (kinds.nix), mkInstanceRegistry (kinds-check.nix) |
same (identity hash); kind validation |
direct |
gen.scope |
mkKinds, eval, folds.list, folds.one (scopes.nix, folds.nix) |
scope tree; folds list/one. Spawned ids are minted bare@parent |
glue: kinds become depth levels (sini/gen-scope#2) |
gen.scope |
no dedup set fold; mergeAttrs is shallow |
folds set/attrs |
gap (sini/gen-scope#2) |
gen.select |
when, matches, subkind, entity, adapters (predicates.nix) |
and anyOf not within has; when f (1-line adapter); refs and is |
direct. kind/subkind/entity need gen-schema kinds, so refs are glue |
gen-delivery |
not called | classes, forwards, channel into |
gap (#6) |
| importing Gen | import gen { } |
— | about 20s of the test run (sini/gen#2) |
Den coverage: every Den pattern and its Ren form
Every user-facing pattern in Den's docs, templates, CI tests, issues and Matrix log. Out of scope: cross-flake namespaces (gen-link), angle brackets, import-tree, diagrams, denTest, nh apps.
| Den pattern | Ren | status |
|---|---|---|
| aspect class bundle | one aspect with several class keys (hm-module) |
covered |
includes, nested aspects, glob of children |
includes = [ … ]; lib.attrValues ren.aspects.<group> |
covered |
| inline fn include | an includes element must be a ref (#35) |
refused for now |
parametric { host, user }:, arg-shape gating |
content args = bindings; activation is at, never the arg shape |
covered |
| misplaced entity arg is silently inert | a content arg with no binding is an error | refused |
excludes, substitute, filterBy, .global |
excludes (subtree); substitute = excludes + includes; global = at the root |
covered |
| exclude by string; exclude that removes nothing | refs only; a warning | covered |
tux@igloo, provides.to-users, provides.to-hosts |
and + within / has predicates |
covered |
hasAspect, .forClass, includeIf, host-aspects |
an aspect ref in at (stratified); is P x in content |
covered |
hasAspect in includes recurses |
a cycle is a named error | refused |
den.hosts, den.homes, schema, custom kinds |
ren.kinds.* + instances; always strict |
covered |
| freeform schema | — | refused |
den.schema.<kind>.includes, den.default |
aspect at = ren.kinds.<kind> or anyOf |
covered |
policy.resolve, enrich, relationship fan-out |
below + spawns; a kind field; function kinds |
covered |
policy.include/when/for, mkPolicy, shared resolve |
at predicates; no policies |
covered |
policy.provide/route/deliver/forward, hmPlatforms |
class into returning a class key; a sub-class in place (homeDarwin) |
covered |
policy.instantiate, intoAttr, deliver modes |
into returns top-level keys: imports, a path, or a sub-eval |
covered |
| homeManager content at host scope is inert | landing nowhere is an error | refused |
isolated entity, collectSubtree |
guest kind + guest branch; content lands only in its own scopes | covered |
| chained forward classes | forwards chain, acyclic | covered |
quirks, pipes (collect, broadcast, to, withProvenance) |
channels: at, to predicates, fold; provenance in the value |
covered |
quirk { config, ... }: thunk, cross-host config |
content never reads config; into reads outputs (stratum 3) |
refused |
| quirk named like a class | class and channel names are disjoint | covered |
diamond: mixed aspect via N users to one host ("defined multiple times", den#468 <class>@<identity> dedup) |
one atom per (aspect, scope): lands once per scope; a host class key never lands at a user scope | covered |
| identical values merge, different values error | the attrs law |
covered |
| one aspect in several files | merges like a module option | covered |
| schema on quirk values | channel type |
covered |
settings, reservedKeys, mkDefault |
settingsT per kind; no default = required |
covered |
collision policy den-wins/class-wins |
a name clash with a module arg is an error; no knob | covered |
| batteries (hostname, define-user, unfree, wsl, …) | ordinary aspects shipped by a library | covered |
custom classes (os, hjem, maid, devshell, roleClass, nixClass) |
ren.classes; a class list with branches |
covered |
flake outputs per system, flake class |
system kind + packages channel into |
covered |
| per-aspect flake inputs | flake-inputs channel + flake-file class |
covered |
| ACL environments, recursive groups | group kind + closure + granted |
covered |
| overlays, unstable channel, patched nixpkgs | channel kind; overlays channel |
covered |
standalone home, user@host home, home with osConfig |
home kind; the standalone branch reads outputs |
covered |
| microvm, terranix, k8s, nix-on-droid | guest kind; terraform class (when); droid class |
covered |
| impermanence collector | persist channel, fold = set |
covered |
| colmena, deploy-rs, tags | deployment channel (one), tags channel (set) |
covered |
| checks, secrets manifest, generated vars | checks class; age-manifest and generators channels |
covered |
meta, fixed-point config self-ref |
Nix let/rec in the file |
covered |
| noflake, flake-parts, den as a library | entry points; Ren has no domain names | covered |
| specialisations, variants | a variant kind + a class that forwards to specialisation |
covered (not shown) |
| deterministic ids, ports, IPs | a let allocator over sorted names (stratum 0) |
covered (not shown) |
| patch a third-party aspect before merge | an override form | change needed |
nested aspect groups without provides |
a rule that tells a group from an aspect | change needed |
| use Ren aspects outside Ren | ren.lib.moduleOf aspect scope |
change needed |
| priority by specificity | instance ref > kind ref, mapped to mkOverride |
change needed |
| explain/trace resolution, option docs, diagrams, LSP | tooling over the strata data | change needed |
| host users declared inside an aspect | existence is spawns, stratum 0 |
refused |
| real-host config from a standalone home | a home has no host scope | refused |
Diamond safety: why an aspect lands once per scope
Den's hardest bug was the diamond. drupol's review shows the shape, and the follow-up explains the fix (den#468). In the shape, a host has users alice and bob. A mixed aspect base (nixos + homeManager) goes to both users, and each user forwards its nixos part back to the one host. The host then gets the same nixos content once per user: boot.kernelPackages is defined multiple times while it's expected to be unique. Den fixed it with a <class>@<identity> dedup key, and anonymous aspects stay outside that fix.
Ren and Stimpy are safe by construction. No dedup pass is needed:
- Identity is the declared path. An aspect exists only as
ren.aspects.<name>. gen-aspects gives it one key and oneid_hash, whichever include path reaches it. An include is a reference edge, not a copy. - No anonymous aspects. An
includeselement must be a ref. An inline aspect is refused (see aspects), so nothing can lack an identity. - Activation is a set, not a walk. Each (aspect, scope) pair is one activation atom. A second include path, or an
atmatch as well as an include, only adds another reason for that atom to be true. Content lands once per active atom. - The den#468 shape is measured, not argued. An aspect active at every user of
stagelands itsnixoscontent once on stage and never at a user scope (test-diamond-host-and-users).
Evidence:
- A probe ran both twins on 7 variants. The results were identical. The variants: plain diamond, includes plus
at, the den#468 host-and-users shape, an include cycle, two include levels, a users-only include (an error, never a duplicate), and channel emissions from many scopes. - The probe measured each variant against a
uniqoption, a list option and a list-fold channel. - The template carries a diamond on purpose:
statusincludesnginx, whichwebalso reaches. Tests intests/diamond.nix(tier 1) andtests-e2e/diamond.nixassert single landing, including the den#468 shape on stage. - Control: landing once per include path, instead of once per scope, makes the tier-1 diamond tests fail in both twins (1/4), and the e2e haproxy test fails.
Open questions
- Parent-side
spawnsspreads "where users come from" over five kinds.usersOnkeeps the rule in one place. A child-side form needs new words (under,picks). - Channels with
into, or a third noun for data outputs? A third noun is clearer, but adds a word. parentor the kind name?parentis for code that is generic over parent kinds. Bindhostwhen you mean the host.- Overlays on guests. Does microvm's
vms.<n>.pkgscompose with the guest'snixpkgs.overlays? Not checked. - Not shown: per-user classes (hjem, maid), wsl. Each is a class plus a predicate.
excludesis not a Gen term. Gen has no exclude, and nothing in Gen removes an existing include.
Gen-side issues: #6 realize classes with gen-delivery, #8 smaller hand-rolled Gen primitives, #13 ordered children and ordered kind ancestors.
