Skip to content

BUG: multiple standalone user@host homes sharing a user aspect resolve host-keyed config against the first home only #635

Description

@blevinn-claude

Summary

When several standalone user@host homes share one user aspect, all host-conditional configuration in that aspect resolves against only the first home's host binding (first in attrset order). The single resolution is then reused for every sibling home:

  • Content keyed to the first host leaks into every other home.
  • Content keyed to any later host is silently dropped everywhere — including in the home actually bound to that host.

All three host-conditioning mechanisms are affected identically: provides.<host>, den.lib.policy.when ({ host, ... }: ...), and a bare parametric include gated on host.name. Declared-host (den.hosts) resolution is not affected — provides.<host> on a user aspect routes to the right nixosConfigurations and never crosses hosts.

Reproduced on 1614f6f (fix #627) and current main 99cc0c5a1cc846cb1be681344b10d2731d430e13.

The existing dead-bug suite (templates/ci/modules/deadbugs/standalone-home-host-context.nix, added with #605) covers each pattern with a single home, which is why CI stays green.

Minimal reproduction

{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/0bb7ec54c8483066ec9d7720e780a5caa71f8612";
    flake-parts.url = "github:hercules-ci/flake-parts";
    home-manager.url = "github:nix-community/home-manager";
    home-manager.inputs.nixpkgs.follows = "nixpkgs";
    den.url = "github:denful/den";
  };
  outputs = inputs: inputs.flake-parts.lib.mkFlake { inherit inputs; } (
    { lib, den, ... }:
    {
      systems = [ "x86_64-linux" ];
      imports = [ inputs.den.flakeModule ];
      den = {
        default.homeManager.home.stateVersion = "25.11";

        # A: host-named provides, two homes
        homes.x86_64-linux."ben@alpha" = { };
        homes.x86_64-linux."ben@beta" = { };
        aspects.ben = {
          homeManager.home = { username = "ben"; homeDirectory = "/home/ben"; };
          provides.alpha.homeManager.programs.zsh.profileExtra = "alpha-only";
        };

        # D: policy.when gate
        homes.x86_64-linux."carl@alpha" = { };
        homes.x86_64-linux."carl@beta" = { };
        aspects.carl = {
          homeManager.home = { username = "carl"; homeDirectory = "/home/carl"; };
          includes = [
            (den.lib.policy.when ({ host, ... }: host.name == "alpha")
              (_: den.lib.policy.include { homeManager.programs.zsh.profileExtra = "alpha-only"; }))
          ];
        };

        # E: parametric include
        homes.x86_64-linux."dana@alpha" = { };
        homes.x86_64-linux."dana@beta" = { };
        aspects.dana = {
          homeManager.home = { username = "dana"; homeDirectory = "/home/dana"; };
          includes = [
            ({ host, ... }: lib.optionalAttrs (host.name == "alpha") {
              homeManager.programs.zsh.profileExtra = "alpha-only";
            })
          ];
        };

        # F: keys on both hosts — first-wins, not union
        homes.x86_64-linux."fred@alpha" = { };
        homes.x86_64-linux."fred@beta" = { };
        aspects.fred = {
          homeManager.home = { username = "fred"; homeDirectory = "/home/fred"; };
          provides.alpha.homeManager.programs.zsh.profileExtra = "from-alpha";
          provides.beta.homeManager.programs.zsh.envExtra = "from-beta";
        };

        # G: key on the second host only — dropped everywhere
        homes.x86_64-linux."gina@alpha" = { };
        homes.x86_64-linux."gina@beta" = { };
        aspects.gina = {
          homeManager.home = { username = "gina"; homeDirectory = "/home/gina"; };
          provides.beta.homeManager.programs.zsh.profileExtra = "beta-only";
        };

        # C: declared hosts — NOT affected (control)
        hosts.x86_64-linux.hosta.users.erin = { };
        hosts.x86_64-linux.hostb.users.erin = { };
        default.nixos.system.stateVersion = "25.11";
        default.nixos.boot.loader.grub.enable = false;
        aspects.erin = {
          homeManager.home = { username = "erin"; homeDirectory = "/home/erin"; };
          provides.hosta.nixos.networking.enableIPv6 = false;
        };
      };
    }
  );
}

Observed vs expected

eval (config.programs.zsh.profileExtra unless noted) observed expected
A ben@alpha alpha-only alpha-only
A ben@beta alpha-only ""
D carl@alpha / E dana@alpha alpha-only alpha-only
D carl@beta / E dana@beta alpha-only ""
F fred@alpha from-alpha (envExtra "") same
F fred@beta from-alpha, envExtra "" "" / from-beta
G gina@alpha "" ""
G gina@beta "" beta-only
C hosta networking.enableIPv6 false false
C hostb networking.enableIPv6 true true

Case G is the sharpest form: the only home that should receive the config gets nothing, because the shared resolution was made against alpha's binding.

Context

Found while restructuring a fleet (one user across a NixOS host plus two user@host standalone homes) to use the documented mutual-provides paradigm ("Standalone HomeManager - Host Specific Configuration" in the mutual guide, and provides.<host> in the home-manager guide). With more than one home per user aspect, per-host home configuration is currently inexpressible; identical bundles delivered under several host keys still work only because the first host's key covers the rest by accident.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions