Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 22 additions & 20 deletions docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export default defineConfig({
{ label: 'Aspects & Functors', slug: 'explanation/aspects' },
{ label: 'Class Modules', slug: 'explanation/class-modules' },
{ label: 'Parametric Aspects', slug: 'explanation/parametric' },
{ label: 'Structural Introspection', slug: 'explanation/structural-introspection', badge: { text: 'advanced', variant: 'caution' } },
],
},
{
Expand Down Expand Up @@ -130,26 +131,27 @@ export default defineConfig({
label: 'Batteries',
collapsed: true,
items: [
{ label: 'define-user — OS user accounts', link: '/reference/batteries/#den_define-user' },
{ label: 'hostname — set system hostname', link: '/reference/batteries/#den_hostname' },
{ label: 'os-class — cross-platform os class', link: '/reference/batteries/#den_os-class' },
{ label: 'os-user — user class forwarding', link: '/reference/batteries/#den_os-user' },
{ label: 'primary-user — admin privileges', link: '/reference/batteries/#den_primary-user' },
{ label: 'user-shell — login shell', link: '/reference/batteries/#den_user-shell' },
{ label: 'mutual-provider — host↔user config', link: '/reference/batteries/#den_mutual-provider' },
{ label: 'host-aspects — project host classes', link: '/reference/batteries/#den_host-aspects' },
{ label: 'tty-autologin — TTY auto-login', link: '/reference/batteries/#den_tty-autologin' },
{ label: 'vm-autologin — auto-login for VMs', link: '/reference/batteries/#den_vm-autologin' },
{ label: 'wsl — WSL support', link: '/reference/batteries/#den_wsl' },
{ label: 'forward — custom class factory', link: '/reference/batteries/#den_forward' },
{ label: 'import-tree — legacy module import', link: '/reference/batteries/#den_import-tree' },
{ label: 'home-manager — HM integration', link: '/reference/batteries/#den_home-manager' },
{ label: 'hjem — hjem integration', link: '/reference/batteries/#den_hjem' },
{ label: 'maid — nix-maid integration', link: '/reference/batteries/#den_maid' },
{ label: 'unfree — allow unfree packages', link: '/reference/batteries/#den_unfree' },
{ label: 'insecure — allow insecure packages', link: '/reference/batteries/#den_insecure' },
{ label: "inputs' — flake-parts inputs", link: '/reference/batteries/#den_inputs' },
{ label: "self' — flake-parts self outputs", link: '/reference/batteries/#den_self' },
{ label: 'define-user — OS user accounts', link: '/reference/batteries/#denbatteriesdefine-user' },
{ label: 'hostname — set system hostname', link: '/reference/batteries/#denbatterieshostname' },
{ label: 'os-class — cross-platform os class', link: '/reference/batteries/#denbatteriesos-class' },
{ label: 'os-user — user class forwarding', link: '/reference/batteries/#denbatteriesos-user' },
{ label: 'primary-user — admin privileges', link: '/reference/batteries/#denbatteriesprimary-user' },
{ label: 'user-shell — login shell', link: '/reference/batteries/#denbatteriesuser-shell' },
{ label: 'mutual-provider — host↔user config', link: '/reference/batteries/#denbatteriesmutual-provider' },
{ label: 'host-aspects — project host classes', link: '/reference/batteries/#denbatterieshost-aspects' },
{ label: 'tty-autologin — TTY auto-login', link: '/reference/batteries/#denbatteriestty-autologin' },
{ label: 'vm-autologin — auto-login for VMs', link: '/reference/batteries/#denbatteriesvm-autologin' },
{ label: 'wsl — WSL support', link: '/reference/batteries/#denbatterieswsl' },
{ label: 'forward — custom class factory', link: '/reference/batteries/#denbatteriesforward' },
{ label: 'import-tree — legacy module import', link: '/reference/batteries/#denbatteriesimport-tree' },
{ label: 'home-manager — HM integration', link: '/reference/batteries/#denbatterieshome-manager' },
{ label: 'hjem — hjem integration', link: '/reference/batteries/#denbatterieshjem' },
{ label: 'maid — nix-maid integration', link: '/reference/batteries/#denbatteriesmaid' },
{ label: 'unfree — allow unfree packages', link: '/reference/batteries/#denbatteriesunfree' },
{ label: 'insecure — allow insecure packages', link: '/reference/batteries/#denbatteriesinsecure' },
{ label: "inputs' — flake-parts inputs", link: '/reference/batteries/#denbatteriesinputs' },
{ label: "self' — flake-parts self outputs", link: '/reference/batteries/#denbatteriesself' },
{ label: "flake-scope — lib/inputs/den to pipeline", link: '/reference/batteries/#denbatteriesflake-scope' },
],
},
{ label: 'Host↔User Mutual Config', slug: 'guides/mutual' },
Expand Down
22 changes: 22 additions & 0 deletions docs/src/content/docs/explanation/parametric.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,28 @@ context drives the *entire aspect* (which classes to include, what includes to
add). Use class-level injection when only a specific class module needs
entity data alongside module-system args.

<Aside type="note" title="Class modules that name a descendant kind">
A **static** aspect (plain attrset) whose class module names a *descendant*
entity kind of the emitting scope gets the same treatment as the aspect-level
form: the pipeline promotes it to parametric and fans it out per descendant.

```nix
# Included at HOST scope — `user` is a descendant of `host` in the entity
# schema, so this static aspect is promoted and fanned out per user,
# exactly like the aspect-level { user, ... }: form.
den.aspects.desktop = {
nixos = { user, ... }: {
services.xdg.portal.extraPortals = [ "${user.userName}-portal" ];
};
};
```

This keeps the two forms equivalent: a class module that needs a descendant
kind never silently drops its content. (A same-kind arg like `host` in a
host-scope class module is not a descendant — it binds statically from the
scope via class-level injection, per [Class Modules](/explanation/class-modules/).)
</Aside>

<Aside type="caution" title="Avoid anonymous functions in includes">
Use named aspects instead of inline anonymous functions. Named aspects produce
better error traces and are easier to debug with the [diagram library](/explanation/diagrams/).
Expand Down
9 changes: 9 additions & 0 deletions docs/src/content/docs/explanation/policies.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,15 @@ den.schema.host.includes = [
];
```

<Aside type="note" title="`when` over inline aspects is different">
`when` applied to an **inline aspect** (not a registered policy) does not
gate policy dispatch — it compiles the aspect into a **conditional aspect**
(`meta.guard`) whose guard is evaluated against the in-flight tree with a
structural, exclude-aware `hasAspect`. That is what makes predicates like
`host.hasAspect den.aspects.git` safe to write. See
[Conditional aspects](/reference/aspects/#metaguard--metaaspects--conditional-aspects).
</Aside>

This page covers the *concept*. For the full activation model — the
registry/value distinction, cascading across scopes, authoritative
exclude semantics, the `for`/`when` combinators, and the dispatch
Expand Down
234 changes: 234 additions & 0 deletions docs/src/content/docs/explanation/structural-introspection.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
---
title: Structural Introspection & Constraints
description: Reading and writing the aspect tree structure — entity.hasAspect, entity.aspects, and meta.handleWith constraints.
---

import { Aside } from '@astrojs/starlight/components';

<Aside title="Source" icon="github">
[`modules/context/has-aspect.nix`](https://github.com/denful/den/blob/main/modules/context/has-aspect.nix) ·
[`nix/lib/aspects/has-aspect.nix`](https://github.com/denful/den/blob/main/nix/lib/aspects/has-aspect.nix) ·
[`nix/lib/aspects/fx/constraints.nix`](https://github.com/denful/den/blob/main/nix/lib/aspects/fx/constraints.nix)
</Aside>

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.hasAspect` and `entity.aspects` let a
class module ask "is aspect X in this entity's tree?"
- **Writing structure** — `meta.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.

## 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:

```nix
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 `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

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

```nix
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](/reference/aspects/#metaguard--metaaspects--conditional-aspects) whose
guard gets the same scoped, structural `hasAspect`:

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

<Aside type="caution" title="Never in `includes`">
Do **not** use `hasAspect` to decide an aspect's `includes` list:

```nix
# BROKEN — infinite recursion at evaluation
den.aspects.broken =
{ host, ... }:
{
includes =
if host.hasAspect den.aspects.zfs-root
then [ den.aspects.zfs-impermanence ]
else [ den.aspects.btrfs-impermanence ];
};
```

The tree depends on every aspect's `includes`; `includes` depending on the
tree is a back-edge with no fixed point. If the decision is *structural*
(which aspects belong in the tree), use [constraints](#writing-structure-metahandlewith)
instead — they run during the walk, with full structural visibility, and
operate *on* the tree rather than reading *from inside* it.
</Aside>

### What "present" means
Comment thread
theutz marked this conversation as resolved.

*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 `hasAspect` in
[conditional aspects](/reference/aspects/#metaguard--metaaspects--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*.

## 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) |

```nix
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`

`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`:

```nix
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`

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

### `substitute ref replacement`

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

### `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`

Every constructor has a `.global` variant:

```nix
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.

## 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](/reference/lib/#structural-introspection-helpers)):

- `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)

## See also

- [Aspects](/reference/aspects/) — structural keys, `meta`, conditional aspects
- [Policies](/reference/policies/) — `include`/`exclude` effects and the `when`/`for` combinators
- [Class Modules](/explanation/class-modules/) — where `hasAspect` is safely readable
- [Diagrams](/reference/diag/) — excluded/tombstoned aspects are visible in captures
14 changes: 14 additions & 0 deletions docs/src/content/docs/guides/batteries.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,20 @@ den.default.includes = [ den.batteries.self' ];

Requires flake-parts. Works in host, user, and home contexts.

### `den.batteries.flake-scope`
Comment thread
theutz marked this conversation as resolved.

Exposes `lib`, `inputs`, and `den` to aspect pipeline functions — the
generic, non-flake-parts counterpart of `inputs'`/`self'`. No flake-parts
required. Values are tagged so class-module-native values win silently on
collision:

```nix
den.default.includes = [ den.batteries.flake-scope ];

# now usable in parametric aspects
den.aspects.my-aspect = { host, inputs, lib, ... }: { ... };
```

## Usage Patterns

### Global Batteries
Expand Down
7 changes: 7 additions & 0 deletions docs/src/content/docs/guides/configure-aspects.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,13 @@ den.aspects.foo =
}
```

Beyond freeform metadata, `meta` carries pipeline-meaningful fields —
`handleWith` (constraints: exclude/substitute/filter aspects in the
subtree), `collisionPolicy` (flat-form class modules), and
`guard`/`aspects` (conditional aspects). See
[Structural Introspection & Constraints](/explanation/structural-introspection/)
and the [aspects reference](/reference/aspects/#metahandlewith).

## Aspect Custom Submodule

In case an aspect needs a custom submodule, it can be added this way:
Expand Down
Loading
Loading