commits
Iterates over `user.classes` instead of just being fixed to
`homeManager` only. Add tests for hjem class.
Named aspects now get a key attribute (class@identity) on their
collected modules. The NixOS module system deduplicates by key across
independent resolve calls, so a user can include an aspect directly AND
receive it via host-aspects without duplicate option declarations.
Anonymous/synthetic aspects are excluded from keying so multiple
anonymous includes coexist correctly.
Also fixes the pre-existing duplication where aspects included by both
host and user produced duplicate nixos modules via the default context
transition.
## Summary
- `host-aspects` battery was including the raw `host.aspect` tree via
`fixedTo`, which caused host nixos modules to be collected again when
the user context contributed to the host's resolution
- Now resolves `host.aspect` specifically for class `"homeManager"` and
emits only a homeManager module, preventing nixos/darwin duplication
## Test plan
- [x] All existing host-aspects tests pass (6/6)
- [x] New `test-no-nixos-duplication`: verifies host nixos tags appear
exactly once, not duplicated
- [x] Full CI passes (499/499)
## Summary
- New battery `den._.host-aspects` that projects all homeManager-class
configs from the host's aspect tree onto users who opt in
- Wraps `host.aspect` with `fixedTo { host, user }` so the pipeline
resolves it with `class = "homeManager"`, collecting only homeManager
keys
- Other class keys (nixos, darwin) are ignored naturally by the pipeline
## Usage
```nix
den.aspects.tux.includes = [ den._.host-aspects ];
```
## Test plan
- [x] Host aspect with homeManager key projects to opted-in user
- [x] Host aspect with only nixos key does not leak into user's
homeManager
- [x] Multiple host sub-aspects with homeManager keys all project
- [x] User who does NOT include the battery does not receive host
homeManager configs
- [x] No circular evaluation when accessing both host and user configs
- [x] Full CI suite passes (498/498)
Co-authored-by: Victor Borja <vborja@apache.org>
## Summary
- `applyDeep` eagerly replaced sub-aspect includes it couldn't resolve
with the current context (`{host,user}`) with `{}`, destroying
parametric includes like `den._.unfree` that need `{class,
aspect-chain}`
- Preserve the original include when `applyDeep` returns `{}` so the
statics path can resolve it with the correct context
## Test plan
- [x] `deadbugs-issue-460` tests: parametric wrapper around unfree calls
on both fx and legacy pipelines
- [x] Full CI suite passes (491/491)
Fixes #460
## Summary
- When a sub-aspect (`_.sub`) received both a parametric function and a
plain attrset from different modules, `providerType`'s either merge fell
back to `aspectType` which evaluated the function as a NixOS module —
failing with "attribute 'host' missing"
- Add a custom merge on `providerType` that detects mixed defs and
coerces parametric functions to `{ includes = [fn]; }` before merging as
aspects
- Single-def functions keep their identity (preserving `functionArgs`
for namespace export/import per #352)
## Test plan
- [x] `deadbugs-issue-448` tests: mixed function+attrset merge on both
fx and legacy pipelines
- [x] `deadbugs-issue-352` tests still pass (function arg reflection
preserved)
- [x] Full CI suite passes (491/491)
Fixes #448
Co-authored-by: Victor Borja <vborja@apache.org>
## Summary
- Rename `ci-fast.bash` to `ci.bash` and make it the default CI runner
- Update Justfile and docs references
## Test plan
- [ ] `just ci ""` runs all tests successfully
## Summary
Replace den's legacy recursive tree-walking resolution with an
effects-based pipeline. Aspects compile into effectful computations via
`aspectToEffect` — the tree structure emerges from effect composition,
not explicit recursion. All resolution strategy (constraint checking,
dedup, tracing, context dispatch) lives in handlers.
- **`aspectToEffect` compiler** — single function compiles any aspect
into a computation that emits `emit-class`, `register-constraint`,
`emit-include`, and `resolve-complete` effects
- **Handler-owned recursion** — `emit-include` handler checks
constraints and recurses via effectful resume; `into-transition` handler
processes context transitions with `scope.stateful`
- **Constraint system** — `meta.handleWith` / `meta.excludes` replace fx
usage of `meta.adapter`; scoped constraints via includes-chain ancestry;
`exclude`, `substitute`, `filterBy` constructors with subtree/global
variants
- **Includes chain provenance** — `chain-push`/`chain-pop` effects
replace `__parent` string tracking; observable by any handler for
diagrams, scope visualization, composable analysis
- **Module wiring** — `{ lib, den }` only, no `init` function, barrel
`default.nix`; nix-effects accessed as `den.lib.fx`
- **`den.fxPipeline` option** — defaults to `true`; legacy tests using
`resolve.withAdapter` run with `fxPipeline = false`
### Pipeline architecture
```
nix/lib/aspects/fx/
aspect.nix — aspectToEffect compiler
pipeline.nix — mkPipeline, defaultHandlers, fxResolve
identity.nix — path/identity utilities, collectPathsHandler, pathSetHandler
constraints.nix — exclude, substitute, filterBy constructors
includes.nix — includeIf conditional inclusion
trace.nix — structuredTraceHandler, tracingHandler
handlers/
include.nix — emit-include handler (owns recursion)
transition.nix — into-transition handler (scope.stateful)
ctx.nix — constantHandler, ctxSeenHandler
tree.nix — constraintRegistryHandler, chainHandler, classCollectorHandler
```
### Effect protocol
| Effect | Handler responsibility |
|---|---|
| `emit-class` | Accumulate modules by class |
| `emit-include` | Check constraints, recurse via `aspectToEffect` |
| `register-constraint` | Store in registry with scope/ownerChain |
| `check-constraint` | Query registry, first-registered-wins |
| `chain-push` / `chain-pop` | Track includes-path stack |
| `into-transition` | Walk context transitions with scoped handlers |
| `ctx-seen` | Dedup context stages |
| `resolve-complete` | Emit trace entries, accumulate paths |
| `get-path-set` | Return accumulated path set for `includeIf` guards |
| `<arg-name>` | `constantHandler` resumes with context value |
### Compatibility shims
The type system operates at declaration time and cannot be gated on
`config.den.fxPipeline` without circular evaluation:
- `aspect-chain` in `constantHandler` — provider functions from
`providerFnType.merge` create `{ class, aspect-chain }` functors
- `options.nix` uses legacy `ctxApply` — `config.resolved` can't access
`config.den` without circularity
- Legacy adapter tests run with `fxPipeline = false`
### Also in this PR
- `refactor: replace _. alias with provides.` across all
aspect/output/template modules
- `feat: ci-fast recipe` using nix-eval-jobs for parallel test eval
- Test restructuring: `batteries/`, `context/`, `perf/`, `home-manager/`
subdirs flattened to `features/`
- Regression reproductions for issues #413, #423, #426, #437
- Consolidated design spec at `docs/design/fx-pipeline-spec.md`
## Test plan
- [x] `nix develop -c just ci ""` — 488/488 pass
- [x] `nix develop -c just ci-fast` — parallel eval, 488/488 pass
- [x] `nix flake check` — templates, packages, devShells all pass
- [x] Checkmate `system-agnostic` tests — 15/15 pass
- [x] Legacy tests with `fxPipeline = false` verify backward
compatibility
---------
Co-authored-by: Victor Borja <vborja@apache.org>
Standalone home-manager configurations may not have a bound host. Change
hostName from strOpt to nullOr str so it defaults to null when no host
is associated.
Tombstone excludedFrom now uses the declaring aspect's full pathKey
instead of the anonymous wrapper's name. adapterOwner is captured at the
meta.adapter declaration site and propagated via tag to nested
filterIncludes invocations.
collectSelfPath extracts the aspect's path if not excluded, shared
between collectPathsInner and the upcoming structuredTrace adapter.
# feat: `entity.hasAspect <ref>` query method
## What this does
Adds a `.hasAspect` method on context entities (`host`, `user`, `home`,
and any custom entity kind that imports `den.schema.conf`). It answers
"is this aspect structurally present in my resolved tree?" from inside
class-config module bodies:
```nix
den.aspects.impermanence.nixos = { config, host, ... }: lib.mkMerge [
(lib.mkIf (host.hasAspect <zfs-root>) { /* zfs impermanence */ })
(lib.mkIf (host.hasAspect <btrfs-root>) { /* btrfs impermanence */ })
];
```
There are two variants for when the bare form isn't enough:
```nix
host.hasAspect.forClass "nixos" <facter>
user.hasAspect.forAnyClass <agenix-rekey>
```
Identity is compared by `aspectPath` (`meta.provider ++ [name]`), so
provider sub-aspects like `foo._.sub` keep their full path. Refs can be
plain aspect values (`den.aspects.facter`) or `<angle-bracket>` sugar,
they're the same thing after `__findFile` resolves.
Alongside `hasAspect`, this ships a companion `oneOfAspects` adapter.
That's the structural-decision primitive for "prefer A over B when both
are present", which is the thing you actually want when you're tempted
to use `hasAspect` to decide includes (see the guardrails section
below).
## Why
Real patterns that users hit:
- `<impermanence>` config depends on whether `<zfs-root>` or
`<btrfs-root>` is also configured on the host
- A secrets forward wants to pick `<agenix-rekey>` when present and
fall back to `<sops-nix>` otherwise
- Library aspects want to gate opt-in behavior on companion aspects
Today these get worked around with `config.*` lookups, hand-maintained
`lib.elem` checks, or structural hacks. `hasAspect` is the first-class
primitive for the read side. `oneOfAspects` is the first-class primitive
for the write side.
## Commits
Each commit is independently reviewable and builds green on its own.
### `fix(parametric): preserve meta on materialized parametric results`
Opened as it's own PR #440
### `feat(adapters): add collectPaths terminal adapter`
New public terminal adapter. Walks a resolved tree via `filterIncludes`
and returns `{ paths = [ [providerSeg..., name], ... ]; }`, depth-first,
not deduplicated. Tombstones are skipped via the `meta.excluded` check.
Ships with two small helpers exported from the same file: `pathKey`
(slash-joined path key) and `toPathSet` (list of paths to attrset-as-set
for O(1) lookups). Both are used by `hasAspect` and `oneOfAspects`, so
exporting them keeps the two consumers from duplicating the same
one-liners.
### `feat(adapters): add oneOfAspects structural-decision adapter`
`meta.adapter` that keeps the first structurally-present candidate and
tombstones the rest via `excludeAspect`:
```nix
den.aspects.secrets-bundle = {
includes = [ <agenix-rekey> <sops-nix> ];
meta.adapter = den.lib.aspects.adapters.oneOfAspects [
<agenix-rekey> # preferred
<sops-nix> # fallback
];
};
```
Complements `excludeAspect` and `substituteAspect`. The three together
cover include-this / exclude-this / swap-this-for-that. Internally it
walks the parent subtree with the raw collector (bypassing
`filterIncludes` so it doesn't re-enter itself), finds which candidates
are present, and folds `excludeAspect` over the losers. No code
duplication with `collectPaths` thanks to the shared helpers from the
previous commit.
### `feat(aspects): add has-aspect library primitives`
New file `nix/lib/aspects/has-aspect.nix` exporting:
- `hasAspectIn { tree; class; ref }` for any resolved tree, not just
entity contexts
- `collectPathSet { tree; class }` for an attrset-as-set of visible
paths
- `mkEntityHasAspect { tree; primaryClass; classes }` which builds the
functor-plus-attrs value attached to entities. Per-class path sets
are thunk-cached, so repeated calls share one traversal per class
`refKey` validates that its input has both `name` and `meta` before
reaching for `aspectPath`, throwing loudly rather than silently
producing an `<anon>` path key.
### `feat(context): add entity.hasAspect method via den.schema.conf`
The wiring. `modules/context/has-aspect.nix` is a flake-level module
that self-wires into `den.schema.conf` via
`config.den.schema.conf.imports`. Every entity type that imports `conf`
(host, user, home, and any user-defined kind) inherits `.hasAspect`
automatically. Zero changes to `nix/lib/types.nix`.
Class-protocol: prefers `classes` (list), falls back to `[ class ]`,
throws otherwise. Entities without a matching `den.ctx.<kind>` (so no
`config.resolved`) produce a call-time throw. The fallback value
preserves the functor-plus-attrs shape so `forClass` / `forAnyClass`
attribute access doesn't leak a cryptic error before reaching the real
one.
### `test(has-aspect): full regression-class coverage for entity method`
31 tests organized by aspect-construction shape. Every shape that's
produced a recent regression (`#408`, `#413`, `#423`, `#429`) has a
lock-in test. A future regression in `parametric.nix` or
`aspects/types.nix` that breaks any of these shapes trips a `has-aspect`
test before it reaches user code.
Groups cover basic and transitive chains, parametric contexts including
the static and bare-function sub-aspect shapes, factory functions,
provider sub-aspects and identity disambiguation, mutual-provider and
provides chains, `meta.adapter` composition, multi-class users, the
extensibility contract, error cases, and the primary intended use case
(calling `hasAspect` from inside a deferred `nixos` module body).
### `docs(example): add hasAspect + oneOfAspects worked examples`
User-facing pedagogical file in `templates/example/`. Three sections:
reading structure via `host.hasAspect` from a class-config body, writing
structure via `oneOfAspects` as a `meta.adapter`, and an anti-pattern
section explaining why `hasAspect` can't decide an aspect's `includes`
list with a pointer at the adapter library.
## Design guardrails
`hasAspect` is a read-only query on frozen structure. You call it from
inside class-config module bodies (`nixos = ...`, `homeManager = ...`)
or from lazy positions in aspect functor bodies. It's cycle-safe by
construction because by the time deferred class modules evaluate, the
aspect tree has already been resolved and frozen.
What it is not for: deciding an aspect's `includes` list. That's cyclic.
The tree you want to query depends on the decision you want `hasAspect`
to inform. Users who need that reach for `meta.adapter` composed via
`oneOfAspects`, `excludeAspect`, `substituteAspect`, or `filter` /
`filterIncludes`. Those run during the tree walk with full structural
visibility, so they can't cycle. The template example file has an
explicit anti-pattern section with the failing shape and the correct
rewrite.
Two tools, two jobs:
| Need | Tool | When it runs |
|---|---|---|
| Read "is X in my tree?" from module config | `hasAspect` | After the
tree is frozen, inside lazy class-module bodies |
| Decide tree structure based on "is X present?" | `meta.adapter` +
`oneOfAspects` / friends | During the tree walk, with full structural
visibility |
## Test plan
- [x] `just ci` passes 331/331 at branch tip
- [x] Each commit builds and passes tests on its own
- [x] `just fmt` is idempotent across the tree
- [x] The parametric fix doesn't regress `deadbugs/issue-413-*`,
`deadbugs/issue-423-*`, or `issue-408` tests
- [x] Full regression-class matrix covers every aspect shape that's
produced a recent bug
## Migration
None. Purely additive.
- `hasAspect` is a new option name, no conflict.
- No signature changes in `parametric.nix`, `resolve.nix`,
`ctx-apply.nix`, `types.nix`, or the other core library files.
- `adapters.nix` gains new exports (`collectPaths`, `oneOfAspects`,
`pathKey`, `toPathSet`). Nothing is renamed or removed.
- `nix/lib/aspects/default.nix` re-exports the new lib functions under
`den.lib.aspects.*` at their canonical paths.
- `modules/context/has-aspect.nix` is a new file, picked up by the
existing `import-tree` flake setup.
- Zero changes to `nix/lib/types.nix`.
## Follow-ups
- Docs pass on when to make aspects non-parametric. Users often reach
for `perHost` / `perUser` wrappers when the aspect has no actual ctx
dependence, which makes structural tooling less reliable than it could
be. Docs-only, separate PR.
- Conditional-include wrapper `onlyIf guard target`. A small adapter
sitting next to `oneOfAspects` that lets users write `includes = [
(onlyIf <zfs-root> <zfs-impermanence>) ]` for "include target iff guard
is structurally present." Same cycle-avoidance trick (decision runs in
the wrapper's own meta.adapter, walks the subtree with the raw
`collectPathsInner` collector so it doesn't re-enter itself). Reuses
every helper this PR exports. Rough spec is written up, scoped as its
own follow-up PR.
## Summary
Adds new a new lib module `den.lib.strict` which when imported into a
submodule type (usually via den.schema) disables the freeform type which
is prone to subtle bugs with scoping and mistyping problems.
It works out of the box with `den.schema.host` and `den.schema.user`,
but the types of aspects and flake didn't import a schema type so I
added those too.
I've also added support for asserting errors with `denTest`
Named aspects coerced from parametric fns (e.g. `den.aspects.git = {
user, ... }: { nixos = ...; }`) had their context silently dropped when
included from other parametric aspects. applyDeep's sub-recursion now
reaches into named aspects' includes to apply context to the parametric
functions inside.
Fixes #442.
Parametric sub-aspects defined as bare functions
(`foo._.sub = { host, ... }: { nixos = ...; }`) lost their `foo`
provider prefix in the resolved aspect tree: when the functor was
invoked by applyDeep, the raw user return (`{ nixos = ...; }`) didn't
carry the sub's `meta.provider = ["foo"]`, so downstream
aspectPath-based queries saw the aspect as `["sub"]` instead of
`["foo","sub"]`.
This was invisible as long as consumers only extracted classModule via
`aspect.${class}` without caring about aspectPath — see
deadbugs/issue-413-provider-sub-aspect-function, still passes. It
surfaces the moment anything compares aspect references by their full
`meta.provider ++ [name]` identity.
Fix: add a small `carryMeta fn result` helper in applyDeep that copies
`meta` from the originating fn onto the materialized result when the
result doesn't already carry its own. Applied to both:
- the outer `take` result (direct-include path)
- the inner re-recursion on bare-result.includes (parametric-parent
path, where a functor returns `{ includes = [foo._.sub] }`)
`isBareResult` is checked against the pre-carryMeta value so the
bare-result recursion branch still fires — carrying meta would otherwise
mask it and skip the sub-recursion.
Scope kept narrow to applyDeep rather than touching `take.carryAttrs`
globally: a broader fix there affects every take.* call site (including
statics.nix and resolve.apply) and breaks unrelated tests that depend on
take results being metaless.
PR #410 added coercedProviderType to make `{host, ...}: {nixos = ...}`
aspects merge with sibling static `.nixos = ...` defs (fixes #408). The
predicate was too broad: any non-module function got wrapped into `{
includes = [fn] }`, which silently broke "factory" aspects like:
den.aspects.facter = reportPath: { nixos = ...; };
den.aspects.igloo.includes = [ (den.aspects.facter "/path") ];
Coercing turns den.aspects.facter into a full aspect with the default
functor. Calling `(facter "/path")` invokes the default functor in a
non-static branch with the string as `ctx` — the user's `reportPath` is
discarded and the config body never materializes.
Narrow the predicate with `lib.functionArgs v != {}`. Context fns have
destructured named args (non-empty functionArgs) and still get coerced.
Factory fns with a bare positional arg stay typed as providerFnType,
whose merge wraps the underlying function via
`__functor = _: eth.merge loc defs` so `(aspect arg)` correctly
dispatches to the user function.
Bisected to d266c3a (PR #410). Minimal repro verified broken at that
commit and working at v0.15.0. Fix passes the new deadbugs test plus all
278 existing CI tests including deadbugs-issue-408 (`context-fn +
static` merge still works).
Fixes #429.
this allows people to customize the aspect being used to configure their
host/user/home, even if it is in another namespace than den.aspects.
Fixes #416
As a precursor to adopting a robust AI contributions policy, I thought
it might be useful to expand the text at the beginning of the
Contributions section. This is inspired by discussions in the matrix
channel, but ultimately is just my opinion without consensus from the
other contributors. Feel free to pick it apart!
PR #419 introduced applyDeep to propagate parametric context into
bare-result sub-includes. But it called takeFn unconditionally on every
sub, including full static aspects whose default functor (withOwn
parametric.atLeast) discards owned class configs in a non-static branch.
Result: `den.aspects.role._.sub.nixos.x = true` was silently dropped
when role was a parametric parent that included its sub-aspect.
Gate the inner re-application on canTake.upTo: a static aspect's default
functor has empty functionArgs, so upTo is false and the sub is left
alone for the static resolve pass. User-provided provider fns (e.g. `{
host, ... }: { nixos = ...; }`) have host in functionArgs, so upTo fires
and their config is materialized. This preserves the #419 fix while
restoring pre-#419 behavior for static sub-aspects.
Fixes #423.
Updates documentation for bogus template on how to contribute.
Adds AI Policy aside.
Adds CI helper for `trace`.
The value is the parent aspect whose meta.adapter caused the exclusion,
not the agent doing the excluding. excludedFrom better describes "this
aspect was excluded from server's subtree."
Tombstones now carry meta.excludedBy — the name of the aspect whose
meta.adapter caused the exclusion. Combined with the existing
meta.provider (structural origin from provides chain), tombstones
clearly document both who defines an aspect and who excluded it.
Tombstone meta fields:
- provider — who defines this aspect (provides chain)
- excludedBy — who excluded it (adapter source)
- originalName — display name before ~prefix
- replacedBy — replacement name (substitutions only)
Fixes #413
### Problem
Provider sub-aspects defined as bare context functions don't receive `{
host }` during resolution:
```nix
den.aspects.foo._.sub = { host, ... }: {
nixos = lib.optionalAttrs (host.hostName != "whatever") {
networking.networkmanager.enable = true;
};
};
```
When a parent function returns `{ includes = [foo._.sub] }`, the
sub-aspect is resolved by the adapter system which only passes `{ class,
aspect-chain }` — the `{ host }` context from the pipeline never reaches
nested includes of function results.
### Fix
`applyDeep` in `parametric.applyIncludes` — when `takeFn` succeeds and
returns a bare result with sub-includes (no `meta`, no `__functor`),
also apply `takeFn` to those sub-includes. This propagates context to
provider sub-aspects nested inside function results without
double-applying to parametric wrappers or `deepRecurse` outputs.
The key discriminator: bare provider results carry only `includes` (+
`name` from `carryAttrs`). Results from `withOwn`/`withIdentity` have
`meta`; deferred `deepRecurse` wrappers have `__functor`. Neither should
be re-resolved.
### Test coverage
- `deadbugs/issue-413-provider-bare-function.nix` — reproduces the
original report
- `deadbugs/issue-413-provider-sub-aspect-function.nix` — variant with
`lib.optionalAttrs` guard
- `provides-parametric.nix` — provider sub-aspects with parametric
context in various configurations
cc: @kalyanoliveira for the report
---------
Co-authored-by: horyzon <kalyan.coliveira@gmail.com>
`excludeAspect` now checks if the reference's `aspectPath` is a prefix
of the aspect's path. Excluding `monitoring` also excludes
`monitoring._.node-exporter`, `monitoring._.alerting`, etc.
Return { includes = [fn] } instead of a bare function, matching the POC
pattern. The function result lives in includes where
parametric.applyIncludes handles it with withIdentity, preserving the
aspect name through resolution. Fixes #408.
filterIncludes now produces tombstones (~name, meta.excluded) for
excluded includes instead of silently dropping them. Tombstones are
empty aspects harmless to module but visible to trace/debug adapters.
Substituted includes produce a tombstone for the original plus the
replacement, enabling both to appear in traces.
New adapters:
- aspectPath: derive identity from name + provider (replaces == on
aspects)
- excludeAspect: exclude by aspect reference via path comparison
- substituteAspect: substitute by aspect reference via mapAspect + path
- tombstone: create a tombstone from a resolved aspect (exported
utility)
`adapters.trace`, like default, uses `filterIncludes` but with
`traceNames`. This was being repeated in many places, also simplifies
documentation for debugging.
## Summary
- Entities automatically expose `.resolved` — the result of their
context pipeline — derived from the schema type system
- `forward.nix` defaults to `item.resolved` when `fromAspect` is absent,
enabling cross-context forwarding without manual wiring
- `mainModule` helper eliminated — entity types resolve directly via
`config.resolved`
## What changed
**options.nix** — `schemaEntryType` wraps `deferredModule` to
auto-inject `config.resolved` for any schema entry where
`den.ctx.${kind}` exists. Context args are derived from the entity's
`_module.args`, filtered to known context kinds. No per-entity
boilerplate — host, user, and home all get `.resolved` automatically.
**types.nix** — `mainModule` helper removed. Both host and home
`mainModule` options simplified to `den.lib.aspects.resolve config.class
config.resolved`.
**forward.nix** — `asp` falls back to `item.resolved or item` when
`fromAspect` is absent.
## How context adapters flow through forwards
`den.ctx.host.meta.adapter` is carried through `ctxApply` (via
`withIdentity` from #398) onto `.resolved`. When `resolve` processes it,
`adapters.filterIncludes` (from #397) picks up the adapter and applies
it transitively to the entire subtree — including nested aspects reached
through forwards.
Move our `traceName` adapter into lib, it is useful at tests but could
also be used by people for debugging. Updated existing tests to use it,
and formatted code afterwards.
Named aspects now get a key attribute (class@identity) on their
collected modules. The NixOS module system deduplicates by key across
independent resolve calls, so a user can include an aspect directly AND
receive it via host-aspects without duplicate option declarations.
Anonymous/synthetic aspects are excluded from keying so multiple
anonymous includes coexist correctly.
Also fixes the pre-existing duplication where aspects included by both
host and user produced duplicate nixos modules via the default context
transition.
## Summary
- `host-aspects` battery was including the raw `host.aspect` tree via
`fixedTo`, which caused host nixos modules to be collected again when
the user context contributed to the host's resolution
- Now resolves `host.aspect` specifically for class `"homeManager"` and
emits only a homeManager module, preventing nixos/darwin duplication
## Test plan
- [x] All existing host-aspects tests pass (6/6)
- [x] New `test-no-nixos-duplication`: verifies host nixos tags appear
exactly once, not duplicated
- [x] Full CI passes (499/499)
## Summary
- New battery `den._.host-aspects` that projects all homeManager-class
configs from the host's aspect tree onto users who opt in
- Wraps `host.aspect` with `fixedTo { host, user }` so the pipeline
resolves it with `class = "homeManager"`, collecting only homeManager
keys
- Other class keys (nixos, darwin) are ignored naturally by the pipeline
## Usage
```nix
den.aspects.tux.includes = [ den._.host-aspects ];
```
## Test plan
- [x] Host aspect with homeManager key projects to opted-in user
- [x] Host aspect with only nixos key does not leak into user's
homeManager
- [x] Multiple host sub-aspects with homeManager keys all project
- [x] User who does NOT include the battery does not receive host
homeManager configs
- [x] No circular evaluation when accessing both host and user configs
- [x] Full CI suite passes (498/498)
Co-authored-by: Victor Borja <vborja@apache.org>
## Summary
- `applyDeep` eagerly replaced sub-aspect includes it couldn't resolve
with the current context (`{host,user}`) with `{}`, destroying
parametric includes like `den._.unfree` that need `{class,
aspect-chain}`
- Preserve the original include when `applyDeep` returns `{}` so the
statics path can resolve it with the correct context
## Test plan
- [x] `deadbugs-issue-460` tests: parametric wrapper around unfree calls
on both fx and legacy pipelines
- [x] Full CI suite passes (491/491)
Fixes #460
## Summary
- When a sub-aspect (`_.sub`) received both a parametric function and a
plain attrset from different modules, `providerType`'s either merge fell
back to `aspectType` which evaluated the function as a NixOS module —
failing with "attribute 'host' missing"
- Add a custom merge on `providerType` that detects mixed defs and
coerces parametric functions to `{ includes = [fn]; }` before merging as
aspects
- Single-def functions keep their identity (preserving `functionArgs`
for namespace export/import per #352)
## Test plan
- [x] `deadbugs-issue-448` tests: mixed function+attrset merge on both
fx and legacy pipelines
- [x] `deadbugs-issue-352` tests still pass (function arg reflection
preserved)
- [x] Full CI suite passes (491/491)
Fixes #448
Co-authored-by: Victor Borja <vborja@apache.org>
## Summary
Replace den's legacy recursive tree-walking resolution with an
effects-based pipeline. Aspects compile into effectful computations via
`aspectToEffect` — the tree structure emerges from effect composition,
not explicit recursion. All resolution strategy (constraint checking,
dedup, tracing, context dispatch) lives in handlers.
- **`aspectToEffect` compiler** — single function compiles any aspect
into a computation that emits `emit-class`, `register-constraint`,
`emit-include`, and `resolve-complete` effects
- **Handler-owned recursion** — `emit-include` handler checks
constraints and recurses via effectful resume; `into-transition` handler
processes context transitions with `scope.stateful`
- **Constraint system** — `meta.handleWith` / `meta.excludes` replace fx
usage of `meta.adapter`; scoped constraints via includes-chain ancestry;
`exclude`, `substitute`, `filterBy` constructors with subtree/global
variants
- **Includes chain provenance** — `chain-push`/`chain-pop` effects
replace `__parent` string tracking; observable by any handler for
diagrams, scope visualization, composable analysis
- **Module wiring** — `{ lib, den }` only, no `init` function, barrel
`default.nix`; nix-effects accessed as `den.lib.fx`
- **`den.fxPipeline` option** — defaults to `true`; legacy tests using
`resolve.withAdapter` run with `fxPipeline = false`
### Pipeline architecture
```
nix/lib/aspects/fx/
aspect.nix — aspectToEffect compiler
pipeline.nix — mkPipeline, defaultHandlers, fxResolve
identity.nix — path/identity utilities, collectPathsHandler, pathSetHandler
constraints.nix — exclude, substitute, filterBy constructors
includes.nix — includeIf conditional inclusion
trace.nix — structuredTraceHandler, tracingHandler
handlers/
include.nix — emit-include handler (owns recursion)
transition.nix — into-transition handler (scope.stateful)
ctx.nix — constantHandler, ctxSeenHandler
tree.nix — constraintRegistryHandler, chainHandler, classCollectorHandler
```
### Effect protocol
| Effect | Handler responsibility |
|---|---|
| `emit-class` | Accumulate modules by class |
| `emit-include` | Check constraints, recurse via `aspectToEffect` |
| `register-constraint` | Store in registry with scope/ownerChain |
| `check-constraint` | Query registry, first-registered-wins |
| `chain-push` / `chain-pop` | Track includes-path stack |
| `into-transition` | Walk context transitions with scoped handlers |
| `ctx-seen` | Dedup context stages |
| `resolve-complete` | Emit trace entries, accumulate paths |
| `get-path-set` | Return accumulated path set for `includeIf` guards |
| `<arg-name>` | `constantHandler` resumes with context value |
### Compatibility shims
The type system operates at declaration time and cannot be gated on
`config.den.fxPipeline` without circular evaluation:
- `aspect-chain` in `constantHandler` — provider functions from
`providerFnType.merge` create `{ class, aspect-chain }` functors
- `options.nix` uses legacy `ctxApply` — `config.resolved` can't access
`config.den` without circularity
- Legacy adapter tests run with `fxPipeline = false`
### Also in this PR
- `refactor: replace _. alias with provides.` across all
aspect/output/template modules
- `feat: ci-fast recipe` using nix-eval-jobs for parallel test eval
- Test restructuring: `batteries/`, `context/`, `perf/`, `home-manager/`
subdirs flattened to `features/`
- Regression reproductions for issues #413, #423, #426, #437
- Consolidated design spec at `docs/design/fx-pipeline-spec.md`
## Test plan
- [x] `nix develop -c just ci ""` — 488/488 pass
- [x] `nix develop -c just ci-fast` — parallel eval, 488/488 pass
- [x] `nix flake check` — templates, packages, devShells all pass
- [x] Checkmate `system-agnostic` tests — 15/15 pass
- [x] Legacy tests with `fxPipeline = false` verify backward
compatibility
---------
Co-authored-by: Victor Borja <vborja@apache.org>
Tombstone excludedFrom now uses the declaring aspect's full pathKey
instead of the anonymous wrapper's name. adapterOwner is captured at the
meta.adapter declaration site and propagated via tag to nested
filterIncludes invocations.
collectSelfPath extracts the aspect's path if not excluded, shared
between collectPathsInner and the upcoming structuredTrace adapter.
# feat: `entity.hasAspect <ref>` query method
## What this does
Adds a `.hasAspect` method on context entities (`host`, `user`, `home`,
and any custom entity kind that imports `den.schema.conf`). It answers
"is this aspect structurally present in my resolved tree?" from inside
class-config module bodies:
```nix
den.aspects.impermanence.nixos = { config, host, ... }: lib.mkMerge [
(lib.mkIf (host.hasAspect <zfs-root>) { /* zfs impermanence */ })
(lib.mkIf (host.hasAspect <btrfs-root>) { /* btrfs impermanence */ })
];
```
There are two variants for when the bare form isn't enough:
```nix
host.hasAspect.forClass "nixos" <facter>
user.hasAspect.forAnyClass <agenix-rekey>
```
Identity is compared by `aspectPath` (`meta.provider ++ [name]`), so
provider sub-aspects like `foo._.sub` keep their full path. Refs can be
plain aspect values (`den.aspects.facter`) or `<angle-bracket>` sugar,
they're the same thing after `__findFile` resolves.
Alongside `hasAspect`, this ships a companion `oneOfAspects` adapter.
That's the structural-decision primitive for "prefer A over B when both
are present", which is the thing you actually want when you're tempted
to use `hasAspect` to decide includes (see the guardrails section
below).
## Why
Real patterns that users hit:
- `<impermanence>` config depends on whether `<zfs-root>` or
`<btrfs-root>` is also configured on the host
- A secrets forward wants to pick `<agenix-rekey>` when present and
fall back to `<sops-nix>` otherwise
- Library aspects want to gate opt-in behavior on companion aspects
Today these get worked around with `config.*` lookups, hand-maintained
`lib.elem` checks, or structural hacks. `hasAspect` is the first-class
primitive for the read side. `oneOfAspects` is the first-class primitive
for the write side.
## Commits
Each commit is independently reviewable and builds green on its own.
### `fix(parametric): preserve meta on materialized parametric results`
Opened as it's own PR #440
### `feat(adapters): add collectPaths terminal adapter`
New public terminal adapter. Walks a resolved tree via `filterIncludes`
and returns `{ paths = [ [providerSeg..., name], ... ]; }`, depth-first,
not deduplicated. Tombstones are skipped via the `meta.excluded` check.
Ships with two small helpers exported from the same file: `pathKey`
(slash-joined path key) and `toPathSet` (list of paths to attrset-as-set
for O(1) lookups). Both are used by `hasAspect` and `oneOfAspects`, so
exporting them keeps the two consumers from duplicating the same
one-liners.
### `feat(adapters): add oneOfAspects structural-decision adapter`
`meta.adapter` that keeps the first structurally-present candidate and
tombstones the rest via `excludeAspect`:
```nix
den.aspects.secrets-bundle = {
includes = [ <agenix-rekey> <sops-nix> ];
meta.adapter = den.lib.aspects.adapters.oneOfAspects [
<agenix-rekey> # preferred
<sops-nix> # fallback
];
};
```
Complements `excludeAspect` and `substituteAspect`. The three together
cover include-this / exclude-this / swap-this-for-that. Internally it
walks the parent subtree with the raw collector (bypassing
`filterIncludes` so it doesn't re-enter itself), finds which candidates
are present, and folds `excludeAspect` over the losers. No code
duplication with `collectPaths` thanks to the shared helpers from the
previous commit.
### `feat(aspects): add has-aspect library primitives`
New file `nix/lib/aspects/has-aspect.nix` exporting:
- `hasAspectIn { tree; class; ref }` for any resolved tree, not just
entity contexts
- `collectPathSet { tree; class }` for an attrset-as-set of visible
paths
- `mkEntityHasAspect { tree; primaryClass; classes }` which builds the
functor-plus-attrs value attached to entities. Per-class path sets
are thunk-cached, so repeated calls share one traversal per class
`refKey` validates that its input has both `name` and `meta` before
reaching for `aspectPath`, throwing loudly rather than silently
producing an `<anon>` path key.
### `feat(context): add entity.hasAspect method via den.schema.conf`
The wiring. `modules/context/has-aspect.nix` is a flake-level module
that self-wires into `den.schema.conf` via
`config.den.schema.conf.imports`. Every entity type that imports `conf`
(host, user, home, and any user-defined kind) inherits `.hasAspect`
automatically. Zero changes to `nix/lib/types.nix`.
Class-protocol: prefers `classes` (list), falls back to `[ class ]`,
throws otherwise. Entities without a matching `den.ctx.<kind>` (so no
`config.resolved`) produce a call-time throw. The fallback value
preserves the functor-plus-attrs shape so `forClass` / `forAnyClass`
attribute access doesn't leak a cryptic error before reaching the real
one.
### `test(has-aspect): full regression-class coverage for entity method`
31 tests organized by aspect-construction shape. Every shape that's
produced a recent regression (`#408`, `#413`, `#423`, `#429`) has a
lock-in test. A future regression in `parametric.nix` or
`aspects/types.nix` that breaks any of these shapes trips a `has-aspect`
test before it reaches user code.
Groups cover basic and transitive chains, parametric contexts including
the static and bare-function sub-aspect shapes, factory functions,
provider sub-aspects and identity disambiguation, mutual-provider and
provides chains, `meta.adapter` composition, multi-class users, the
extensibility contract, error cases, and the primary intended use case
(calling `hasAspect` from inside a deferred `nixos` module body).
### `docs(example): add hasAspect + oneOfAspects worked examples`
User-facing pedagogical file in `templates/example/`. Three sections:
reading structure via `host.hasAspect` from a class-config body, writing
structure via `oneOfAspects` as a `meta.adapter`, and an anti-pattern
section explaining why `hasAspect` can't decide an aspect's `includes`
list with a pointer at the adapter library.
## Design guardrails
`hasAspect` is a read-only query on frozen structure. You call it from
inside class-config module bodies (`nixos = ...`, `homeManager = ...`)
or from lazy positions in aspect functor bodies. It's cycle-safe by
construction because by the time deferred class modules evaluate, the
aspect tree has already been resolved and frozen.
What it is not for: deciding an aspect's `includes` list. That's cyclic.
The tree you want to query depends on the decision you want `hasAspect`
to inform. Users who need that reach for `meta.adapter` composed via
`oneOfAspects`, `excludeAspect`, `substituteAspect`, or `filter` /
`filterIncludes`. Those run during the tree walk with full structural
visibility, so they can't cycle. The template example file has an
explicit anti-pattern section with the failing shape and the correct
rewrite.
Two tools, two jobs:
| Need | Tool | When it runs |
|---|---|---|
| Read "is X in my tree?" from module config | `hasAspect` | After the
tree is frozen, inside lazy class-module bodies |
| Decide tree structure based on "is X present?" | `meta.adapter` +
`oneOfAspects` / friends | During the tree walk, with full structural
visibility |
## Test plan
- [x] `just ci` passes 331/331 at branch tip
- [x] Each commit builds and passes tests on its own
- [x] `just fmt` is idempotent across the tree
- [x] The parametric fix doesn't regress `deadbugs/issue-413-*`,
`deadbugs/issue-423-*`, or `issue-408` tests
- [x] Full regression-class matrix covers every aspect shape that's
produced a recent bug
## Migration
None. Purely additive.
- `hasAspect` is a new option name, no conflict.
- No signature changes in `parametric.nix`, `resolve.nix`,
`ctx-apply.nix`, `types.nix`, or the other core library files.
- `adapters.nix` gains new exports (`collectPaths`, `oneOfAspects`,
`pathKey`, `toPathSet`). Nothing is renamed or removed.
- `nix/lib/aspects/default.nix` re-exports the new lib functions under
`den.lib.aspects.*` at their canonical paths.
- `modules/context/has-aspect.nix` is a new file, picked up by the
existing `import-tree` flake setup.
- Zero changes to `nix/lib/types.nix`.
## Follow-ups
- Docs pass on when to make aspects non-parametric. Users often reach
for `perHost` / `perUser` wrappers when the aspect has no actual ctx
dependence, which makes structural tooling less reliable than it could
be. Docs-only, separate PR.
- Conditional-include wrapper `onlyIf guard target`. A small adapter
sitting next to `oneOfAspects` that lets users write `includes = [
(onlyIf <zfs-root> <zfs-impermanence>) ]` for "include target iff guard
is structurally present." Same cycle-avoidance trick (decision runs in
the wrapper's own meta.adapter, walks the subtree with the raw
`collectPathsInner` collector so it doesn't re-enter itself). Reuses
every helper this PR exports. Rough spec is written up, scoped as its
own follow-up PR.
## Summary
Adds new a new lib module `den.lib.strict` which when imported into a
submodule type (usually via den.schema) disables the freeform type which
is prone to subtle bugs with scoping and mistyping problems.
It works out of the box with `den.schema.host` and `den.schema.user`,
but the types of aspects and flake didn't import a schema type so I
added those too.
I've also added support for asserting errors with `denTest`
Named aspects coerced from parametric fns (e.g. `den.aspects.git = {
user, ... }: { nixos = ...; }`) had their context silently dropped when
included from other parametric aspects. applyDeep's sub-recursion now
reaches into named aspects' includes to apply context to the parametric
functions inside.
Fixes #442.
Parametric sub-aspects defined as bare functions
(`foo._.sub = { host, ... }: { nixos = ...; }`) lost their `foo`
provider prefix in the resolved aspect tree: when the functor was
invoked by applyDeep, the raw user return (`{ nixos = ...; }`) didn't
carry the sub's `meta.provider = ["foo"]`, so downstream
aspectPath-based queries saw the aspect as `["sub"]` instead of
`["foo","sub"]`.
This was invisible as long as consumers only extracted classModule via
`aspect.${class}` without caring about aspectPath — see
deadbugs/issue-413-provider-sub-aspect-function, still passes. It
surfaces the moment anything compares aspect references by their full
`meta.provider ++ [name]` identity.
Fix: add a small `carryMeta fn result` helper in applyDeep that copies
`meta` from the originating fn onto the materialized result when the
result doesn't already carry its own. Applied to both:
- the outer `take` result (direct-include path)
- the inner re-recursion on bare-result.includes (parametric-parent
path, where a functor returns `{ includes = [foo._.sub] }`)
`isBareResult` is checked against the pre-carryMeta value so the
bare-result recursion branch still fires — carrying meta would otherwise
mask it and skip the sub-recursion.
Scope kept narrow to applyDeep rather than touching `take.carryAttrs`
globally: a broader fix there affects every take.* call site (including
statics.nix and resolve.apply) and breaks unrelated tests that depend on
take results being metaless.
PR #410 added coercedProviderType to make `{host, ...}: {nixos = ...}`
aspects merge with sibling static `.nixos = ...` defs (fixes #408). The
predicate was too broad: any non-module function got wrapped into `{
includes = [fn] }`, which silently broke "factory" aspects like:
den.aspects.facter = reportPath: { nixos = ...; };
den.aspects.igloo.includes = [ (den.aspects.facter "/path") ];
Coercing turns den.aspects.facter into a full aspect with the default
functor. Calling `(facter "/path")` invokes the default functor in a
non-static branch with the string as `ctx` — the user's `reportPath` is
discarded and the config body never materializes.
Narrow the predicate with `lib.functionArgs v != {}`. Context fns have
destructured named args (non-empty functionArgs) and still get coerced.
Factory fns with a bare positional arg stay typed as providerFnType,
whose merge wraps the underlying function via
`__functor = _: eth.merge loc defs` so `(aspect arg)` correctly
dispatches to the user function.
Bisected to d266c3a (PR #410). Minimal repro verified broken at that
commit and working at v0.15.0. Fix passes the new deadbugs test plus all
278 existing CI tests including deadbugs-issue-408 (`context-fn +
static` merge still works).
Fixes #429.
As a precursor to adopting a robust AI contributions policy, I thought
it might be useful to expand the text at the beginning of the
Contributions section. This is inspired by discussions in the matrix
channel, but ultimately is just my opinion without consensus from the
other contributors. Feel free to pick it apart!
PR #419 introduced applyDeep to propagate parametric context into
bare-result sub-includes. But it called takeFn unconditionally on every
sub, including full static aspects whose default functor (withOwn
parametric.atLeast) discards owned class configs in a non-static branch.
Result: `den.aspects.role._.sub.nixos.x = true` was silently dropped
when role was a parametric parent that included its sub-aspect.
Gate the inner re-application on canTake.upTo: a static aspect's default
functor has empty functionArgs, so upTo is false and the sub is left
alone for the static resolve pass. User-provided provider fns (e.g. `{
host, ... }: { nixos = ...; }`) have host in functionArgs, so upTo fires
and their config is materialized. This preserves the #419 fix while
restoring pre-#419 behavior for static sub-aspects.
Fixes #423.
Tombstones now carry meta.excludedBy — the name of the aspect whose
meta.adapter caused the exclusion. Combined with the existing
meta.provider (structural origin from provides chain), tombstones
clearly document both who defines an aspect and who excluded it.
Tombstone meta fields:
- provider — who defines this aspect (provides chain)
- excludedBy — who excluded it (adapter source)
- originalName — display name before ~prefix
- replacedBy — replacement name (substitutions only)
Fixes #413
### Problem
Provider sub-aspects defined as bare context functions don't receive `{
host }` during resolution:
```nix
den.aspects.foo._.sub = { host, ... }: {
nixos = lib.optionalAttrs (host.hostName != "whatever") {
networking.networkmanager.enable = true;
};
};
```
When a parent function returns `{ includes = [foo._.sub] }`, the
sub-aspect is resolved by the adapter system which only passes `{ class,
aspect-chain }` — the `{ host }` context from the pipeline never reaches
nested includes of function results.
### Fix
`applyDeep` in `parametric.applyIncludes` — when `takeFn` succeeds and
returns a bare result with sub-includes (no `meta`, no `__functor`),
also apply `takeFn` to those sub-includes. This propagates context to
provider sub-aspects nested inside function results without
double-applying to parametric wrappers or `deepRecurse` outputs.
The key discriminator: bare provider results carry only `includes` (+
`name` from `carryAttrs`). Results from `withOwn`/`withIdentity` have
`meta`; deferred `deepRecurse` wrappers have `__functor`. Neither should
be re-resolved.
### Test coverage
- `deadbugs/issue-413-provider-bare-function.nix` — reproduces the
original report
- `deadbugs/issue-413-provider-sub-aspect-function.nix` — variant with
`lib.optionalAttrs` guard
- `provides-parametric.nix` — provider sub-aspects with parametric
context in various configurations
cc: @kalyanoliveira for the report
---------
Co-authored-by: horyzon <kalyan.coliveira@gmail.com>
filterIncludes now produces tombstones (~name, meta.excluded) for
excluded includes instead of silently dropping them. Tombstones are
empty aspects harmless to module but visible to trace/debug adapters.
Substituted includes produce a tombstone for the original plus the
replacement, enabling both to appear in traces.
New adapters:
- aspectPath: derive identity from name + provider (replaces == on
aspects)
- excludeAspect: exclude by aspect reference via path comparison
- substituteAspect: substitute by aspect reference via mapAspect + path
- tombstone: create a tombstone from a resolved aspect (exported
utility)
## Summary
- Entities automatically expose `.resolved` — the result of their
context pipeline — derived from the schema type system
- `forward.nix` defaults to `item.resolved` when `fromAspect` is absent,
enabling cross-context forwarding without manual wiring
- `mainModule` helper eliminated — entity types resolve directly via
`config.resolved`
## What changed
**options.nix** — `schemaEntryType` wraps `deferredModule` to
auto-inject `config.resolved` for any schema entry where
`den.ctx.${kind}` exists. Context args are derived from the entity's
`_module.args`, filtered to known context kinds. No per-entity
boilerplate — host, user, and home all get `.resolved` automatically.
**types.nix** — `mainModule` helper removed. Both host and home
`mainModule` options simplified to `den.lib.aspects.resolve config.class
config.resolved`.
**forward.nix** — `asp` falls back to `item.resolved or item` when
`fromAspect` is absent.
## How context adapters flow through forwards
`den.ctx.host.meta.adapter` is carried through `ctxApply` (via
`withIdentity` from #398) onto `.resolved`. When `resolve` processes it,
`adapters.filterIncludes` (from #397) picks up the adapter and applies
it transitively to the entire subtree — including nested aspects reached
through forwards.