Skip to content

Instantly share code, notes, and snippets.

@sini
Last active June 9, 2026 16:10
Show Gist options
  • Select an option

  • Save sini/e84e088c9eba4d847fffb5c3f793fa98 to your computer and use it in GitHub Desktop.

Select an option

Save sini/e84e088c9eba4d847fffb5c3f793fa98 to your computer and use it in GitHub Desktop.
Den: projected hasAspect (v1 patch, HOAG-aligned)

Projected hasAspect (v1 patch, HOAG-aligned)

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.

Problem

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.

HOAG framing (why this design, not the others)

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 reach evalModules-time reads — necessary v1 bridge, kept private, deleted in v2.

Overload by provenance (user-facing semantics)

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

Design

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.

1. Per-scope path set (pathSetByScope)

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 pathSetidentity.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.

2. Surface pathSetByScope off the entity (the memoization point)

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) binds result = mkPipeline …, reads result.state.scopedClassImports null etc., then returns ONLY { imports = phase4.${class} or []; } (:748-750), discarding the rest of state. Extend it (or add a resolveWithPaths variant) to also surface result.state.pathSetByScope from that same fx.handle.
  • In _types.nix mainModuleOption, thread one shared call yielding both: mainModule = { imports = …; } AND a thunked read-only config.__pathSetByScope. Source both from the single result.
  • Declare __pathSetByScope internal = true; readOnly = true; type = lib.types.raw; with a lazy default — mirror the existing hasAspect option shape (modules/context/has-aspect.nix) so the module system never merges or eagerly forces it.

One run, module-system-memoized — "memoization into scope."

3. Projected hasAspect factory (a pure lookup, no run)

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.

4. In-context override

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:

  1. pathSetByScope is built in the structural walk (resolve-complete) without forcing class-module function bodies, so computing host.__pathSetByScope never forces the user's homeManager body (which holds the hasAspect thunk). The thunk forces host.__pathSetByScope back at evalModules, after the walk — the phase separation test-J relies on.
  2. The emitted module carrying the mkIf (host.__pathSetByScope …) condition lives inside state.scopedClassImports, which is thunked (default pipeline.nix:140; live thunked write class-collector.nix:50). The trampoline's per-step deepSeq (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).

Semantics: "projected" = delivered at-or-into this scope

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.

Tests

v1 (templates/ci/modules/deadbugs/):

  • Content-position: parametric body mkIf (user.hasAspect aspect1) with both aspects under igloo.provides.tuxtrue (the reported case).
  • Multi-user discrimination: tux has aspect1, pingu does not (+ a policy.exclude variant 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 true under the delivering host's path and false under the other. Locks the per-active-path keying (the load-bearing correctness property).
  • Provenance split lock: registry den.hosts.…​.users.tux.hasAspect stays false for a provides-delivered aspect (reuse the test-E-host-policyFn-not-visible-to-user-hasAspect shape) while the in-context parametric-body equivalent returns true — pin both halves of the overload in one place.
  • Includes-position misuse: in-context user.hasAspect used to decide an includes list re-enters and recurses (wrap in builtins.tryEval, assert success == 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.

Non-goals

  • No new public accessor name (overload hasAspect, per decision).
  • No neededBy work in v1 (v2).
  • Perf: zero extra pipeline runs — the in-context query is a lookup over the owning entity's already-computed __pathSetByScope byproduct (module-system memoized). v2 replaces it with a direct node-attribute read.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment