Skip to content

feat: entity.hasAspect <ref> query method on context entities#439

Merged
sini merged 6 commits intovic:mainfrom
sini:feat/has-aspect
Apr 13, 2026
Merged

feat: entity.hasAspect <ref> query method on context entities#439
sini merged 6 commits intovic:mainfrom
sini:feat/has-aspect

Conversation

@sini
Copy link
Copy Markdown
Collaborator

@sini sini commented Apr 11, 2026

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:

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:

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:

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

  • just ci passes 331/331 at branch tip
  • Each commit builds and passes tests on its own
  • just fmt is idempotent across the tree
  • The parametric fix doesn't regress deadbugs/issue-413-*,
    deadbugs/issue-423-*, or issue-408 tests
  • 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.

@sini sini changed the title feat: ctx.hasAspect(<ref>) and supported toolin feat: ctx.hasAspect(<ref>) and supported tooling Apr 11, 2026
@sini sini added allow-ci allow all CI integration tests ai-assisted labels Apr 11, 2026
@sini sini requested review from theutz and vic April 11, 2026 21:45
@sini sini added the enhancement New feature or request label Apr 11, 2026
@sini
Copy link
Copy Markdown
Collaborator Author

sini commented Apr 11, 2026

Found two real bugs in the new regression test suite to cover this feature. Pushing fix now. :)

@sini
Copy link
Copy Markdown
Collaborator Author

sini commented Apr 11, 2026

This is functionally complete, but I'm now going through the deeper human code review/refactor process. Not ready for external reviewers yet.

@sini sini self-assigned this Apr 12, 2026
@sini sini changed the title feat: ctx.hasAspect(<ref>) and supported tooling feat: entity.hasAspect <ref> query method on context entities Apr 12, 2026
@sini sini marked this pull request as ready for review April 12, 2026 00:39
@sini sini force-pushed the feat/has-aspect branch from 73a2e28 to 02f157e Compare April 12, 2026 00:40
@sini
Copy link
Copy Markdown
Collaborator Author

sini commented Apr 12, 2026

The next feature PR on top of this will be guarded include wrappers to work around the cycle dependency -- we can create an intermediate wrapper aspect that can evaluate the constraint during resolve time.

Copy link
Copy Markdown
Contributor

@drupol drupol left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just made very minor nit comments.

Comment on lines +29 to +32
if builtins.isAttrs result && fn ? meta && !(result ? meta) then
result // { inherit (fn) meta; }
else
result;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit:

Suggested change
if builtins.isAttrs result && fn ? meta && !(result ? meta) then
result // { inherit (fn) meta; }
else
result;
result // lib.optionalAttrs (builtins.isAttrs result && fn ? meta && !(result ? meta)) { inherit (fn) meta; }

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't a bad nit. I would take it if vic hadn't already merged this commit. :)

Comment on lines +38 to +41
if config ? class then
[ config.class ]
else
throw "den.schema.conf.hasAspect: entity has no `class` or `classes`"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I think we could use lib.throwIf/lib.throwIfNot ?

Comment on lines +44 to +47
if classes == [ ] then
throw "den.schema.conf.hasAspect: entity has empty `classes` list"
else
lib.head classes;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here ?

vic
vic previously approved these changes Apr 12, 2026
Copy link
Copy Markdown
Owner

@vic vic left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You were right @sini, reviewing commit per commit was way easier. Maybe at the end of this can we add documentation for all meta.* and adapters.* (maybe a dedicated page on how resolve + adapters work). And also entity.hasAspect and warning-asides for its cyclic-usage.

@vic vic added the merge-approved Approved, merge yourself when ready label Apr 12, 2026
@vic
Copy link
Copy Markdown
Owner

vic commented Apr 12, 2026

Feel free to merge when conflicts resolved.

@kalyanoliveira
Copy link
Copy Markdown
Contributor

just wanted to say how cool this functionality is yall, great job!

sini added 6 commits April 13, 2026 10:27
New terminal adapter for walking a resolved aspect tree and
collecting aspectPaths of every non-tombstone aspect visited.
Result shape:

    { paths = [ [providerSeg..., name], ... ]; }

Depth-first, not deduplicated at this layer. Used by the has-aspect
lib (landing next) and available for tests, introspection tooling,
and future diag features that need structural-tree visibility.

Adds three small helpers, all exported:

- pathKey : slash-joined key for an aspectPath, matching the
  convention used by adapters.structuredTrace.
- toPathSet : list-of-paths → attrset-as-set keyed by pathKey,
  for O(1) membership checks.
- collectPathsInner : the raw walker shared with oneOfAspects
  (landing next) so the two consumers don't duplicate the logic.
  collectPaths itself is just filterIncludes wrapping the inner.
meta.adapter that keeps the first candidate structurally present in
a parent's subtree and tombstones the rest via excludeAspect:

    meta.adapter = oneOfAspects [ <agenix-rekey> <sops-nix> ];

When both candidates are present the first wins; when only one is
present it survives unchanged; when neither is present the adapter
is a no-op. Complements excludeAspect / substituteAspect — the three
together cover the include-this / exclude-this / swap-this-for-that
space of structural decisions.

Shares the collectPathsInner walker with collectPaths (via the
bypassing form, to avoid re-entering our own meta.adapter and
infinitely recursing) and the pathKey / toPathSet helpers for the
subtree presence lookup. No code duplication with the surrounding
adapters.

Requires adapters.nix to accept \`den\` in its argument set so it
can reference den.lib.aspects.resolve.withAdapter from inside the
body. That reference is a fixpoint closure that's safe via Nix
laziness — the adapter body isn't forced until resolve walks a
tree, by which point den.lib.aspects is fully constructed.
nix/lib/aspects/has-aspect.nix exports:

- hasAspectIn { tree; class; ref } — low-level query. Walks tree
  under class via collectPaths, returns whether ref is present.
- collectPathSet { tree; class } — set-as-attrset of slash-joined
  paths for O(1) membership lookups. Thin wrapper over the
  adapters.toPathSet / collectPaths combo.
- mkEntityHasAspect { tree; primaryClass; classes } — entity-facing
  functor+attrs constructor: the bare form runs under primaryClass,
  .forClass picks a specific class, .forAnyClass unions across all
  classes the entity participates in. Per-class path sets are
  thunk-cached so repeated calls share one traversal per class.

Identity is aspectPath (= meta.provider ++ [name]). refKey validates
that its argument has both `name` and `meta`, rejecting malformed
inputs loudly rather than silently producing an <anon> key. Uses
the pathKey / toPathSet helpers exported from adapters.nix — no
duplication of slash-joining or set construction.

Re-exported from nix/lib/aspects/default.nix at the canonical
den.lib.aspects.* paths. Ships with lib-level tests that exercise
the primitives in isolation against handcrafted trees plus a
factory-function identity lock-in assertion.
Adds the hasAspect query method to every entity that imports
den.schema.conf — host, user, home out of the box, and any user-
defined kind that imports conf inherits it for free. Zero changes
to nix/lib/types.nix; the per-entity submodule definitions stay
untouched.

The option lives in modules/context/has-aspect.nix, auto-imported
as a flake-level module by nix/flakeModule.nix. The outer module
captures config.den so the inner entity submodule can reach
den.lib.aspects.mkEntityHasAspect at entity-eval time.

Class-protocol: prefer `classes` (list), fall back to wrapping
`class` (string), else throw. Entities without a matching
den.ctx.<kind> produce a call-time throw — the fallback shape
preserves the functor+attrs shape so forClass / forAnyClass
attribute access doesn't leak a cryptic error; all three paths
throw the same error on invocation.

Note: the test access path is den.hosts.<system>.<name>.hasAspect
(and den.hosts.<system>.<name>.users.<user>.hasAspect for users),
not the denTest `igloo` specialArg — that's the resolved NixOS
config, not the den host entity where the option lives.

Includes a smoke test suite covering host/user bare usage,
forClass, forAnyClass, tombstone respect, absent aspect, and
angle-bracket sugar equivalence. The full regression-class test
matrix lands in a follow-up commit.
Extends the smoke suite with the complete regression-class matrix,
organized by aspect shape:

- Group A: sanity / transitive chains
- Group B: parametric contexts, incl. vic#423 static-sub-in-parametric-
  parent and vic#413 bare-function-sub-aspect shapes as explicit
  regression lock-ins
- Group C: factory-function aspects — direct factory reference in
  includes (factory-path identity is stable; inline invocation
  produces a differently-named sibling, tested here as a caveat)
- Group D: provider sub-aspects + identity disambiguation
- Group E: mutual-provider / provides chains (to-users, per-user,
  to-hosts)
- Group F: substituteAspect, composition at different levels (NOT
  nested-reaching per filterIncludes.tag semantics), oneOfAspects
  integration
- Group G: multi-class users (primary, forClass, forAnyClass,
  unknown class)
- Group H: extensibility contract — den.schema.conf owns the option
- Group I: error cases — bad ref throws
- Group J: real-world call from inside a deferred nixos module body,
  validating the cycle-safety guarantee for the primary intended
  use case

hasAspect reads config.resolved — the output of the ctxApply +
parametric resolution pipeline. Every aspect-shape that recently
produced a regression (vic#408, vic#413, vic#423, vic#429) has a lock-in test
here, so a future regression in parametric.nix or aspects/types.nix
that affects any of those shapes fails a hasAspect test before it
reaches user code.
Three-section example file demonstrating the intended usage patterns:

1. Reading structure via host.hasAspect from inside a class-config
   body — the common "branch on companion aspect" case.

2. Deciding structure via oneOfAspects as a meta.adapter — the
   "prefer A over B" structural decision, with the adapter doing the
   tree walk instead of hasAspect trying to read its own output.

3. Anti-pattern section explaining why hasAspect can't be used inside
   an aspect's `includes =` list, with a pointer to the adapter library
   (oneOfAspects, excludeAspect, substituteAspect) as the correct
   alternatives.

Ties the two features (hasAspect for reading structure, adapters for
writing structure) into a unified user-facing story. Uses `example-`
prefixed aspect names to avoid colliding with anything in the existing
template.
@sini sini merged commit 326ed59 into vic:main Apr 13, 2026
29 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-assisted allow-ci allow all CI integration tests enhancement New feature or request merge-approved Approved, merge yourself when ready

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants