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.

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.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 a host’s config (e.g., reading a port from NixOS options). These are collected as bare functions and resolved lazily:

  • Local thunks: Resolved inside evalModules using the entity’s own config fixpoint.
  • Cross-host thunks (via pipe.collect): Resolved eagerly against the source host’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
Contribute Community Sponsor