Skip to content

Quirks & Pipes

A quirk is structured data emitted by an aspect on a named key registered in den.quirks. A pipe is a policy effect that routes, filters, transforms, or aggregates quirk data — within a scope or across hosts. Producers emit quirks without knowing who consumes them; policies wire producers to consumers.

Quirks and pipes solve a fundamental problem: how do multiple aspects contribute structured data that a single consumer assembles? Firewall ports, backup paths, persistence directories, monitoring endpoints — these are all cases where many producers feed one consumer.

Without quirks, the only way to share data between aspects is through NixOS module options. This works but creates coupling: every producer must know the consumer’s option path, and cross-entity aggregation (e.g., collecting ports from all users on a host) requires manual wiring.

# Without quirks — tight coupling
den.aspects.nginx = {
nixos.networking.firewall.allowedTCPPorts = [ 80 443 ];
};
den.aspects.postgres = {
nixos.networking.firewall.allowedTCPPorts = [ 5432 ];
};

With quirks, producers don’t know or care about consumers:

# With quirks — decoupled
den.aspects.nginx = {
firewall = { ports = [ 80 443 ]; };
};
den.aspects.postgres = {
firewall = { ports = [ 5432 ]; };
};

The quirks system separates three concerns:

RoleWhoWhat they do
ProducerAny aspectEmits data on a named quirk key
ArchitectPolicy authorDeclares pipes and routing via pipe.from
ConsumerClass moduleReceives assembled data via function args

Producers and consumers don’t know about each other. The architect wires them together via policy pipe effects.

Register the quirk name in den.quirks. This tells the pipeline to classify matching aspect keys as pipe data rather than class modules:

den.quirks.firewall = {
description = "Firewall port declarations";
};

Any aspect can emit data on a quirk key. The pipeline collects these values scope-locally (per entity):

den.aspects.nginx = {
nixos.services.nginx.enable = true;
firewall = { ports = [ 80 443 ]; };
};
den.aspects.postgres = {
nixos.services.postgresql.enable = true;
firewall = { ports = [ 5432 ]; };
};

Quirk values can be attrsets, lists, or any Nix value. List values are auto-flattened — [ [a b] [c] ] becomes [a b c].

A class module receives quirk data by naming the quirk in its function arguments:

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

The firewall argument receives a list of all quirk values collected in the current scope. If no producers emitted data, it’s [].

For simple same-scope aggregation, steps 1-3 are sufficient — no pipe policies needed. When you need filtering, transformation, or cross-scope flow, declare a pipe policy:

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

See Pipes guide for hands-on examples of all pipe stages.

Quirk data is scope-local by default. Each entity (host, user, home) has its own scope, and producers within that scope contribute to that scope’s pool. Which pool a value lands in, and which pool a consumer reads, is decided by the aspect’s scope, not by its class: a nixos module on a user aspect reads the user’s pool.

Nothing crosses a scope boundary by itself:

  • a user’s values do not reach its host unless a policy exposes them (pipe.expose);
  • a host’s values reach a user only when a host-scope pipe policy binds that quirk, and only if the user emits nothing of its own on it;
  • sibling scopes (two users, two hosts) never see each other’s values except through pipe.collect, pipe.collectAll or pipe.broadcast.

The guide’s scoping table lists each case.

flowchart TD
  subgraph "host: igloo"
    nginx["nginx: firewall=[80,443]"]
    postgres["postgres: firewall=[5432]"]
    consumer["networking: receives [80,443,5432]"]
    nginx --> consumer
    postgres --> consumer
  end

Child-scope data can flow upward. A user’s quirk data reaches the host scope via pipe.expose:

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

pipe.as delivers data under a different quirk name. This creates derived quirks — quirks with no native emitters, populated entirely from other pipes via collect + transform + rename:

den.policies.derive-targets = { host, ... }:
let inherit (den.lib.policy) pipe; in
[ (pipe.from "backends" [
(pipe.transform (b: "${b.addr}:${toString b.port}"))
(pipe.as "monitoring-targets")
])
];

Consumers of monitoring-targets receive the transformed data. Consumers of backends are unaffected. Composes with pipe.to and pipe.collect.

pipe.collect harvests data from sibling scopes matching a predicate. This enables cross-host aggregation — e.g., collecting all hosts’ ports for a load balancer:

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

Siblings are scopes sharing the same parent in the scope tree.

When an aspect requires a quirk argument ({ firewall, ... }:), the pipeline defers its inclusion during the tree walk. After all producers have emitted their data and pipes are assembled, deferred aspects are resolved with the assembled pipe data.

Include ordering does not matter. Whether the consumer appears before or after producers in includes, the result is identical. This eliminates walk-order sensitivity — consumers always see all available data regardless of include order.

Quirk values can depend on the evaluated config of the class that produced them (e.g., reading a port from NixOS options, or a username from home-manager). These are collected as bare functions and resolved against the producer’s config, wherever they are consumed:

  • Local thunks: Resolved inside evalModules against the producing class and scope’s config — a user’s quirk sees its home-manager config (plus the host’s as osConfig), a host’s quirk sees the host config.
  • Cross-host thunks (via pipe.collect or pipe.broadcast): Resolved eagerly against the source’s instantiated config.
den.aspects.my-service = {
firewall = { config, ... }: {
ports = [ config.services.my-service.port ];
};
};
WhatWhen to use
QuirksStructured data aggregation within or across scopes
PoliciesEntity topology (fan-out, routing)
providesCross-entity aspect delivery (host↔user)
Class modulesNixOS/Darwin/HM configuration
ContributeCommunitySponsor