From a2dbe0270534b95c877858d38abdec1468f7a71a Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 31 Jul 2026 09:10:36 -0700 Subject: [PATCH 1/4] fix: don't re-declare forward adapters in host-aspects spawns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A custom class built with `_.forward` fails to evaluate when the `host-aspects` battery is also included: The option `home-manager.users..den.fwd."//"' is already declared #603 made a spawned node apply the parent pipeline's subtree routes, so a user-schema route (homeLinux->homeManager) fires against the content the spawn re-emits. Adapter-bearing routes are not safe under that rule: `mkAdapterAspect` / `mkAdapterFunctor` materialize an option DECLARATION, and the parent pipeline already materializes the same route at the same scope. Both folds land in one home-manager evaluation, so the second declaration is a hard conflict — content definitions merge, declarations do not. Exclude declaration-bearing parent routes from the spawn's route merge, leaving the parent's copy as the single owner. That copy is always there to take over: an excluded route is by construction inside the spawned subtree, and the redundant-root suppression only fires on a route at the fold root. Simple routes keep re-applying, preserving #603. Four regression cases (user-defined content, host-defined content, host-defined across two users, and a no-battery control); the three battery cases fail at the parent commit with the reported error. Reported in discussion #642. --- nix/lib/aspects/fx/spawn-node.nix | 43 ++++- .../issue-642-host-aspects-custom-class.nix | 152 ++++++++++++++++++ 2 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 templates/ci/modules/deadbugs/issue-642-host-aspects-custom-class.nix diff --git a/nix/lib/aspects/fx/spawn-node.nix b/nix/lib/aspects/fx/spawn-node.nix index 0983af37..da012881 100644 --- a/nix/lib/aspects/fx/spawn-node.nix +++ b/nix/lib/aspects/fx/spawn-node.nix @@ -161,6 +161,27 @@ in allScopeIds = spawnAllScopeIds; }) (_: true); + # A route that materializes an adapter DECLARES `options.den.fwd.` in + # the target bucket (handlers/forward.nix mkAdapterAspect, edges/route.nix + # mkAdapterFunctor). Content definitions merge; an option DECLARATION does + # not — a second one in the same evalModules is a hard "already declared" + # error. Of buildForwardAspect's three arms only mkAdapterAspect declares: + # the top-level adapter arm evaluates inline and mkDirectAspect places + # content, so both stay. + # + # Testing __complexForward FIRST is load-bearing, not stylistic: every + # complex forward carries an adapterKey (lib/forward.nix always builds + # one), so a bare `adapterKey != null` would also exclude non-declaring + # forwards — among them the home-manager battery's own delivery route. + # The simple-route arm is defensive: adapterKey has one in-tree producer + # (lib/forward.nix), which only builds complex-forward specs. + declaresForwardOption = + r: + if r.__complexForward or false then + (r.needsAdapter or false) && !(r.needsTopLevelAdapter or false) + else + (r.adapterKey or null) != null; + # DELIBERATE: parent-pipeline routes sourced inside the spawned # subtree MUST re-apply — the spawn re-emits class content at the same scope # ids but never re-fires schema policies, so without them a user-schema route @@ -171,6 +192,24 @@ in # simple route would re-nest content in fresh keyless wrappers and conflict # at the target). Order/precedence preserved exactly: freshParent (parent # routes whose key ∉ spawn keys) ++ spawnHere. + # + # Declaration-bearing parent routes are excluded outright, leaving the + # parent's copy as the single owner of that declaration: the parent + # materializes the same route at the same scope and both folds land in one + # target (the user's home-manager evaluation), so re-applying here emits a + # second `den.fwd.` declaration. + # + # The parent's copy is always present to take over — an excluded route is + # by construction inside the spawned subtree, and `suppressionVerdicts`' + # redundant-root rule only fires on a route AT the fold root, which for the + # parent is an ancestor of spawnRoot. + # + # For a forward-only custom class the parent's copy also carries what the + # spawn would have forwarded, because `getCollectedSource` pulls root-scope + # content for it. That is NOT general: `filterRootModules` narrows root + # content to `den.default` modules once fromClass is an entity-owned class, + # and the parent collects the host bucket unprojected where the spawn would + # have re-resolved it per user. spawnRoutes = result.state.scopedRoutes null; parentSubtreeRoutes = lib.filterAttrs (sid: _: subtreeSet ? ${sid}) parentState.scopedRoutes; mergedSpawnRoutes = @@ -180,7 +219,9 @@ in let spawnHere = spawnRoutes.${sid} or [ ]; spawnKeys = lib.genAttrs (map (routeKey sid) spawnHere) (_: true); - freshParent = builtins.filter (r: !(spawnKeys ? ${routeKey sid r})) parentRoutes; + freshParent = builtins.filter ( + r: !(spawnKeys ? ${routeKey sid r}) && !(declaresForwardOption r) + ) parentRoutes; in freshParent ++ spawnHere ) parentSubtreeRoutes; diff --git a/templates/ci/modules/deadbugs/issue-642-host-aspects-custom-class.nix b/templates/ci/modules/deadbugs/issue-642-host-aspects-custom-class.nix new file mode 100644 index 00000000..8cda8b90 --- /dev/null +++ b/templates/ci/modules/deadbugs/issue-642-host-aspects-custom-class.nix @@ -0,0 +1,152 @@ +# Discussion #642: a custom class defined via `_.forward` double-emits its +# forwarded content when the `host-aspects` battery is also included, producing +# an "already declared" option conflict on `den.fwd."//"`. +# +# The battery's spawn re-applied the parent pipeline's own forward route, so the +# adapter's `options.den.fwd.` declaration was materialized by two folds +# that both land in the user's home-manager evaluation. +{ denTest, ... }: +let + # One definition, threaded per test — each `denTest` gets its own `den`/`lib`. + obsidianClass = + den: lib: + { class, aspect-chain }: + den._.forward { + each = lib.singleton class; + fromClass = _: "obsidian"; + intoClass = _: "homeManager"; + intoPath = _: [ + "programs" + "obsidian" + ]; + fromAspect = _: lib.last aspect-chain; + guard = { options, ... }: options ? programs.obsidian; + }; +in +{ + flake.tests.deadbugs.issue-642-host-aspects-custom-class = { + + # The reported shape: user-defined content through the custom class, with + # the battery on. + test-custom-class-with-host-aspects = denTest ( + { + den, + lib, + tuxHm, + ... + }: + { + den.default.includes = [ + den._.host-aspects + (obsidianClass den lib) + ]; + + den.aspects.obsidian = { + obsidian.enable = true; + }; + + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.tux.includes = [ den.aspects.obsidian ]; + + expr = tuxHm.programs.obsidian.enable; + expected = true; + } + ); + + # The custom class's content lives on the HOST aspect and reaches the user + # only through host-aspects — the projection the duplicate-suppression must + # not strand. + test-host-defined-custom-class-projects = denTest ( + { + den, + lib, + tuxHm, + ... + }: + { + den.default.includes = [ + den._.host-aspects + (obsidianClass den lib) + ]; + + den.aspects.obsidian = { + obsidian.enable = true; + }; + + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.igloo.includes = [ den.aspects.obsidian ]; + + expr = tuxHm.programs.obsidian.enable; + expected = true; + } + ); + + # Suppressing the spawn's copy leaves ONE owner for the declaration, so that + # owner must still reach every user on the host — not just the first. + test-host-defined-custom-class-reaches-every-user = denTest ( + { + den, + lib, + igloo, + ... + }: + { + den.default.includes = [ + den._.host-aspects + (obsidianClass den lib) + ]; + + den.aspects.obsidian = { + obsidian.enable = true; + }; + + den.hosts.x86_64-linux.igloo.users = { + tux = { }; + pingu = { }; + }; + + den.aspects.igloo.includes = [ den.aspects.obsidian ]; + + expr = { + tux = igloo.home-manager.users.tux.programs.obsidian.enable or ""; + pingu = igloo.home-manager.users.pingu.programs.obsidian.enable or ""; + }; + expected = { + tux = true; + pingu = true; + }; + } + ); + + # CONTROL: same custom class without the host-aspects battery — the one + # shape that stayed green while the bug was live, isolating the battery as + # the trigger. + test-custom-class-without-host-aspects = denTest ( + { + den, + lib, + tuxHm, + ... + }: + { + den.default.includes = [ + (obsidianClass den lib) + ]; + + den.aspects.obsidian = { + obsidian.enable = true; + }; + + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.tux.includes = [ den.aspects.obsidian ]; + + expr = tuxHm.programs.obsidian.enable; + expected = true; + } + ); + + }; +} From 14d07a1f1d6f3436cdf6e2e9ae66857ed23712b8 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 31 Jul 2026 10:47:37 -0700 Subject: [PATCH 2/4] fix: give the parent precedence for chained forward declarations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two custom forward classes chained (`inner` -> `mid` -> `homeManager`) collide on the inner hop: The option `home-manager.users..den.fwd."inner/mid/"' is already declared Same #603 cause as the direct case, one hop removed. The inner adapter declares into the `mid` bucket, which the second hop nests into the target, so the declaration arrives indirectly. Suppressing the parent's copy in the spawn does not help here: the spawn's OWN walk registers the inner forward too, and `spawnKeys` gave the spawn's copy precedence over the parent's, leaving one producer on each side. Flip the precedence for declaration-bearing routes only. A declaring route is dropped from `freshParent` always, and additionally from `spawnHere` when the parent registered the same identity at that scope, so the parent is the sole owner. A declaration only the spawn registers still stands — there is no competing producer for it. Matching by route identity rather than `intoClass == class` is what catches the indirect hop; a target-class test would miss it. Three cases: user-defined, host-defined (the projection this must not strand), and a no-battery control. --- nix/lib/aspects/fx/spawn-node.nix | 28 +++- ...e-644-parametric-host-forward-per-user.nix | 140 ++++++++++++++++++ .../issue-645-chained-forward-classes.nix | 120 +++++++++++++++ 3 files changed, 282 insertions(+), 6 deletions(-) create mode 100644 templates/ci/modules/deadbugs/issue-644-parametric-host-forward-per-user.nix create mode 100644 templates/ci/modules/deadbugs/issue-645-chained-forward-classes.nix diff --git a/nix/lib/aspects/fx/spawn-node.nix b/nix/lib/aspects/fx/spawn-node.nix index da012881..6057ddfa 100644 --- a/nix/lib/aspects/fx/spawn-node.nix +++ b/nix/lib/aspects/fx/spawn-node.nix @@ -193,11 +193,21 @@ in # at the target). Order/precedence preserved exactly: freshParent (parent # routes whose key ∉ spawn keys) ++ spawnHere. # - # Declaration-bearing parent routes are excluded outright, leaving the - # parent's copy as the single owner of that declaration: the parent - # materializes the same route at the same scope and both folds land in one - # target (the user's home-manager evaluation), so re-applying here emits a - # second `den.fwd.` declaration. + # Declaration-bearing routes get the OPPOSITE precedence: the parent owns + # them, and the spawn's copy goes. The parent materializes the same route + # at the same scope and both folds land in one target (the user's + # home-manager evaluation), so whichever side re-applies it emits a second + # `den.fwd.` declaration — and unlike content definitions, two + # declarations do not merge. So a declaring route is dropped from + # `freshParent` always, and additionally from `spawnHere` whenever the + # parent registered the same identity at that scope. A declaration the + # spawn alone registers stays: there is no other producer for it. + # + # Applying this by identity rather than by target class is deliberate. A + # declaration can reach the extracted class INDIRECTLY — an `inner -> mid` + # forward declares into the `mid` bucket, which a `mid -> homeManager` hop + # then nests into the target — so gating on `intoClass == class` would miss + # the chained shape. # # The parent's copy is always present to take over — an excluded route is # by construction inside the spawned subtree, and `suppressionVerdicts`' @@ -219,11 +229,17 @@ in let spawnHere = spawnRoutes.${sid} or [ ]; spawnKeys = lib.genAttrs (map (routeKey sid) spawnHere) (_: true); + parentDeclaredKeys = lib.genAttrs (map (routeKey sid) ( + builtins.filter declaresForwardOption parentRoutes + )) (_: true); freshParent = builtins.filter ( r: !(spawnKeys ? ${routeKey sid r}) && !(declaresForwardOption r) ) parentRoutes; + keptSpawnHere = builtins.filter ( + r: !(declaresForwardOption r && parentDeclaredKeys ? ${routeKey sid r}) + ) spawnHere; in - freshParent ++ spawnHere + freshParent ++ keptSpawnHere ) parentSubtreeRoutes; # The spawn's provides + routes fold + isolation-BLIND, dedup-FREE final diff --git a/templates/ci/modules/deadbugs/issue-644-parametric-host-forward-per-user.nix b/templates/ci/modules/deadbugs/issue-644-parametric-host-forward-per-user.nix new file mode 100644 index 00000000..f4293a4f --- /dev/null +++ b/templates/ci/modules/deadbugs/issue-644-parametric-host-forward-per-user.nix @@ -0,0 +1,140 @@ +# Issue #644: a host-attached PARAMETRIC aspect feeding a declaring custom +# forward class delivers every user's content to every user. +# +# The aspect is fanned per user at the host scope, so the host's `myshell` +# bucket holds all users' emissions. A child-scope complex forward then pulls +# that root bucket wholesale (`getCollectedSource`), handing each user the union +# instead of their own slice. Silent — the result is a wrong configuration, not +# an error. +{ denTest, ... }: +let + shellClass = + den: lib: + { class, aspect-chain }: + den._.forward { + each = lib.singleton class; + fromClass = _: "myshell"; + intoClass = _: "homeManager"; + intoPath = _: [ + "programs" + "bash" + ]; + fromAspect = _: lib.last aspect-chain; + guard = { options, ... }: options ? programs.bash; + }; + + aliasesOf = + lib: igloo: user: + lib.attrNames (igloo.home-manager.users.${user}.programs.bash.shellAliases or { }); +in +{ + flake.tests.deadbugs.issue-644-parametric-host-forward-per-user = { + + # No battery: no spawn at all, so the leak is attributable to the parent + # pipeline's forward source collection alone. + test-parametric-host-content-stays-per-user = denTest ( + { + den, + lib, + igloo, + ... + }: + { + den.default.includes = [ (shellClass den lib) ]; + + den.aspects.per-user = + { user, ... }: + { + myshell.shellAliases.${user.name} = "mine"; + }; + + den.hosts.x86_64-linux.igloo.users = { + tux = { }; + pingu = { }; + }; + + den.aspects.igloo.includes = [ den.aspects.per-user ]; + + expr = { + tux = aliasesOf lib igloo "tux"; + pingu = aliasesOf lib igloo "pingu"; + }; + expected = { + tux = [ "tux" ]; + pingu = [ "pingu" ]; + }; + } + ); + + # Same shape with host-aspects on — the projection path must agree. + test-parametric-host-content-stays-per-user-with-battery = denTest ( + { + den, + lib, + igloo, + ... + }: + { + den.default.includes = [ + den._.host-aspects + (shellClass den lib) + ]; + + den.aspects.per-user = + { user, ... }: + { + myshell.shellAliases.${user.name} = "mine"; + }; + + den.hosts.x86_64-linux.igloo.users = { + tux = { }; + pingu = { }; + }; + + den.aspects.igloo.includes = [ den.aspects.per-user ]; + + expr = { + tux = aliasesOf lib igloo "tux"; + pingu = aliasesOf lib igloo "pingu"; + }; + expected = { + tux = [ "tux" ]; + pingu = [ "pingu" ]; + }; + } + ); + + # CONTROL: non-parametric host content through the same forward is shared by + # design and must still reach both users. + test-static-host-content-reaches-every-user = denTest ( + { + den, + lib, + igloo, + ... + }: + { + den.default.includes = [ (shellClass den lib) ]; + + den.aspects.shared.myshell.shellAliases.ll = "ls -l"; + + den.hosts.x86_64-linux.igloo.users = { + tux = { }; + pingu = { }; + }; + + den.aspects.igloo.includes = [ den.aspects.shared ]; + + expr = { + tux = aliasesOf lib igloo "tux"; + pingu = aliasesOf lib igloo "pingu"; + }; + expected = { + tux = [ "ll" ]; + pingu = [ "ll" ]; + }; + } + ); + + }; +} diff --git a/templates/ci/modules/deadbugs/issue-645-chained-forward-classes.nix b/templates/ci/modules/deadbugs/issue-645-chained-forward-classes.nix new file mode 100644 index 00000000..97306ca6 --- /dev/null +++ b/templates/ci/modules/deadbugs/issue-645-chained-forward-classes.nix @@ -0,0 +1,120 @@ +# Issue #645: two custom forward classes chained (`inner` -> `mid` -> +# `homeManager`) collide on the INNER hop's `den.fwd."inner/mid/"` +# declaration. +# +# The inner adapter declares into the `mid` bucket, which the second hop then +# nests into `homeManager` — so the declaration reaches the target indirectly +# and a second producer survives the suppression that covers direct hops. +{ denTest, ... }: +let + # `inner` content lands at `programs.bash` of the `mid` class; `mid` merges + # into `homeManager` wholesale. + innerClass = + den: lib: + { class, aspect-chain }: + den._.forward { + each = lib.singleton class; + fromClass = _: "inner"; + intoClass = _: "mid"; + intoPath = _: [ + "programs" + "bash" + ]; + fromAspect = _: lib.last aspect-chain; + guard = _: true; + }; + + midClass = + den: lib: + { class, aspect-chain }: + den._.forward { + each = lib.singleton class; + fromClass = _: "mid"; + intoClass = _: "homeManager"; + intoPath = _: [ ]; + fromAspect = _: lib.last aspect-chain; + }; +in +{ + flake.tests.deadbugs.issue-645-chained-forward-classes = { + + test-chained-forward-with-host-aspects = denTest ( + { + den, + lib, + tuxHm, + ... + }: + { + den.default.includes = [ + den._.host-aspects + (innerClass den lib) + (midClass den lib) + ]; + + den.aspects.chained.inner.enable = true; + + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.tux.includes = [ den.aspects.chained ]; + + expr = tuxHm.programs.bash.enable or ""; + expected = true; + } + ); + + # Chained content defined on the HOST aspect: dropping the spawn's copy of + # the inner declaration must not strand what the projection carries. + test-chained-forward-host-defined = denTest ( + { + den, + lib, + tuxHm, + ... + }: + { + den.default.includes = [ + den._.host-aspects + (innerClass den lib) + (midClass den lib) + ]; + + den.aspects.chained.inner.enable = true; + + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.igloo.includes = [ den.aspects.chained ]; + + expr = tuxHm.programs.bash.enable or ""; + expected = true; + } + ); + + # The same chain without the battery — no spawn, so this isolates the + # chained-declaration collision from anything host-aspects contributes. + test-chained-forward-without-host-aspects = denTest ( + { + den, + lib, + tuxHm, + ... + }: + { + den.default.includes = [ + (innerClass den lib) + (midClass den lib) + ]; + + den.aspects.chained.inner.enable = true; + + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.tux.includes = [ den.aspects.chained ]; + + expr = tuxHm.programs.bash.enable or ""; + expected = true; + } + ); + + }; +} From 71ff2b47259fe8c1f057db182a30d636cbc04f56 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 31 Jul 2026 10:47:37 -0700 Subject: [PATCH 3/4] fix: keep fanned host content with the entity it was bound to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A host-attached parametric aspect feeding a custom forward class handed every user every other user's content, silently: expected { pinguSees = ["pingu"]; tuxSees = ["tux"]; } actual { pinguSees = ["pingu" "tux"]; tuxSees = ["pingu" "tux"]; } `bind`'s descendant-arg fan-out emits every instance at the EMITTING scope, distinguishing them only by the `@=` pairs in each identity's {ctxId}. So the host's bucket legitimately holds one instance per user: myshell@per-user/:0/{per-user/:0@user=pingu} myshell@per-user/:0/{per-user/:0@user=tux} `filterRootModules` passed that bucket to every child-scope forward whole, and only narrowed it by class ownership — never by binding. Each user's forward therefore collected the union. Filter root modules by the bindings their identity carries: a module naming an entity the child's ctx also names must name the SAME one. Bindings for a kind the child has no record of cannot disqualify it, and unfanned content binds nothing and always passes, so shared host content still reaches every user. `ctxBindings` sits beside `stripCtxSuffix`, which already reads this suffix — the binding survives nowhere else by the time routes fold, as a wrapped NixOS module cannot carry extra attributes. Reproduces with no host-aspects battery, hence no spawn; a static-content control covers the sharing case. --- nix/lib/aspects/fx/edges/route.nix | 26 ++++++++++++++++++++++++-- nix/lib/aspects/fx/identity.nix | 27 +++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/nix/lib/aspects/fx/edges/route.nix b/nix/lib/aspects/fx/edges/route.nix index 1dedeb1e..e3bc2a72 100644 --- a/nix/lib/aspects/fx/edges/route.nix +++ b/nix/lib/aspects/fx/edges/route.nix @@ -532,6 +532,27 @@ let # A `den.default`-tagged module — root content shared across the entity chain. isDenDefaultModule = mod: lib.hasSuffix "@default" (mod.key or mod._file or ""); + # A root-scope module that a descendant-arg fan-out bound to ONE entity + # belongs to that entity alone. handlers/bind.nix emits every fan instance at + # the emitting (root) scope, distinguished only by the `@=` pairs + # in its {ctxId} — so a child-scope forward pulling the root bucket sees all + # siblings' instances and, without this, hands each child the union. Bindings + # naming a kind the child's ctx does not have are not about this child and + # cannot disqualify the module; unfanned content binds nothing and always + # passes. + boundToChild = + childCtx: mod: + let + bindings = den.lib.aspects.fx.identity.ctxBindings (toString (mod.key or mod._file or "")); + in + builtins.all ( + kind: + let + record = childCtx.${kind} or null; + in + record == null || (record.name or null) == bindings.${kind} + ) (builtins.attrNames bindings); + # Root-scope `fromClass` content a child-scope COMPLEX forward may pull in. # S-construction rule (§B Decision 2): when `fromClass` is owned by an entity in # the chain, root content under it is that entity's OWN declaration (not @@ -545,11 +566,12 @@ let (childCtx.user.classes or [ ]) ++ lib.optional (childCtx ? host) childCtx.host.class ++ lib.optional (childCtx ? home) childCtx.home.class; + ownBinding = builtins.filter (boundToChild childCtx) rootModules; in if builtins.elem spec.fromClass ownedClasses then - builtins.filter isDenDefaultModule rootModules + builtins.filter isDenDefaultModule ownBinding else - rootModules; + ownBinding; # The "collected" source branch: a child-scope forward collects its own-scope # fromClass modules PLUS the (filtered) root-scope fromClass modules; a root- diff --git a/nix/lib/aspects/fx/identity.nix b/nix/lib/aspects/fx/identity.nix index 621de384..3e2c2a5a 100644 --- a/nix/lib/aspects/fx/identity.nix +++ b/nix/lib/aspects/fx/identity.nix @@ -25,6 +25,32 @@ let # Strip the {ctxId} suffix from an identity, yielding the base identity. stripCtxSuffix = id: lib.head (lib.splitString "/{" id); + # The entity bindings a ctx-qualified identity carries, as { kind = name; }. + # handlers/bind.nix fans a descendant-arg aspect by appending `@=` + # per bound child and emits EVERY instance at the same scope, so the {ctxId} + # suffix is the only record of which entity an instance belongs to. Identities + # with no suffix (unfanned content) bind nothing and yield { }. + ctxBindings = + id: + let + parts = lib.splitString "/{" id; + in + if builtins.length parts < 2 then + { } + else + builtins.listToAttrs ( + builtins.concatMap ( + pair: + let + kv = lib.splitString "=" pair; + in + lib.optional (builtins.length kv == 2) { + name = builtins.head kv; + value = lib.elemAt kv 1; + } + ) (builtins.tail (lib.splitString "@" (lib.removeSuffix "}" (lib.last parts)))) + ); + tombstone = resolved: extra: { name = "~${resolved.name or ""}"; meta = @@ -114,6 +140,7 @@ in baseKey isAnonIdentity stripCtxSuffix + ctxBindings tombstone flattenPathSetByScope collectPathsHandler From 8f07b0c5e4fe2163bfd33a0162dccad009bb7b65 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 31 Jul 2026 12:22:11 -0700 Subject: [PATCH 4/4] fix: bind a standalone home's own user for class modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `homeManager` class module requesting `user` was silently dropped on any standalone home whose user could not be resolved from a DECLARED host — a bare `den.homes..tux`, or `tux@astra` where `astra` is undeclared. No error and no warning: `wrapFunctionModule` puts the module on the `missingDenArgNames` path, and the `lib.warn` it attaches rides on the discarded module, so it is never forced. home.nix bound `user` only via `den.hosts..users.`. The schema DAG offers no fallback either — `user.parent` and `home.parent` are both `host`, making `user` a SIBLING of `home`, so #634's descendant fan-out does not reach it. But a standalone home always names its user, so bind it from the home itself; a declared host still wins and keeps the full record. The synthetic user is identity-only and, like the synthetic host, carries no `class`. That is what keeps OS batteries inert, which is the reason `user` was left null when the synthetic host was introduced. Two supports for that gate: - `user-to-host` now gates on `host ? class`, matching `os-to-host`, `hostname`, `unfree` and `insecure`. It read `host.class` unguarded and only ever escaped because `user` was absent from a standalone home's ctx. - the synthetic host carries `system`, which the home knows for certain and which host-keyed content needs for platform-dependent values (define-user's home directory). `standalone-homes.test-home-standalone-without-existing-host` asserted the superseded contract (`user = null`, host without `system`) and is updated. Its `keyboard.model = "standalone"` is unchanged, which is the evidence `osConfig` is still not wired and OS routing still does not fire. Pre-existing, not a #603 regression. Fixes #640. --- modules/aspects/batteries/os-user.nix | 13 ++- nix/lib/entities/home.nix | 40 +++++-- .../issue-640-standalone-home-user-arg.nix | 107 ++++++++++++++++++ .../ci/modules/deprecated/homes-perhome.nix | 17 ++- 4 files changed, 159 insertions(+), 18 deletions(-) create mode 100644 templates/ci/modules/deadbugs/issue-640-standalone-home-user-arg.nix diff --git a/modules/aspects/batteries/os-user.nix b/modules/aspects/batteries/os-user.nix index fdd564ee..6fafc4b2 100644 --- a/modules/aspects/batteries/os-user.nix +++ b/modules/aspects/batteries/os-user.nix @@ -1,4 +1,4 @@ -{ den, ... }: +{ den, lib, ... }: let description = '' @@ -42,8 +42,11 @@ in den.policies.user-to-host = { user, host, ... }: - [ - (den.lib.policy.route { + # Same gate as os-to-host: a standalone home binds a user and a synthetic + # host identity, neither of which has an OS to route into. `host ? class` + # keeps this inert there and reading `host.class` safe. + lib.optional (host ? class) ( + den.lib.policy.route { fromClass = "user"; intoClass = host.class; path = [ @@ -52,6 +55,6 @@ in user.userName ]; adaptArgs = args: args // { osConfig = args.config; }; - }) - ]; + } + ); } diff --git a/nix/lib/entities/home.nix b/nix/lib/entities/home.nix index 26efed82..b8718d09 100644 --- a/nix/lib/entities/home.nix +++ b/nix/lib/entities/home.nix @@ -73,19 +73,43 @@ let # A declared host always wins and remains the only thing that wires # `osConfig`. # - # Only `host` is synthesized, never `user`: a synthetic host alone fires - # `{ host }`-keyed policies (gated on `host ? class` so OS-class routing - # stays inert for a classless synthetic host), while `{ host, user }`-keyed - # OS batteries (define-user, user-to-host, …) keep their existing - # null-user gating and fall back to their home-scope path. + # The synthetic host stays classless: `host ? class` is what keeps + # OS-class routing (os-to-host, user-to-host, hostname, unfree, …) + # inert for a host that was never declared. It does carry `system`, + # which the home knows for certain and which host-keyed content needs + # to compute platform-dependent values (e.g. define-user's home dir). hostCtx = if hostByName != null then hostByName else if nameWithHost then - { name = hostName; } + { + name = hostName; + inherit system; + } else null; + # A standalone home always names its user, whether or not a declared + # host can resolve one. Binding it from the home is what lets a + # `{ user, ... }` class module resolve at all: without it + # wrapFunctionModule takes the missingDenArgNames path and the whole + # class block is dropped — silently, since the lib.warn it attaches + # rides on the discarded module and is never forced. + # + # A declared host still wins, so a real user keeps its full record + # (classes, aspect, host). The synthetic one is identity-only; OS + # batteries do not act on it because they gate on `host ? class`, + # which no standalone home satisfies. + userCtx = + if userByName != null then + userByName + else + { + name = userName; + userName = userName; + classes = [ config.class ]; + }; + homeManagerConfiguration = if nameWithHost && hostByName != null then { pkgs, modules }: @@ -111,7 +135,7 @@ let "system" ]; config._module.args.host = hostCtx; - config._module.args.user = userByName; + config._module.args.user = userCtx; options = { userName = strOpt "user account name" userName; @@ -121,7 +145,7 @@ let description = "host name (null for unbound standalone homes)"; }; user = lib.mkOption { - default = userByName; + default = userCtx; defaultText = lib.literalExpression "user"; }; host = lib.mkOption { diff --git a/templates/ci/modules/deadbugs/issue-640-standalone-home-user-arg.nix b/templates/ci/modules/deadbugs/issue-640-standalone-home-user-arg.nix new file mode 100644 index 00000000..d5371be7 --- /dev/null +++ b/templates/ci/modules/deadbugs/issue-640-standalone-home-user-arg.nix @@ -0,0 +1,107 @@ +# Issue #640: a class module requesting the `user` entity arg is silently +# dropped on a standalone home whose user cannot be resolved from a DECLARED +# host — no error, no warning, the whole class block disappears. +# +# `nix/lib/entities/home.nix` only bound `user` when the home is named +# `user@host` and that host is declared in `den.hosts` with that user. Without +# it, `wrapFunctionModule` takes the `missingDenArgNames` path and returns +# `unsatisfied = true`; the module is dropped, and the `lib.warn` it attaches is +# never forced, so even the existing warning stays invisible. +{ denTest, ... }: +let + outOf = + config: home: config.flake.homeConfigurations.${home}.config.home.file."OUT".text or ""; +in +{ + flake.tests.deadbugs.issue-640-standalone-home-user-arg = { + + # A bare standalone home: no host in the name at all. + test-bare-standalone-home-binds-user = denTest ( + { den, config, ... }: + { + den.default.homeManager.home.stateVersion = "25.11"; + den.default.includes = [ den._.define-user ]; + + den.homes.x86_64-linux.tux = { }; + + den.aspects.probe.homeManager = + { user, ... }: + { + home.file."OUT".text = "user=${user.name}"; + }; + den.aspects.tux.includes = [ den.aspects.probe ]; + + expr = outOf config "tux"; + expected = "user=tux"; + } + ); + + # `user@host` where the host is NOT declared in den.hosts — home.nix + # synthesizes the host, so the user must come from the home too. + test-synthetic-host-home-binds-user = denTest ( + { den, config, ... }: + { + den.default.homeManager.home.stateVersion = "25.11"; + den.default.includes = [ den._.define-user ]; + + den.homes.x86_64-linux."tux@astra" = { }; + + den.aspects.probe.homeManager = + { user, ... }: + { + home.file."OUT".text = "user=${user.name}"; + }; + den.aspects.tux.includes = [ den.aspects.probe ]; + + expr = outOf config "tux@astra"; + expected = "user=tux"; + } + ); + + # CONTROL: `user@host` with the host declared — the path that already + # resolved `user` from `den.hosts`, and must keep resolving it from there. + test-declared-host-home-still-binds-user = denTest ( + { den, config, ... }: + { + den.default.homeManager.home.stateVersion = "25.11"; + den.default.includes = [ den._.define-user ]; + + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.homes.x86_64-linux."tux@igloo" = { }; + + den.aspects.probe.homeManager = + { user, ... }: + { + home.file."OUT".text = "user=${user.name}"; + }; + den.aspects.tux.includes = [ den.aspects.probe ]; + + expr = outOf config "tux@igloo"; + expected = "user=tux"; + } + ); + + # CONTROL: `{ home, ... }` on a bare standalone home already worked and must + # keep working — it is what shows the drop is specific to `user`. + test-bare-standalone-home-binds-home = denTest ( + { den, config, ... }: + { + den.default.homeManager.home.stateVersion = "25.11"; + den.default.includes = [ den._.define-user ]; + + den.homes.x86_64-linux.tux = { }; + + den.aspects.probe.homeManager = + { home, ... }: + { + home.file."OUT".text = "home=${home.userName}"; + }; + den.aspects.tux.includes = [ den.aspects.probe ]; + + expr = outOf config "tux"; + expected = "home=tux"; + } + ); + + }; +} diff --git a/templates/ci/modules/deprecated/homes-perhome.nix b/templates/ci/modules/deprecated/homes-perhome.nix index 445b1eae..f5d1f57d 100644 --- a/templates/ci/modules/deprecated/homes-perhome.nix +++ b/templates/ci/modules/deprecated/homes-perhome.nix @@ -54,14 +54,21 @@ homeSchema.name = "tux"; homeSchema.userName = "tux"; homeSchema.hostName = "igloo"; - # A `user@host` home with no declared host now carries a synthetic - # host identity (name only) so host-keyed provides/policies resolve - # without instantiating a real host. `user` stays null — only the - # host is synthesized. See deadbugs/standalone-home-host-context.nix. + # A `user@host` home with no declared host carries synthetic host AND + # user identities, so host-keyed provides/policies and `{ user, ... }` + # class modules resolve without instantiating a real host. Both stay + # identity-only and neither gains a `class`, which is what keeps OS + # routing inert — `keyboard.model` below is still "standalone". + # See deadbugs/standalone-home-host-context.nix and issue #640. homeSchema.host = { name = "igloo"; + system = "x86_64-linux"; + }; + homeSchema.user = { + name = "tux"; + userName = "tux"; + classes = [ "homeManager" ]; }; - homeSchema.user = null; configuredUserName = "tux"; keyboard.model = "standalone"; keyboard.layout = "enthium";