- Date: 2026-06-12
- Status: Design approved; not implementable until Phase 0 (carrier census) closes.
- Scope: den v1 (soon-legacy) fx-pipeline. Stabilize and shrink before HOAG so v1↔v2 parity is assertable.
- Triggers: denful/den#609 (host-included
homeManagersilently delivered only when the aspect is user-parametric) + an audit finding that the core resolver hardcodeshost/user/homeManager.
1.1 The bug (denful/den#609)
A homeManager aspect included at host scope is silently dropped — unless the aspect is parametric on { user, … }, in which case it is delivered to each user. Plain and { host, … } aspects drop; { user, … } delivers. Empirically (two-user host, parametric aspect carrying both nixos and homeManager markers): nixos lands on the host merged per-user ("alice\ntux") and homeManager lands on each user.
Plain-drop is correct: host-scope homeManager is not a user's content unless the user includes it or opts into the host-aspects battery. The bug is the inverse — the { user, … } variant accidentally delivers. It is emitted in the host's include tree, not the user's; the user has its own include tree.
The resolver conflates argument-binding with projection. A parametric aspect that cannot bind an entity-kind arg at its include scope is resolved at descendant scopes where the arg is available, and its output is emitted there (the binding scope) rather than at the emitting scope. "I need a user" silently becomes "apply me once per user, emitting on the user." This is also why den deprecated the explicit, intent-bearing projection markers (perCtx/perHost/perUser/perHome, modules/context/perHost-perUser.nix) in favour of implicit { user, … }: — trading away the ability to express whether an aspect projects.
The host/user/homeManager literals in the core (resolve.nix:713-722, policy/schema.nix:77) are downstream symptoms of the same conflation. The fan-out is additionally implemented through at least two redundant carriers (disabling the push-scope deferred-include inheritance does not stop it).
For a parametric aspect emitted at scope S (entity kind K_S) destructuring an arg of entity-kind K_a, classified against the schema DAG:
relationship of K_a to K_S |
behaviour |
|---|---|
K_a is K_S or an ancestor (in S's ctx) |
bind from ctx, resolve once at S |
K_a is a descendant of K_S |
relationship: fan out over S's K_a-children, refire bound to each, emit at S |
K_a is neither in ctx nor a descendant |
misplaced → inert |
Invariants:
- Emission is always at S, in S's resolving class(es) (
kind.classes). The bound entity is the source of the arg, never the output target. - Content in a class
K_Sdoes not resolve is inert, silently. Cross-entity delivery is the explicit job ofprovides/policy/thehost-aspectsbattery. - Every term (
K_S,K_a, ancestor/descendant,kind.classes) derives from the schema. Nohost/user/homeManagerliteral appears in the rule. The same rule governs environment→host and host→guest with no new code.
This rule is canonical. Tests, existing behaviour, and code are validated against it — not the reverse (see §6).
feature = { user, … } emitted in the host's includes. user is a descendant of host → fan out over the host's users, refire each, emit at the host in class nixos. nixos lands per-user on the host (correct). homeManager is inert (host does not resolve homeManager), so no user receives it. Each user's homeManager comes only from its own include tree.
The classifier runs per destructured arg; mechanisms compose because they are orthogonal dimensions:
| arg is… | mechanism | emit scope |
|---|---|---|
| in-ctx (self/ancestor) | bind from ctx, once | S |
| descendant entity kind | local fan-out over S's children (NEW) | S |
| pipe (post-assembly data) | keep defer → drain same scope |
S |
conditional / self (pathSet grows) |
keep defer-conditional → drain same scope |
S |
| misplaced / zero children | inert | — |
New / changed:
- Arg classifier — pure
(K_S, argKind, schema) → {ctx | descendant | pipe | conditional | misplaced}. The decisionbindmakes implicitly and wrongly today (it lumps descendant-entity args with pipe/conditional args and defers them cross-scope). - Local relationship fan-out — for a descendant arg, enumerate S's children of that kind from the entity record. The record is
scopeContexts[S].<K_S>(theK_S-keyed binding in S's ctx — i.e. the host/environment/… entity itself); the children are read off that record via the schema's child-collection accessor (host.users,host.guests). Refire once per child bound, withcurrentScopestill = S. Synchronous: a parametric body consumes the child record (user.userName), not the child's resolved config, so child scopes need not exist yet. Replaces the defer→inherit→drain-at-child→walkDeferredchain for entity args. - Decoupled explicit projection — the host→user
homeManagerprojection (host-aspectsbattery;resolve.nix:700-742withsctx.host/sctx.user/"homeManager") is reframed as "splice the parent's aspects into each child's include tree, emitted for the child" — aprovides-shaped op parametrized by(childKind, classes)from the schema. Same observable behaviour, no kind literals. Stays (it is the sanctioned cross-entity path).
Composition (mixed args): bind ctx args → fan out descendant args → residual per-child instances defer on any pipe/conditional args. Transitive descendants follow DAG nesting ({ host, user } at environment → for-each-host, for-each-user). Independent descendant branches → cartesian product (e.g. { user, guest } at host → every user × every guest); confirmed correct, falls out of the rule.
Kept untouched: defer/drain for pipes; defer-conditional/drain-conditionals for conditionals — same-scope waits, correct.
Removed (pending §5 census): cross-scope deferred-include path for entity args (push-scope inheritance + walkDeferred refire + the second carrier); the two literals (resolve.nix:713-722, policy/schema.nix:77); the deprecated modules/context/perHost-perUser.nix.
Headline: "wait for an entity that lives in a deeper scope" stops being deferral. It becomes a synchronous local loop. Deferral shrinks to its honest meaning — "wait for data not yet available at this scope."
- Modify:
handlers/bind.nix(classifier + fan-out path; the single decision point),handlers/resolve-schema-entity.nix(dropwalkDeferredcross-scope refire for entity args),handlers/push-scope.nix(drop entity-arg deferred inheritance; keep pipe/conditional),resolve.nix:700-742(rewrite projection re-walk decoupled),policy/schema.nix:77(generic root-owner, nothost). - Remove:
modules/context/perHost-perUser.nix. - Add: the arg classifier (pure fn over schema DAG); a schema child-collection accessor if not already exposed.
The design is not implementable until every cross-scope entity-arg fan-out path is enumerated, so the single synchronous fan-out replaces all of them rather than racing a survivor. Method: instrument the #609 repro; disable candidates one at a time; watch both the nixos(→host) and homeManager(→user) markers.
Known / suspect carriers:
push-scope.nix:72-78— parent→child deferred-include inheritance (confirmed; not the only one).resolve-schema-entity.nix:80—walkDeferredrefire at the child scope.- second, unpinned carrier (survived disabling push-scope inheritance) — suspects:
scope-widen/widen-context, thescopedDeferredConditionalsdrain, or schema user-resolution pulling host includes. resolve.nix:700-742—host-aspectsspawn re-walk → keep (decoupled).
Deliverable: complete carrier list partitioned remove (cross-scope entity-arg fan-out) vs keep (pipe/conditional same-scope defer; explicit host-aspects). The implementation plan's first task produces this census; nothing else starts until it closes.
Census exit criterion: the remove set is complete when disabling all of it together (a) drops homeManager delivery to zero across every #609 shape (plain, { user, … }, nested), and (b) preserves the nixos per-user landing on the host. (a) without (b) means a carrier was over-removed (the legitimate relationship); (b) without (a) means a carrier is still missing.
The current pipeline has bugs that pass tests, so a green test may encode incorrect behaviour. We do not chase coverage. For every test whose expectation changes under B:
- Classify the change against §2: correct (rule says the old expectation was right → B regressed → fix B) vs artifact (rule says the old expectation encoded a bug → update the expectation).
- Record the classification + rationale (one line per flipped expectation) in the implementation plan.
- No expectation flips silently in either direction. The rule is the arbiter.
Expected reclassifications (to validate, not assume):
templates/ci/modules/deprecated/perUser-perHost.nix,parametric-fixedTo.nix— migrate fromperCtx/perHost/perUserto plain{ user, … }:; thenixosfan-out expectations stay, thehomeManagerprojection expectations flip to inert (artifact of the bug).issue-609acceptance:homeManager→MISSINGfor all shapes;nixos→ per-user on host.
- Per-user
nixosrelationship preserved (migratedperCtxtests pass fornixos). - Policies /
provides/user-host-mutual-configunaffected (separate explicit relationship mechanism) — stays green. - Real-world parity: cortex toplevel builds; delivered-guest GPU parity byte-identical (vfio-gate derivation unchanged); axon/cortex
identityPaths = /persist/etc/ssh. - den CI green with the deprecated-fan-out tests migrated, not deleted, per §6.
- gen-bind migration. Different layer (module-arg injection in
class-module.nix, not the fxbindhandler's relationship resolution); zero current den usage; belongs with the gen-ecosystem / v2 effort. No sequencing dependency either way.
- Second carrier unknown until §5 closes — sizes the
removeset and the rework. - Child-collection accessor — confirm the schema exposes a uniform parent→children accessor per kind (
host.users,host.guests); add one if not. - Cartesian blow-up — sibling-branch products are intentional but could be large; acceptable (relationships are explicit in the aspect's destructure), but worth a note if a real config hits it.
host-aspectsre-walk decoupling must preserve fleet-pipe visibility (the re-walk threads host+sibling state today) — verify the decoupled form keeps it.