From a7d10cf32082b6154a3c425de342971e0936ae23 Mon Sep 17 00:00:00 2001 From: Alessio Caiazza Date: Wed, 29 Jul 2026 13:43:36 +0200 Subject: [PATCH 1/2] fix: give cross-host homes distinct ids Two standalone homes sharing a username but bound to different hosts had identical id_hash values, so id_hash-keyed entity resolution could not distinguish them. Use the registry key and system as the explicit home identity. This keeps identity independent of user-overridable presentation metadata while preserving uniqueness for the same registry key across systems. AI-assisted: GitLab Duo Agentic Chat (GPT-5.6 Sol) --- nix/lib/entities/_types.nix | 13 ++++++ nix/lib/entities/home.nix | 8 ++++ .../home-samename-crosshost-idhash.nix | 42 +++++++++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 templates/ci/modules/deadbugs/home-samename-crosshost-idhash.nix diff --git a/nix/lib/entities/_types.nix b/nix/lib/entities/_types.nix index e0732223f..073a3049c 100644 --- a/nix/lib/entities/_types.nix +++ b/nix/lib/entities/_types.nix @@ -118,6 +118,19 @@ let kind: { config, ... }: { + # Injective scope identity, consumed by fx.pipeline's mkScopeId. Defaults + # to `name`, which is the registry key for most kinds. A kind that rewrites + # `name` into something non-unique (home forces it to the bare user name) + # MUST override this with its registry key, or two such entities collapse + # onto one pipeline scope and merge each other's content. + options.__scopeName = lib.mkOption { + description = "Registry key of this ${kind}, used as its internal identity."; + internal = true; + visible = false; + type = lib.types.str; + default = config.name; + defaultText = "config.name"; + }; options.resolved = lib.mkOption { description = "The resolved aspect for this ${kind}."; readOnly = true; diff --git a/nix/lib/entities/home.nix b/nix/lib/entities/home.nix index 0f292a939..26efed825 100644 --- a/nix/lib/entities/home.nix +++ b/nix/lib/entities/home.nix @@ -101,7 +101,15 @@ let { # mkInstanceType defaults name to the registry key (e.g. "tux@igloo"); # den's name is the bare user name, so identity/description stay stable. + # That also makes `name` non-unique across homes (two `user@host` homes + # on one system share it), so the registry key is kept as the scope + # identity — see __scopeName in ./_types.nix. config.name = lib.mkForce userName; + config.__scopeName = name; + config._identity.keys = [ + "__scopeName" + "system" + ]; config._module.args.host = hostCtx; config._module.args.user = userByName; options = { diff --git a/templates/ci/modules/deadbugs/home-samename-crosshost-idhash.nix b/templates/ci/modules/deadbugs/home-samename-crosshost-idhash.nix new file mode 100644 index 000000000..1dd747e50 --- /dev/null +++ b/templates/ci/modules/deadbugs/home-samename-crosshost-idhash.nix @@ -0,0 +1,42 @@ +# Regression: two STANDALONE homes sharing a username but bound to different +# hosts (`user@hostA`, `user@hostB`) hashed to an identical `id_hash`. +# +# A home's public `name` is force-set to the bare user name, so it cannot +# distinguish these homes. Identity is instead keyed on `__scopeName` (the +# registry key, e.g. `user@hostA`) plus `system` via `_identity.keys` (see +# nix/lib/entities/home.nix), never on reflected, user-overridable presentation +# fields like `description`. +{ denTest, ... }: +{ + flake.tests.home-samename-crosshost-idhash = { + + # Same username, different host binding: id_hash must differ. + test-crosshost-distinct-id-hash = denTest ( + { den, ... }: + { + den.homes.aarch64-darwin."someuser@hostA" = { }; + den.homes.aarch64-darwin."someuser@hostB" = { }; + + expr = + den.homes.aarch64-darwin."someuser@hostA".id_hash + == den.homes.aarch64-darwin."someuser@hostB".id_hash; + expected = false; + } + ); + + # The same registry key on different systems is also a distinct home. + test-cross-system-distinct-id-hash = denTest ( + { den, ... }: + { + den.homes.aarch64-darwin."someuser@hostA" = { }; + den.homes.x86_64-linux."someuser@hostA" = { }; + + expr = + den.homes.aarch64-darwin."someuser@hostA".id_hash + == den.homes.x86_64-linux."someuser@hostA".id_hash; + expected = false; + } + ); + + }; +} From 3fcaf5c02c04c2384de41ee4a4660e08cd1b6662 Mon Sep 17 00:00:00 2001 From: Alessio Caiazza Date: Wed, 29 Jul 2026 14:40:58 +0200 Subject: [PATCH 2/2] fix: give same-name entities distinct scope ids Two standalone homes sharing a username but bound to different hosts (user@hostA, user@hostB) collapsed onto a single pipeline scope: mkScopeId rendered an entity value as v.name, and a home's name is force-set to the bare user name, so both rendered home=user. Their collected class content merged and each flake output yielded the other's configuration. Entities now expose __scopeName, their registry key, which is unique per kind per system. mkScopeId prefers it and falls back to name, so only kinds that rewrite name behave differently, and scope ids stay readable in traces and error messages. This is a separate keying layer from the parent commit's id_hash fix, and neither alone is sufficient: id_hash keys the edge layer, where entity scopes are named ":" and same-name siblings collapse by design, while mkScopeId keys the resolution pipeline and never read id_hash. The added regression suite therefore needs both commits. AI-assisted: Claude Code (opus-5) --- nix/lib/aspects/fx/pipeline.nix | 15 +- .../home-samename-crosshost-scope.nix | 179 ++++++++++++++++++ 2 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 templates/ci/modules/deadbugs/home-samename-crosshost-scope.nix diff --git a/nix/lib/aspects/fx/pipeline.nix b/nix/lib/aspects/fx/pipeline.nix index 6c99d598e..c58f85954 100644 --- a/nix/lib/aspects/fx/pipeline.nix +++ b/nix/lib/aspects/fx/pipeline.nix @@ -107,6 +107,17 @@ let # mkScopeId: injective scope identity from a context attrset. # Produces a canonical comma-separated "key=value" string, sorted by key. + # + # An entity's `name` is NOT always injective across entities of one kind: a + # home's `name` is force-set to the bare user name + # (nix/lib/entities/home.nix), so two standalone homes `user@hostA` / + # `user@hostB` both rendered `home=user` and collapsed onto ONE scope — their + # collected class content merged and each flake output yielded the other's + # configuration. Entities therefore expose `__scopeName`, their registry key + # (see nix/lib/entities/_types.nix), which is unique per kind per system. + # It defaults to `name`, so only kinds that rewrite `name` differ here. + # Synthetic context values (e.g. a bare `{ name = ...; }` host) carry no + # `__scopeName` and fall back to `name`. mkScopeId = ctx: lib.concatStringsSep "," ( @@ -117,7 +128,9 @@ let v = ctx.${k}; in "${k}=${ - if builtins.isAttrs v && v ? name then + if builtins.isAttrs v && v ? __scopeName then + v.__scopeName + else if builtins.isAttrs v && v ? name then v.name else if builtins.isString v then v diff --git a/templates/ci/modules/deadbugs/home-samename-crosshost-scope.nix b/templates/ci/modules/deadbugs/home-samename-crosshost-scope.nix new file mode 100644 index 000000000..3a21d405a --- /dev/null +++ b/templates/ci/modules/deadbugs/home-samename-crosshost-scope.nix @@ -0,0 +1,179 @@ +# Regression: two STANDALONE homes sharing a username but bound to different +# hosts (`user@hostA`, `user@hostB`) collapsed onto ONE pipeline scope, so each +# flake output yielded the other's configuration. +# +# `mkScopeId` (nix/lib/aspects/fx/pipeline.nix) rendered an entity as `v.name`, +# but a home's `name` is force-set to the bare user name, so both rendered +# `home=someuser` and shared a scope. Fix: entities expose `__scopeName` (their +# registry key) and mkScopeId reads that, falling back to `name`. +# +# The `provides.` cases are downstream symptoms of the same collapse; +# `test-two-targets-single-home` is the control proving the dispatch itself is +# sound. Markers are separate `sessionVariables` keys so a leak surfaces as an +# extra attribute rather than a merge conflict. +{ denTest, ... }: +{ + flake.tests.home-samename-crosshost-scope = { + + # The core defect, with no provides involved: each home must resolve its own + # host context, not the first-declared home's. + test-same-username-homes-resolve-independently = denTest ( + { config, den, ... }: + { + den.homes.x86_64-linux."someuser@hostA" = { }; + den.homes.x86_64-linux."someuser@hostB" = { }; + + den.aspects.someuser.homeManager = + { home, ... }: + { + home = { + username = "someuser"; + homeDirectory = "/home/someuser"; + sessionVariables.SAW_HOST = home.hostName; + }; + }; + + expr = + let + sawHost = key: config.flake.homeConfigurations.${key}.config.home.sessionVariables.SAW_HOST; + in + { + hostA = sawHost "someuser@hostA"; + hostB = sawHost "someuser@hostB"; + }; + expected = { + hostA = "hostA"; + hostB = "hostB"; + }; + } + ); + + # No `den.hosts` at all: both homes synthesize their host identity from the + # `user@host` key, so only the home entities are in play. + test-synthetic-host-provides-no-cross-contamination = denTest ( + { config, den, ... }: + { + den.homes.x86_64-linux."someuser@hostA" = { }; + den.homes.x86_64-linux."someuser@hostB" = { }; + + den.aspects.someuser.homeManager.home = { + username = "someuser"; + homeDirectory = "/home/someuser"; + }; + den.aspects.someuser.provides = { + hostA.homeManager.home.sessionVariables.FROM_A = "a"; + hostB.homeManager.home.sessionVariables.FROM_B = "b"; + }; + + expr = + let + varsOf = key: config.flake.homeConfigurations.${key}.config.home.sessionVariables; + a = varsOf "someuser@hostA"; + b = varsOf "someuser@hostB"; + in + { + hostA = { + own = a.FROM_A or "MISSING"; + other = a.FROM_B or "MISSING"; + }; + hostB = { + own = b.FROM_B or "MISSING"; + other = b.FROM_A or "MISSING"; + }; + }; + expected = { + hostA = { + own = "a"; + other = "MISSING"; + }; + hostB = { + own = "b"; + other = "MISSING"; + }; + }; + } + ); + + # Both hosts declared, each with the same user. The users keep + # `classes = [ "user" ]` so the hosts do not also build inline home-manager — + # the standalone homes are the delivery path under test. + test-declared-host-provides-no-cross-contamination = denTest ( + { config, den, ... }: + { + den.hosts.x86_64-linux.hostA.users.someuser.classes = [ "user" ]; + den.hosts.x86_64-linux.hostB.users.someuser.classes = [ "user" ]; + + den.homes.x86_64-linux."someuser@hostA" = { }; + den.homes.x86_64-linux."someuser@hostB" = { }; + + den.aspects.someuser.homeManager.home = { + username = "someuser"; + homeDirectory = "/home/someuser"; + }; + den.aspects.someuser.provides = { + hostA.homeManager.home.sessionVariables.FROM_A = "a"; + hostB.homeManager.home.sessionVariables.FROM_B = "b"; + }; + + expr = + let + varsOf = key: config.flake.homeConfigurations.${key}.config.home.sessionVariables; + a = varsOf "someuser@hostA"; + b = varsOf "someuser@hostB"; + in + { + hostA = { + own = a.FROM_A or "MISSING"; + other = a.FROM_B or "MISSING"; + }; + hostB = { + own = b.FROM_B or "MISSING"; + other = b.FROM_A or "MISSING"; + }; + }; + expected = { + hostA = { + own = "a"; + other = "MISSING"; + }; + hostB = { + own = "b"; + other = "MISSING"; + }; + }; + } + ); + + # Control: the same two-target aspect with only ONE home consuming it — the + # `provides.` dispatch itself was never the defect. + test-two-targets-single-home = denTest ( + { config, den, ... }: + { + den.homes.x86_64-linux."someuser@hostB" = { }; + + den.aspects.someuser.homeManager.home = { + username = "someuser"; + homeDirectory = "/home/someuser"; + }; + den.aspects.someuser.provides = { + hostA.homeManager.home.sessionVariables.FROM_A = "a"; + hostB.homeManager.home.sessionVariables.FROM_B = "b"; + }; + + expr = + let + vars = config.flake.homeConfigurations."someuser@hostB".config.home.sessionVariables; + in + { + own = vars.FROM_B or "MISSING"; + other = vars.FROM_A or "MISSING"; + }; + expected = { + own = "b"; + other = "MISSING"; + }; + } + ); + + }; +}