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 structure —
entity.hasAspectandentity.aspectslet a class module ask “is aspect X in this entity’s tree?” - Writing structure —
meta.handleWithwith 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.
Reading structure: entity.hasAspect
Section titled “Reading structure: entity.hasAspect”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 classhost.hasAspect.forClass "nixos" den.aspects.zfs-roothost.hasAspect.forAnyClass den.aspects.zfs-root # union over the entity's classesThe 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 classes —
nixos 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.
Where you may call it
Section titled “Where you may call it”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"; })];What “present” means
Section titled “What “present” means”Present is exactly what the boolean above reports: hasAspect ref is
true when ref is in the entity’s resolved tree, and false — absent —
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
hasAspectin conditional aspects is scoped to the current pipeline scope plus its ancestors.) - Exclude-aware — an aspect excluded from the entity’s scope (via
excludesor a constraint) reads as absent.
Reading the tree: entity.aspects
Section titled “Reading the tree: entity.aspects”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:
| Field | Meaning |
|---|---|
.identity | Base fully-qualified name, ctx-stripped — e.g. "roles/workstation" |
.identityKey | Full unique key incl. {ctxId} — distinguishes anonymous instances |
.isNamed | false 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.
Writing structure: meta.handleWith
Section titled “Writing structure: meta.handleWith”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.
exclude ref
Section titled “exclude ref”Tombstones ref — it contributes no modules, and its exclusion is visible
to hasAspect queries (they return false) and to traces/diagrams.
substitute ref replacement
Section titled “substitute ref replacement”Tombstones ref and resolves replacement in its place at the same
position. The tombstone records replacedBy for tracing.
filterBy predicate
Section titled “filterBy predicate”Keeps/drops each aspect in scope by predicate. The predicate receives the
aspect attrset (with .name, .meta, .includes, …) and returns a bool.
Scope: subtree (default) vs .global
Section titled “Scope: subtree (default) vs .global”Every constructor has a .global variant:
den.lib.aspects.fx.constraints.exclude den.aspects.sops-nix # subtreeden.lib.aspects.fx.constraints.exclude.global den.aspects.sops-nix # globalsubtree(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.
Lib-level building blocks
Section titled “Lib-level building blocks”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 treecollectPathSet { tree, class }— the raw identity-key path setmkEntityHasAspect { tree, primaryClass, classes }— the fullhasAspect/aspectsrecordmkProjectedHasAspect { pathSetByScope, key }— pure lookup over an already-computed path set (no pipeline run)
See also
Section titled “See also”- Aspects — structural keys,
meta, conditional aspects - Policies —
include/excludeeffects and thewhen/forcombinators - Class Modules — where
hasAspectis safely readable - Diagrams — excluded/tombstoned aspects are visible in captures