Status: approved (conversational, 2026-06-09). Scope: minimal v1 patch whose semantics match den-v2 HOAG, so it is not throwaway design — only its carrier is.
user.hasAspect aspectX returns false when aspectX is delivered into the
user's scope via provides/policy (e.g. igloo.provides.tux.includes = [ aspectX ]),
even though the config genuinely applies. Cause: entity hasAspect
(nix/lib/aspects/has-aspect.nix) runs a standalone pipeline over the
entity's own config.resolved tree, which never contains cross-entity
deliveries (those are produced during the owning run's fan-out). Guards
(policy.when) already read the delivered path set and work (issue-540); the
entity-level / parametric-body path does not.
In den-v2 (2026-05-24-den-v2-hoag-architecture.md), a scope node's
resolved-aspects (attr #6) is the projected set by construction:
forward-expand + neededBy (provides desugars to neededBy with an
entity-kind selector, open-Q #3) + constraints. hasAspect is just membership
in that node attribute / the monotone guard-set (attr #7) — projected by
construction. The v1 "structural own tree vs host-delivered tree" split
dissolves in v2.
The projected set is per active path, NOT per user. v2 node IDs are
user:tux@host:igloo — a user that appears under multiple hosts is multiple
distinct scope nodes, each with its own resolved-aspects, because provides /
policies differ per host. There is no single "tux resolved set"; the question
"is aspectX in tux's projected set" is only well-posed for a specific
(host, user) path. This is exactly why v1 must key pathSetByScope by the
full scope id (mkScopeId scopedCtx, which includes the active host
binding), and why the in-context query reads the owning host's
__pathSetByScope: the active path selects which host's deliveries apply. A
shared user's parametric body is re-evaluated once per host fan-out, each
evaluation stamped with its own host-qualified scopeId and reading that host's
byproduct.
Therefore the v1 patch must make in-context hasAspect answer projected
membership (the same delivered path set guards use), NOT the standalone
own-tree run. The own-tree run is the artifact v2 deletes.
- ✅ In-context
hasAspect= delivered/projected path set — survives to v2 as a node-attribute read. - ❌ Entity-run capture — reifies v1 pipeline state v2 discards.
⚠️ Per-scope carrier to reachevalModules-time reads — necessary v1 bridge, kept private, deleted in v2.
.hasAspect keeps one name; meaning is set by where the entity came from:
- Entity obtained as a scope-context arg inside aspect resolution
(
{ user, ... }:body,policy.when ({ user, ... }: …)) → projected. - Entity obtained standalone from the registry
(
den.hosts.….users.tux.hasAspect) → structural/own (today's behavior, unchanged). "Want the entity's own tree? query it from the registry."
This matches the existing asymmetry: guards already get a projected stub; parametric bodies wrongly get the registry/structural entity. The patch makes the in-context entity consistent with the guard entity.
Reuse the production run — never re-run the pipeline. The owning host's
production resolution (the single fx.handle that already runs to produce its
config) walks the user-scope's aspects and records them. Empirically verified:
the host's nixos-class production run records a homeManager-only,
provides-delivered aspect (aspect1) in its pathSet. So the projected answer
is already computed — the patch surfaces that byproduct and reads it. No
separate or "memoized" capture pipeline.
The global pathSet only stores scope-agnostic base keys (empirically verified:
it records "aspect1" with no scope tag), so it cannot distinguish tux's scope
from pingu's — and a user appears under multiple hosts (per-active-path, above).
Add a thunked state field pathSetByScope :: scopeId → { basePathKey → true }
(thunk discipline: stored _: value, read field null, like pathSet —
identity.nix:60-71, default pipeline.nix:137; the per-step deepSeq it must
survive is in nix-effects/src/trampoline.nix:124,175, described by the comment
at pipeline.nix:100-107). In collectPathsHandler (resolve-complete,
nix/lib/aspects/fx/identity.nix:45-72), bucket each non-excluded node's
baseKey under state.currentScope (the reviewer confirmed currentScope is
the entity scope that owns the node at record time). Register default in
pipeline.nix defaultState.
The entity's existing production resolve already computes this; expose it instead
of discarding it. The single shared run lives in nix/lib/entities/_types.nix
mainModuleOption (:33-34, default = den.lib.aspects.resolve config.class config.resolved) — the only site that invokes the run for the entity.
resolved (modules/options.nix) is just raw input data, NOT the run, so the
new option must NOT be placed "beside resolved" and must NOT call a separate
resolveWithState/collectPathSet — that is a second fx.handle and breaks
the zero-extra-runs guarantee.
Concretely:
fxResolve(nix/lib/aspects/fx/resolve.nix) bindsresult = mkPipeline …, readsresult.state.scopedClassImports nulletc., then returns ONLY{ imports = phase4.${class} or []; }(:748-750), discarding the rest of state. Extend it (or add aresolveWithPathsvariant) to also surfaceresult.state.pathSetByScopefrom that samefx.handle.- In
_types.nixmainModuleOption, thread one shared call yielding both:mainModule = { imports = …; }AND a thunked read-onlyconfig.__pathSetByScope. Source both from the single result. - Declare
__pathSetByScopeinternal = true; readOnly = true; type = lib.types.raw;with a lazy default — mirror the existinghasAspectoption shape (modules/context/has-aspect.nix) so the module system never merges or eagerly forces it.
One run, module-system-memoized — "memoization into scope."
New in nix/lib/aspects/has-aspect.nix:
mkProjectedHasAspect { pathSetByScope, scopeId } → returns
{ __functor; forClass; forAnyClass; } that answers
(pathSetByScope.${scopeId} or {}) ? refKey ref, with the same refKey matching
as mkEntityHasAspect. No pipeline — a dictionary lookup over an already-computed
attrset.
Where a child scope's scopedCtx is built
(nix/lib/aspects/fx/policy/schema.nix, decomposeSchemaEffect, which already
computes ctxNames = mkScopeId scopedCtx): wrap entity-kind bindings so their
hasAspect is mkProjectedHasAspect stamped with scopeId = ctxNames and
pathSetByScope taken from the owning entity's __pathSetByScope — the
host binding present in the same scopedCtx for host-nested users; the entity
itself for an unbound standalone home (host = null,
nix/lib/entities/home.nix:30-33, where projected == structural, no delta, no
fan-out source — do not build a fleet branch). Registry entities
(modules/context/has-aspect.nix) are untouched → standalone structural. Guards
are unaffected (compile-conditional.nix:101-125, mkGuardCtx synthesizes its
own pathSet stubs and ignores the entity's hasAspect).
Cycle-safety. Two independent reasons the in-flight host run never forces the in-context query:
pathSetByScopeis built in the structural walk (resolve-complete) without forcing class-module function bodies, so computinghost.__pathSetByScopenever forces the user'shomeManagerbody (which holds thehasAspectthunk). The thunk forceshost.__pathSetByScopeback atevalModules, after the walk — the phase separationtest-Jrelies on.- The emitted module carrying the
mkIf (host.__pathSetByScope …)condition lives insidestate.scopedClassImports, which is thunked (defaultpipeline.nix:140; live thunked writeclass-collector.nix:50). The trampoline's per-stepdeepSeq(nix-effects/src/trampoline.nix:124,175) forces the thunk-closure, not the modules inside it — so the condition is never forced mid-run even though the user wrote a bare attrset (homeManager.config = mkIf …, the reported shape). This makes the thunk discipline of #1 load-bearing for correctness, not just performance — a non-thunked carrier would deepSeq the condition into the in-flight run and recurse.
Includes-position misuse (deciding includes from the in-context hasAspect)
forces host.__pathSetByScope while the host run is still in flight → recurses
loudly; accepted (documented-unsafe today).
pathSetByScope.${scopeId} contains aspects that resolved under this scope
node — the user's own includes plus everything delivered into it (provides /
to-users policies). It does not include host-level aspects that apply to
the user only by P-edge inheritance — those resolve under the host scope, not
the user scope. This matches today's standalone-user behavior (a user's own tree
never contained host aspects), so it is not a regression. The overloaded
in-context .hasAspect therefore answers "is X delivered into my scope", which
is deliberately distinct from the guard hasAspect (which reads the global flat
pathSet and would also see host-scope aspects). If a future caller needs
"host-inherited aspects too", that is a separate primitive, explicitly out of
scope here.
v1 (templates/ci/modules/deadbugs/):
- Content-position: parametric body
mkIf (user.hasAspect aspect1)with both aspects underigloo.provides.tux→true(the reported case). - Multi-user discrimination: tux has aspect1, pingu does not (+ a
policy.excludevariant mirroring issue-540) → per-scope correctness. - Multi-host discrimination: the same user under two hosts, aspect provided
only on one → the in-context projected query returns
trueunder the delivering host's path andfalseunder the other. Locks the per-active-path keying (the load-bearing correctness property). - Provenance split lock: registry
den.hosts.….users.tux.hasAspectstaysfalsefor a provides-delivered aspect (reuse thetest-E-host-policyFn-not-visible-to-user-hasAspectshape) while the in-context parametric-body equivalent returnstrue— pin both halves of the overload in one place. - Includes-position misuse: in-context
user.hasAspectused to decide anincludeslist re-enters and recurses (wrap inbuiltins.tryEval, assertsuccess == false) — pins the "only content position is supported" contract, mirroring the existing documented-unsafe cycle behavior. - Existing
has-aspect+ issue-540 suites stay green.
v2 corpus: add the same atuin scenario as a HOAG acceptance test so
provides → neededBy desugaring (open-Q #3) must make tux's resolved-aspects
contain aspect1.
- No new public accessor name (overload
hasAspect, per decision). - No
neededBywork in v1 (v2). - Perf: zero extra pipeline runs — the in-context query is a lookup over the
owning entity's already-computed
__pathSetByScopebyproduct (module-system memoized). v2 replaces it with a direct node-attribute read.