Skip to content

Structural Introspection & Constraints

Sometimes a piece of configuration depends on what else is in the aspect tree — “use the zfs flavor of impermanence when zfs-root is present, otherwise the btrfs flavor”. Den splits that need into two complementary tools:

  • Reading structureentity.hasAspect and entity.aspects let a class module ask “is aspect X in this entity’s tree?”
  • Writing structuremeta.handleWith with constraint records lets an aspect decide “aspect Y should not be in my subtree (or should be replaced by Z)”

The two live at different points in evaluation, and that difference is what makes them safe.

Every entity (host, user, home — and any custom schema kind) carries a readOnly hasAspect functor computed from its resolved aspect tree:

host.hasAspect den.aspects.zfs-root # bool — asks at the primary class
host.hasAspect.forClass "nixos" den.aspects.zfs-root
host.hasAspect.forAnyClass den.aspects.zfs-root # union over the entity's classes

The reference is any aspect you can point at: den.aspects.foo, den.aspects.foo.provides.bar, a namespace aspect, …

All three agree for any class the entity declares. Membership is structural, and the resolved tree is the same whichever class it is resolved at — a class decides what an aspect emits, never whether it is in the tree. The variants differ only in the class they ask at: hasAspect uses the primary class (the head of the entity’s classesnixos for a Linux host, darwin for a darwin one), forClass uses the class you name and answers false for one the entity does not declare, and forAnyClass unions over the declared classes. Prefer the bare functor; reach for the other two only when the class is the point of the question.

Safe — inside class module bodies. The nixos/homeManager/… module bodies run during evalModules, long after the aspect tree has been resolved and frozen:

den.aspects.impermanence =
{ host, ... }:
{
nixos =
{ lib, ... }:
lib.mkMerge [
(lib.mkIf (host.hasAspect den.aspects.zfs-root) {
# zfs-flavored impermanence wiring
})
(lib.mkIf (host.hasAspect den.aspects.btrfs-root) {
# btrfs-flavored impermanence wiring
})
];
};

This is cycle-safe: the body reads a frozen tree; nothing in the tree reads the body.

Safe — in policy guards. den.lib.policy.when over an inline aspect compiles a conditional aspect whose guard gets the same scoped, structural hasAspect:

den.aspects.igloo.includes = [
(den.lib.policy.when ({ host, ... }: host.hasAspect den.aspects.git) {
nixos.environment.variables.GIT_ENABLED = "true";
})
];

Present is exactly what the boolean above reports: hasAspect ref is true when ref is in the entity’s resolved tree, and falseabsent — when it is not. Two rules decide which:

  • Scoped to the entity — membership covers the entity’s own resolved tree: its scope and all descendants (for a host, the host scope plus its user scopes). A sibling host that included the aspect does not leak in, regardless of evaluation order. (The guard-level hasAspect in conditional aspects is scoped to the current pipeline scope plus its ancestors.)
  • Exclude-aware — an aspect excluded from the entity’s scope (via excludes or a constraint) reads as absent.

Alongside the boolean functor, every entity exposes aspects — a flat list of all resolved aspect nodes (every depth), taken at its primary class — the same class-invariant tree hasAspect reads, so no class carries a different list. The entity root itself and excluded/tombstoned aspects are not listed; anonymous aspects are. Each node keeps its .name, .meta, and .includes (its resolved subtree), and adds identity accessors:

FieldMeaning
.identityBase fully-qualified name, ctx-stripped — e.g. "roles/workstation"
.identityKeyFull unique key incl. {ctxId} — distinguishes anonymous instances
.isNamedfalse for anonymous/synthetic aspects (filter on this)
den.aspects.report =
{ host, ... }:
{
nixos = {
environment.etc."aspect-inventory".text =
lib.concatMapStringsSep "\n" (n: n.identity) host.aspects;
};
};

(aspects lives on the entity, so reach it through the entity arg — host.aspects, user.aspects, home.aspects — not through config.)

The entity’s hasAspect record also carries the per-class and union counterparts, aspectsForClass and allAspects, for the same reasons you would reach for forClass / forAnyClass above.

meta.handleWith on an aspect takes a constraint handler record (or a list of them) and registers it for the aspect’s subtree. Constraints are constructed via den.lib.aspects.fx.constraints:

den.aspects.secrets-bundle = {
includes = [
den.aspects.agenix-rekey
den.aspects.workstation-role # pulls in sops-nix somewhere below
];
# Prefer agenix: sops-nix never resolves under this bundle, whoever pulls it in.
meta.handleWith = den.lib.aspects.fx.constraints.exclude den.aspects.sops-nix;
};

A constraint is unconditional — it is not a rule that fires when some other aspect turns out to be present. Registering it tombstones ref throughout the registering aspect’s subtree, which is what makes it useful against aspects you never named yourself: secrets-bundle cannot see what workstation-role includes, but it can state that sops-nix is not welcome below it.

Tombstones ref — it contributes no modules, and its exclusion is visible to hasAspect queries (they return false) and to traces/diagrams.

Tombstones ref and resolves replacement in its place at the same position. The tombstone records replacedBy for tracing.

Keeps/drops each aspect in scope by predicate. The predicate receives the aspect attrset (with .name, .meta, .includes, …) and returns a bool.

Every constructor has a .global variant:

den.lib.aspects.fx.constraints.exclude den.aspects.sops-nix # subtree
den.lib.aspects.fx.constraints.exclude.global den.aspects.sops-nix # global
  • subtree (default) — the constraint applies within the including aspect’s own include chain. Two siblings including different aspects are unaffected by each other’s constraints.
  • global — the constraint applies fleet-wide, in every scope.

excludes (the structural key) is the same mechanism in shorthand: each excluded ref registers a subtree-scoped exclude — and it works on policies by name as well as aspects.

den.lib.aspects exposes the pieces the entity options are built from, for custom pipelines and tooling (see the lib reference):

  • hasAspectIn { tree, class, ref } — membership over a resolved tree
  • collectPathSet { tree, class } — the raw identity-key path set
  • mkEntityHasAspect { tree, primaryClass, classes } — the full hasAspect/aspects record
  • mkProjectedHasAspect { pathSetByScope, key } — pure lookup over an already-computed path set (no pipeline run)
  • Aspects — structural keys, meta, conditional aspects
  • Policiesinclude/exclude effects and the when/for combinators
  • Class Modules — where hasAspect is safely readable
  • Diagrams — excluded/tombstoned aspects are visible in captures
Contribute Community Sponsor