Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions nix/lib/aspects/fx/resolve.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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 { };
}
);
};
Expand Down
25 changes: 24 additions & 1 deletion nix/lib/aspects/fx/spawn-node.nix
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ in
class,
aspect,
bindings ? { },
requestingImports ? { },
}:
let
normalized = normalizeRoot aspect;
Expand Down Expand Up @@ -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)
Expand Down
105 changes: 105 additions & 0 deletions templates/ci/modules/public-api/user-class-quirk-exposure.nix
Original file line number Diff line number Diff line change
@@ -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";
}
);

};
}
Loading