Skip to content

den.quirks

Type: lazyAttrsOf pipeSchemaType

Default: {}

Registry of named data routes. Each key declares a quirk name that the pipeline classifies as pipe data rather than class modules. Quirk names must not collide with class names (den.classes) — the pipeline asserts this at evaluation time.

Each entry receives a name field matching its attribute name automatically.

den.quirks.firewall = {
description = "Firewall port declarations";
};
FieldTypeRequiredDescription
descriptionstryesHuman-readable description of the quirk

When the pipeline encounters a key on an aspect, classifyKeys dispatches to one of three branches:

Key matchesCategoryWhat happens
den.classesClass keyEmitted as class module
den.quirksPipe keyCollected as pipe data
NeitherUnregistered class keyFalls through to class (backwards compat)

Pipe keys flow through the same emit-classscopedClassImports infrastructure as class keys, but assemblePipes consumes them post-pipeline instead of wrapClassModule.

Any aspect can emit data on a quirk key:

den.aspects.nginx = {
firewall = { ports = [ 80 443 ]; };
};

Values can be attrsets, lists, or any Nix value. List values are auto-flattened: if a producer emits [a, b], each element is a separate entry in the pool.

Class modules receive quirk data by naming the quirk in their function arguments, alongside regular module args:

nixos = { firewall, lib, config, ... }: {
networking.firewall.allowedTCPPorts =
lib.concatMap (f: f.ports or []) firewall;
};

The quirk arg receives a list of all values collected in the current scope. If no producers emitted data, it’s []. Delivery uses the same wrapClassModule mechanism as entity context args.


All pipe constructors are accessed via den.lib.policy.pipe inside policy functions. Pipe effects are returned from policies alongside other policy effects (resolve, include, route, etc.).

den.policies.my-pipe = { host, ... }:
let inherit (den.lib.policy) pipe; in
[ (pipe.from "firewall" [ ... ]) ];

Create a pipe effect targeting the named quirk. pipeName can be a string or a quirk ref (den.quirks.firewall). stages is a list of pipe stages applied sequentially.

pipe.from "firewall" [
(pipe.filter (e: e.proto == "tcp"))
(pipe.transform (e: { port = e.port; }))
]

Transform stages modify the pipe data pool. They can be chained left-to-right.

Remove entries that don’t match the predicate. Config thunk markers pass through unchanged.

pipe.filter (e: e.proto == "tcp")

Input: [a, b, c]Output: entries where predicate returns true

Map each entry to a new value. Config thunk markers pass through unchanged.

pipe.transform (e: { label = "x-${e.name}"; })

Input: [a, b]Output: [fn(a), fn(b)]

Reduce all entries to a single value. The consumer receives a single-element list.

pipe.fold (acc: n: acc + n) 0

Input: [10, 20, 30]Output: [60]

Add a synthetic entry to the pool.

pipe.append { name = "default"; }

Input: [a, b]Output: [a, b, { name = "default"; }]

Replace the entire list. The function receives the full list and returns a new list. At most one pipe.for per pipe per scope — multiple pipe.for on the same pipe throws an error.

pipe.for (vals: lib.reverseList vals)

Input: [a, b, c]Output: [c, b, a]


Routing stages control where data flows. They are terminal — no transform stages should follow them.

Push pipe data from child scope to parent scope. Used to flow user data up to the host level. Transform stages before pipe.expose are applied first.

pipe.from "prefs" [
(pipe.filter (p: p.enabled))
pipe.expose
]

Exposed data merges with the parent scope’s own local pipe data. Multiple children expose into the same parent, and their data is concatenated.

Harvest pipe data from sibling scopes matching the predicate. Siblings share the same parent in the scope tree. The predicate receives the sibling’s context attrset.

pipe.collect ({ host, ... }: true)

Entity kind filtering is automatic: the predicate’s required args are checked against schema entity kinds. Only scopes whose entity kind args match are considered.

pipe.collect is non-terminal — transform stages can follow it:

pipe.from "http-backends" [
(pipe.collect ({ host, ... }: true))
(pipe.filter (b: b.port != 8080))
]

Route transformed data to specific aspects by identity. Only the named aspects receive the data; other consumers see the unmodified pool.

pipe.to [ den.aspects.networking ]

Tag each entry with its source scope context. Consumers receive { value; source; } records where source is the full context attrset of the originating scope. Works with both local quirks and collected data — local entries get the current scope as their source.

pipe.from "http-backends" [
(pipe.collect ({ host, ... }: true))
pipe.withProvenance
]

Quirk values that are functions taking { config, ... } are detected as config-dependent thunks.

  • Local scope: Marked for deferred resolution inside evalModules. The thunk is resolved against the entity’s own config fixpoint.
  • Cross-host (via pipe.collect): Resolved eagerly against the source host’s instantiated config, with scope context args (host, user, etc.) available alongside config and lib.
den.aspects.my-service = {
firewall = { config, ... }: {
ports = [ config.services.my-service.port ];
};
};

Pipe assembly runs post-pipeline in assemblePipes, before class module wrapping. The sequence:

  1. Expose pass — bottom-up tree walk collects all pipe.expose data
  2. Per-scope assembly — for each scope:
    • Extract raw pipe entries from scopedClassImports
    • Merge exposed data from children
    • Mark local config thunks
    • Apply untargeted pipe effects
    • Build targeted data (pipe.to)
  3. Inject into scope contexts — pipe data becomes available as function args in wrapClassModule
Contribute Community Sponsor