diff --git a/nix/lib/aspects/fx/resolve.nix b/nix/lib/aspects/fx/resolve.nix index 0d5a4621..0421a6a6 100644 --- a/nix/lib/aspects/fx/resolve.nix +++ b/nix/lib/aspects/fx/resolve.nix @@ -621,6 +621,12 @@ let bindings = { ${ownKind} = ownRecord; }; + # The requesting scope's OWN raw imports — spawnNode surfaces its + # quirk emits DOWN into the projected consumer (dual of the + # `quirkEmits` surfacing UP into importsForPipes below), so a + # user's directly-included quirk reaches its host-aspects-projected + # homeManager consumer. + requestingImports = scopedClassImportsRaw.${scopeId} or { }; } ); }; diff --git a/nix/lib/aspects/fx/spawn-node.nix b/nix/lib/aspects/fx/spawn-node.nix index 4c532fc0..0983af37 100644 --- a/nix/lib/aspects/fx/spawn-node.nix +++ b/nix/lib/aspects/fx/spawn-node.nix @@ -33,6 +33,7 @@ in class, aspect, bindings ? { }, + requestingImports ? { }, }: let normalized = normalizeRoot aspect; @@ -90,11 +91,33 @@ in _: scopeClasses: builtins.removeAttrs scopeClasses strippableNames ) (result.state.scopedClassImports null); + # The requesting (user) scope's OWN quirk emits — the DOWNWARD dual of the + # `quirkEmits` surfacing at the return below. The projected consumer sits + # under this spawn root, whose parent chain is `from` (the host), so it never + # traverses the requesting user scope; its collection of the user's own + # directly-included quirk emits (e.g. a user aspect's homeManager overlay) + # would otherwise read []. Merge them into the spawn root's imports for + # assembly ONLY — never into `spawnedClassImports`, which feeds the upward + # `quirkEmits` return (folding them there double-counts the emit back at the + # requesting scope). Host-bound quirks are excluded: they inherit the host's + # policy-assembled value, matching the strip above. + requestingQuirkEmits = lib.filterAttrs ( + k: v: (pipeNamesSet ? ${k}) && v != [ ] && !(builtins.elem k strippableNames) + ) requestingImports; + spawnRootImports = spawnedClassImports.${spawnRoot} or { }; + spawnRootWithRequesting = + spawnRootImports // lib.mapAttrs (k: v: (spawnRootImports.${k} or [ ]) ++ v) requestingQuirkEmits; + # 2. Merge parent state (host + siblings) under the spawned subtree, linking # the spawn root up to `from` so scopeParent walks reach the host's # policy-bound pipes and collectAll scans the fleet siblings. mergedScopeContexts = parentState.scopeContexts // (result.state.scopeContexts null); - mergedClassImports = parentState.scopedClassImports // spawnedClassImports; + mergedClassImports = + parentState.scopedClassImports + // spawnedClassImports + // { + ${spawnRoot} = spawnRootWithRequesting; + }; mergedScopeParent = parentState.scopeParent // (result.state.scopeParent null) diff --git a/templates/ci/modules/public-api/user-class-quirk-exposure.nix b/templates/ci/modules/public-api/user-class-quirk-exposure.nix new file mode 100644 index 00000000..d4a23b11 --- /dev/null +++ b/templates/ci/modules/public-api/user-class-quirk-exposure.nix @@ -0,0 +1,105 @@ +# A quirk produced within a user's class context must reach that user's +# homeManager consumer — including when the consumer arrives via the +# host-aspects projection (den.batteries.host-aspects), symmetric with how a +# host's nixos-aspect quirk reaches the host's nixos consumer. +# +# Repro for: a user-scope homeManager quirk emit does not reach a homeManager +# consumer that was projected onto the user via host-aspects. The consumer sees +# only host-scope emits (re-emitted host -> home), never the user's own. +{ denTest, lib, ... }: +{ + flake.tests.user-class-quirk-exposure = { + + # Baseline: host aspect emits a quirk, host's nixos consumer reads it + # locally — no pipe policy. host <-> nixos works. + test-host-quirk-local-nixos = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.quirks.hostvals.description = "host values"; + + den.aspects.igloo.includes = [ + den.aspects.host-emitter + den.aspects.host-consumer + ]; + den.aspects.host-emitter.hostvals = [ "h" ]; + den.aspects.host-consumer.nixos = + { + hostvals ? [ ], + ... + }: + { + networking.hostName = lib.concatStringsSep "-" hostvals; + }; + + expr = igloo.networking.hostName; + expected = "h"; + } + ); + + # Baseline: user aspect emits a quirk, user's OWN homeManager consumer reads + # it locally — no pipe policy. user <-> homeManager works when both are in + # the same directly-included user scope. + test-user-quirk-local-home = denTest ( + { den, tuxHm, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.default.homeManager.home.stateVersion = "25.11"; + den.quirks.hmvals.description = "hm values"; + + den.aspects.tux.includes = [ + den.aspects.hm-emitter + den.aspects.hm-consumer + ]; + den.aspects.hm-emitter.hmvals = [ "u" ]; + den.aspects.hm-consumer.homeManager = + { + hmvals ? [ ], + ... + }: + { + home.sessionVariables.HMVALS = lib.concatStringsSep "-" hmvals; + }; + + expr = tuxHm.home.sessionVariables.HMVALS or "MISSING"; + expected = "u"; + } + ); + + # THE BUG (minimal nix-config repro). The homeManager consumer reaches the + # user via den.batteries.host-aspects (users/sini.nix includes it), i.e. the + # consumer is host-aspects-PROJECTED. A user-scope quirk emit (fenix) must + # reach that projected consumer. It does not: the consumer reads an empty + # collection. + test-user-quirk-into-host-aspects-projected-consumer = denTest ( + { den, tuxHm, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.default.homeManager.home.stateVersion = "25.11"; + den.quirks.hmvals.description = "hm values"; + + # Host aspect's homeManager body IS the consumer, projected to opted-in + # users via host-aspects (the core.nix.nixpkgs analog). + den.aspects.igloo.homeManager = + { + hmvals ? [ ], + ... + }: + { + home.sessionVariables.HMVALS = lib.concatStringsSep "-" hmvals; + }; + + # tux opts into the host-aspects projection AND emits a user-scope quirk. + den.aspects.tux.includes = [ + den.batteries.host-aspects + den.aspects.user-emitter + ]; + den.aspects.user-emitter.hmvals = [ "user" ]; + + expr = tuxHm.home.sessionVariables.HMVALS or "MISSING"; + expected = "user"; + } + ); + + }; +}