From 98e838956d980bbf8d531a06330efb190778d038 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 17 Jul 2026 12:06:34 -0700 Subject: [PATCH] fix: surface the requesting scope's own quirk emits into host-aspects spawns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem A quirk produced within a user's class context (a homeManager aspect's bare quirk key) does not reach that user's homeManager consumer when the consumer is projected onto the user via `den.batteries.host-aspects`. The projected consumer sees only host-scope emits (re-emitted host -> home), never the user's own — asymmetric with host <-> nixos, where a host's quirk reaches the host's nixos consumer directly. (Surfaced in nix-config: an overlay declared on a user-scoped `homeManager` aspect never reached that user's `nixpkgs.overlays`, so `pkgs.` from the overlay was undefined in the user's HM.) ## Cause The host-aspects `policy.spawn` roots the projected consumer with `from` = the requesting scope's parent = the host scope, and links the spawn root's parent to `from`. So the consumer's quirk collection walks the *host* parent chain and never traverses the requesting *user* scope — its own directly-included quirk emits are outside the walk. #625 added the UPWARD half (surface the spawn's emits up into the requesting scope's imports); the DOWNWARD half was missing. ## Fix The downward dual of #625. `spawn-node.nix` takes the requesting scope's own raw imports (`requestingImports`) and merges its non-host-bound quirk emits into the spawn root's imports used for assembly (`mergedClassImports`) — but NOT into `spawnedClassImports`, which feeds the upward `quirkEmits` return (folding them there would double-count the emit back at the requesting scope). Host-bound quirks are excluded (they inherit the host's policy-assembled value, matching the existing strip). `resolve.nix` passes `scopedClassImportsRaw.` through as `requestingImports`. `from` = host is untouched, so #625's fleet-peer collection is preserved. ## Tests New suite `user-class-quirk-exposure` (host<->nixos + user<->home baselines + the host-aspects-projected-consumer repro). Full suite green: 1057/1057. --- nix/lib/aspects/fx/resolve.nix | 6 + nix/lib/aspects/fx/spawn-node.nix | 25 ++++- .../public-api/user-class-quirk-exposure.nix | 105 ++++++++++++++++++ 3 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 templates/ci/modules/public-api/user-class-quirk-exposure.nix 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"; + } + ); + + }; +}