Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

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

Select an option

Save sini/4547d2e7db0c0bcf2ce8871e21aee7cc to your computer and use it in GitHub Desktop.
den v1 fx-pipeline: core-resolver host/user decoupling — class-local relationship resolution (fixes denful/den#609)

Core-resolver host/user decoupling — class-local relationship resolution

  • 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 homeManager silently delivered only when the aspect is user-parametric) + an audit finding that the core resolver hardcodes host/user/homeManager.

1. Problem

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.

1.2 The correct reading (maintainer)

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.

1.3 The fundamental failure

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

2. The formal rule (the arbiter)

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_S does not resolve is inert, silently. Cross-entity delivery is the explicit job of provides/policy/the host-aspects battery.
  • Every term (K_S, K_a, ancestor/descendant, kind.classes) derives from the schema. No host/user/homeManager literal 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).

2.1 Worked example (#609)

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.

3. Components

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:

  1. Arg classifier — pure (K_S, argKind, schema) → {ctx | descendant | pipe | conditional | misplaced}. The decision bind makes implicitly and wrongly today (it lumps descendant-entity args with pipe/conditional args and defers them cross-scope).
  2. 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> (the K_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, with currentScope still = 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→walkDeferred chain for entity args.
  3. Decoupled explicit projection — the host→user homeManager projection (host-aspects battery; resolve.nix:700-742 with sctx.host/sctx.user/"homeManager") is reframed as "splice the parent's aspects into each child's include tree, emitted for the child" — a provides-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."

4. Code-change map

  • Modify: handlers/bind.nix (classifier + fan-out path; the single decision point), handlers/resolve-schema-entity.nix (drop walkDeferred cross-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, not host).
  • Remove: modules/context/perHost-perUser.nix.
  • Add: the arg classifier (pure fn over schema DAG); a schema child-collection accessor if not already exposed.

5. Phase 0 — carrier census (hard gate)

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:80walkDeferred refire at the child scope.
  • second, unpinned carrier (survived disabling push-scope inheritance) — suspects: scope-widen/widen-context, the scopedDeferredConditionals drain, or schema user-resolution pulling host includes.
  • resolve.nix:700-742host-aspects spawn 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.

6. Behaviour-classification protocol

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:

  1. 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).
  2. Record the classification + rationale (one line per flipped expectation) in the implementation plan.
  3. 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 from perCtx/perHost/perUser to plain { user, … }:; the nixos fan-out expectations stay, the homeManager projection expectations flip to inert (artifact of the bug).
  • issue-609 acceptance: homeManagerMISSING for all shapes; nixos → per-user on host.

7. Parity / acceptance gates

  • Per-user nixos relationship preserved (migrated perCtx tests pass for nixos).
  • Policies / provides / user-host-mutual-config unaffected (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.

8. Out of scope

  • gen-bind migration. Different layer (module-arg injection in class-module.nix, not the fx bind handler's relationship resolution); zero current den usage; belongs with the gen-ecosystem / v2 effort. No sequencing dependency either way.

9. Risks / open questions

  • Second carrier unknown until §5 closes — sizes the remove set 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-aspects re-walk decoupling must preserve fleet-pipe visibility (the re-walk threads host+sibling state today) — verify the decoupled form keeps it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment