user-class forwarder ignores config arriving via provides.to-users
Summary
Config written to the user class via provides.to-users (or provides.<userName>) from a host aspect is silently dropped — it never reaches users.users.<userName>. The same pattern works correctly for the homeManager class.
The root cause is in modules/aspects/provides/os-user.nix: the forwarder reads user.aspect directly, bypassing den.ctx.user and therefore missing everything that mutual-provider contributes (including provides.to-users). The homeManager forwarder, in contrast, routes through userEnvAspect in nix/lib/home-env.nix which explicitly includes den.ctx.user.
Minimal repro
A host aspect declares both homeManager and user class config, both via provides.to-users:
den.aspects.igloo = {
provides.to-users.homeManager.programs.jq.enable = true; # works
provides.to-users.user.extraGroups = [ "input" ]; # dropped
};
Evaluating the host:
config.home-manager.users.<u>.programs.jq.enable → true
config.users.users.<u>.extraGroups → does not contain "input"
Direct use on the user aspect (not via to-users) works:
den.aspects.tux.user.extraGroups = [ "input" ]; # works — ends up in users.users.tux.extraGroups
Requires den.schema.user.classes to include "user" (it does in my setup).
Root cause
In modules/aspects/provides/os-user.nix:
fwd = { user, host }: den.provides.forward {
each = lib.singleton user;
fromClass = _: "user";
intoClass = _: host.class;
intoPath = _: [ "users" "users" user.userName ];
fromAspect = _: den.lib.parametric.fixedTo { inherit host user; } user.aspect; # <-- reads user.aspect directly
adaptArgs = args: args // { osConfig = args.config; };
};
den.ctx.user.includes = [ fwd ];
user.aspect is just the named user aspect (e.g. den.aspects.tux). It does not include contributions from den.ctx.user.includes — specifically, it does not include what mutual-provider forwards from host aspects via provides.to-users, provides.to-hosts, or provides.<userName>.
Compare to nix/lib/home-env.nix (the homeManager forwarder):
userEnvAspect = ctxName: { host, user }: { class, aspect-chain }: {
includes = [
(den.ctx."${ctxName}-user" { inherit host user; })
(den.ctx.user { inherit host user; }) # <-- full user context, including mutual-provider
];
};
forwardToHost = { className, ctxName, forwardPathFn }: { host, user }:
den.provides.forward {
...
fromAspect = _: userEnvAspect ctxName { inherit host user; }; # <-- uses userEnvAspect
};
homeManager reads the full den.ctx.user, so provides.to-users.homeManager.* lands correctly. user reads the raw aspect and misses everything mutual-provider contributes.
Suggested fix
Change fromAspect in os-user.nix so it reads the resolved user context rather than the raw aspect. The simplest swap that matches the homeManager path:
fromAspect = _: den.ctx.user { inherit host user; };
Or, if a self-referential dependency is a concern there, factor out a helper analogous to userEnvAspect that's usable by both paths.
Why this matters
provides.to-users is the documented way for a host aspect to contribute user-scoped config without hardcoding usernames. Today that works for HM-class config but not for OS-level user config (extraGroups, packages, shell, etc.) that the user class is supposed to forward. Users end up with users.users.<hardcoded-name> in host aspects, which re-introduces exactly the coupling the user class exists to remove.
Environment
- den rev:
0696fb8b155f1829f8bfe37013c54ab6ee80abbe
- nixpkgs:
nixos-unstable (as of 2026-04-19)
- flake using
flake-parts + import-tree
den.schema.user.classes = [ "homeManager" "user" ]
Repro flake
Happy to provide a self-contained minimal flake if the above doesn't reproduce.
user-class forwarder ignores config arriving viaprovides.to-usersSummary
Config written to the
userclass viaprovides.to-users(orprovides.<userName>) from a host aspect is silently dropped — it never reachesusers.users.<userName>. The same pattern works correctly for thehomeManagerclass.The root cause is in
modules/aspects/provides/os-user.nix: the forwarder readsuser.aspectdirectly, bypassingden.ctx.userand therefore missing everything thatmutual-providercontributes (includingprovides.to-users). ThehomeManagerforwarder, in contrast, routes throughuserEnvAspectinnix/lib/home-env.nixwhich explicitly includesden.ctx.user.Minimal repro
A host aspect declares both
homeManageranduserclass config, both viaprovides.to-users:Evaluating the host:
config.home-manager.users.<u>.programs.jq.enable→trueconfig.users.users.<u>.extraGroups→ does not contain"input"Direct use on the user aspect (not via
to-users) works:Requires
den.schema.user.classesto include"user"(it does in my setup).Root cause
In
modules/aspects/provides/os-user.nix:user.aspectis just the named user aspect (e.g.den.aspects.tux). It does not include contributions fromden.ctx.user.includes— specifically, it does not include whatmutual-providerforwards from host aspects viaprovides.to-users,provides.to-hosts, orprovides.<userName>.Compare to
nix/lib/home-env.nix(thehomeManagerforwarder):homeManagerreads the fullden.ctx.user, soprovides.to-users.homeManager.*lands correctly.userreads the raw aspect and misses everything mutual-provider contributes.Suggested fix
Change
fromAspectinos-user.nixso it reads the resolved user context rather than the raw aspect. The simplest swap that matches thehomeManagerpath:Or, if a self-referential dependency is a concern there, factor out a helper analogous to
userEnvAspectthat's usable by both paths.Why this matters
provides.to-usersis the documented way for a host aspect to contribute user-scoped config without hardcoding usernames. Today that works for HM-class config but not for OS-level user config (extraGroups,packages,shell, etc.) that theuserclass is supposed to forward. Users end up withusers.users.<hardcoded-name>in host aspects, which re-introduces exactly the coupling theuserclass exists to remove.Environment
0696fb8b155f1829f8bfe37013c54ab6ee80abbenixos-unstable(as of 2026-04-19)flake-parts+import-treeden.schema.user.classes = [ "homeManager" "user" ]Repro flake
Happy to provide a self-contained minimal flake if the above doesn't reproduce.