From 7fd82595d85c6e71964744cd14374a1ac8e3622a Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 22 May 2026 14:23:51 -0700 Subject: [PATCH 01/10] fix: hasAspect accepts nested aspect refs via __provider chain Nested aspects from freeform traversal (e.g., den.aspects.disk.zfs-disk-single) have __provider set by aspectContentType.merge but lack name/meta. Use __provider to derive the path key, matching how the pathSet stores these entries. (cherry picked from commit 5a3bf908bafb95056ec0239891f7a23591744868) --- nix/lib/aspects/has-aspect.nix | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/nix/lib/aspects/has-aspect.nix b/nix/lib/aspects/has-aspect.nix index f55d17a30..662acb636 100644 --- a/nix/lib/aspects/has-aspect.nix +++ b/nix/lib/aspects/has-aspect.nix @@ -8,8 +8,12 @@ let ref: if (ref ? name) && (ref ? meta) then pathKey (aspectPath ref) + else if ref ? __provider then + # Nested aspect from freeform traversal — content merger sets __provider + # but not name/meta. Derive path key from the provider chain. + pathKey ref.__provider else - throw "hasAspect: ref must have both `name` and `meta` (got ${builtins.typeOf ref})."; + throw "hasAspect: ref must have `name`+`meta` or `__provider` (got ${builtins.typeOf ref})."; # Resolve tree via fx pipeline and extract pathSet from state. # Inlines the same root normalization as fxResolveTree (default.nix) From 706c040606bdc835580a72d7898d871bca22cf00 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 22 May 2026 14:28:41 -0700 Subject: [PATCH 02/10] fix: hasAspect supports nested freeform aspect refs at any depth - has-aspect.nix: accept refs with __provider (set by aspectContentType) - types.nix: annotate nested attrset children in content merger with __provider so deeply nested aspects carry provenance - Only annotate unregistered keys (skip class/pipe/structural keys) - Tests: nested present/absent, provenance distinct, deeply nested (3 levels) (cherry picked from commit aa11ce8939a9769377418131ce9fe5f580a59818) --- nix/lib/aspects/types.nix | 18 +++++- templates/ci/modules/features/has-aspect.nix | 67 ++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/nix/lib/aspects/types.nix b/nix/lib/aspects/types.nix index 11825691e..31d5ae108 100644 --- a/nix/lib/aspects/types.nix +++ b/nix/lib/aspects/types.nix @@ -488,9 +488,25 @@ let name = "${aspectName}._"; includes = map (k: merged.${k}) childKeys; }; + # Annotate nested attrset children with __provider so deeply nested + # aspects carry provenance for hasAspect resolution. + # Only annotate unregistered keys (potential nested aspects) — + # skip class keys, pipe keys, structural keys, and internal keys. + annotatedMerged = lib.mapAttrs ( + k: v: + if builtins.isAttrs v + && !(v ? __provider) + && !(v ? __contentValues) + && !(lib.hasPrefix "__" k) + && !(classReg ? ${k}) + && !(pipeReg ? ${k}) + && !(structuralKeysSet ? ${k}) + then v // { __provider = provider ++ [ k ]; } + else v + ) merged; in providesChildren - // merged + // annotatedMerged // { __contentValues = flatDefs; __provider = provider; diff --git a/templates/ci/modules/features/has-aspect.nix b/templates/ci/modules/features/has-aspect.nix index 490e9f680..5623dcbed 100644 --- a/templates/ci/modules/features/has-aspect.nix +++ b/templates/ci/modules/features/has-aspect.nix @@ -619,6 +619,73 @@ } ); + # ─── Group H2: nested freeform aspect refs ───────────────────────── + + # Nested aspects accessed via freeform key traversal (e.g., + # den.aspects.disk.zfs-disk-single) lack name/meta but carry + # __provider. hasAspect must resolve these via __provider chain. + test-H2-nested-freeform-present = denTest ( + { den, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.igloo.includes = [ den.aspects.parent.child ]; + den.aspects.parent.child.nixos = { }; + + expr = den.hosts.x86_64-linux.igloo.hasAspect den.aspects.parent.child; + expected = true; + } + ); + + test-H2-nested-freeform-absent = denTest ( + { den, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.igloo.nixos = { }; + den.aspects.parent.child.nixos = { }; + + expr = den.hosts.x86_64-linux.igloo.hasAspect den.aspects.parent.child; + expected = false; + } + ); + + # Full provenance: den.aspects.a.sub and den.aspects.b.sub are distinct. + # hasAspect must match the full path (a/sub vs b/sub), not just the leaf name. + test-H2-nested-freeform-provenance-distinct = denTest ( + { den, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.igloo.includes = [ den.aspects.a.sub ]; + den.aspects.a.sub.nixos = { }; + den.aspects.b.sub.nixos = { }; + + expr = { + a-sub = den.hosts.x86_64-linux.igloo.hasAspect den.aspects.a.sub; + b-sub = den.hosts.x86_64-linux.igloo.hasAspect den.aspects.b.sub; + }; + expected = { + a-sub = true; + b-sub = false; + }; + } + ); + + # Deeply nested freeform ref (3 levels). + test-H2-deeply-nested-freeform = denTest ( + { den, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.igloo.includes = [ den.aspects.disk.zfs.single ]; + den.aspects.disk.zfs.single.nixos = { }; + + expr = den.hosts.x86_64-linux.igloo.hasAspect den.aspects.disk.zfs.single; + expected = true; + } + ); + # ─── Group I: error cases ───────────────────────────────────────── test-I-bad-ref-throws = denTest ( From a59fa7bcba5991c071be0422c6d65ad041c1569c Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Mon, 1 Jun 2026 19:32:01 -0700 Subject: [PATCH 03/10] fix(aspects): deep-merge nested namespace children across files aspectContentType's multi-def branch forwarded sub-keys with a shallow `//`, so when several files each contribute a different child under the same deeply-nested namespace, all but the last were dropped from navigation (e.g. services/network/cilium/{cilium,hubble-ui, cilium-bgp-resources}.nix all defining children of network.cilium). Deep-merge instead: colliding attrsets recurse and colliding lists concatenate (matching den's own merge semantics); scalars keep last-def-wins, with __contentValues remaining the canonical source for emit/forward collection. Adds a deadbugs regression test. Full suite 860/861; the one failure (issue-583) is pre-existing nixpkgs drift (nixpkgs now declares programs.atuin.flags, colliding with the test's mock module) and fails identically at HEAD. (cherry picked from commit ca4569afa876415ccbcb62b79fad33f8926b8cfe) --- nix/lib/aspects/types.nix | 44 ++++++++++++++--- .../deadbugs/deep-nested-multi-file-merge.nix | 48 +++++++++++++++++++ 2 files changed, 86 insertions(+), 6 deletions(-) create mode 100644 templates/ci/modules/features/deadbugs/deep-nested-multi-file-merge.nix diff --git a/nix/lib/aspects/types.nix b/nix/lib/aspects/types.nix index 31d5ae108..55b0a1740 100644 --- a/nix/lib/aspects/types.nix +++ b/nix/lib/aspects/types.nix @@ -451,10 +451,39 @@ let lib.concatLists (map (d: d.value) defsForKey) else let - # Forward sub-keys from attrset defs so deeper nested access works - # (e.g., den.aspects.root.sub1.sub2.a where sub2 has multi-def). + # Forward sub-keys from attrset defs so deeper nested access + # works (e.g., den.aspects.root.sub1.sub2.a where sub2 has + # multi-def). Deep-merge so sub-keys contributed by several + # files all survive for navigation — a shallow `//` drops all + # but the last when multiple files each add a different child + # under the same nested namespace (e.g. cilium.nix, + # hubble-ui.nix and cilium-bgp-resources.nix all defining + # children of services.network.cilium). __contentValues + # remains the canonical source for emit/forward collection, + # so this only affects read-navigation (no double-collection). subAttrVals = builtins.filter builtins.isAttrs (map (d: d.value) defsForKey); - subForwarded = builtins.foldl' (a: b: a // b) { } subAttrVals; + # Merge contributions consistently with den's own semantics + # (and the module system): colliding attrsets recurse and + # colliding lists concatenate, so children contributed by + # multiple files all survive. Scalars keep last-def-wins — + # genuinely-conflicting scalars are resolved by the real + # module merge via __contentValues (which errors without + # mkForce), so this navigation view stays total. + deepMerge = + a: b: + a + // builtins.mapAttrs ( + bk: bv: + if !(a ? ${bk}) then + bv + else if builtins.isAttrs a.${bk} && builtins.isAttrs bv then + deepMerge a.${bk} bv + else if builtins.isList a.${bk} && builtins.isList bv then + a.${bk} ++ bv + else + bv + ) b; + subForwarded = builtins.foldl' deepMerge { } subAttrVals; in subForwarded // { @@ -494,15 +523,18 @@ let # skip class keys, pipe keys, structural keys, and internal keys. annotatedMerged = lib.mapAttrs ( k: v: - if builtins.isAttrs v + if + builtins.isAttrs v && !(v ? __provider) && !(v ? __contentValues) && !(lib.hasPrefix "__" k) && !(classReg ? ${k}) && !(pipeReg ? ${k}) && !(structuralKeysSet ? ${k}) - then v // { __provider = provider ++ [ k ]; } - else v + then + v // { __provider = provider ++ [ k ]; } + else + v ) merged; in providesChildren diff --git a/templates/ci/modules/features/deadbugs/deep-nested-multi-file-merge.nix b/templates/ci/modules/features/deadbugs/deep-nested-multi-file-merge.nix new file mode 100644 index 000000000..0c8b1f6fb --- /dev/null +++ b/templates/ci/modules/features/deadbugs/deep-nested-multi-file-merge.nix @@ -0,0 +1,48 @@ +# Regression: several files each contribute a DIFFERENT child to the same +# deeply-nested namespace node, so that node is itself a colliding sub-key of +# its parent across multiple definitions. +# +# aspectContentType's multi-def branch forwarded sub-keys with a shallow `//`, +# which kept only the last contribution to a colliding sub-key — so all but one +# child vanished from navigation and could not be included. This reproduces the +# kubernetes layout where cilium.nix, hubble-ui.nix and cilium-bgp-resources.nix +# each define a child of services.network.cilium (collision at network → cilium). +# +# Unlike deep-nested-separate-imports (where a/b are distinct DIRECT children of +# sub2, which a shallow `//` handles), here a/b/c live under a shared `grp` node +# that collides across the three definitions one level up. +{ denTest, ... }: +{ + flake.tests.deadbugs.deep-nested-multi-file-merge = { + test-multi-file-colliding-namespace-merge = denTest ( + { den, igloo, ... }: + { + imports = [ + { den.aspects.root.sub1.sub2.grp.a.nixos.environment.variables.FROM_A = "yes"; } + { den.aspects.root.sub1.sub2.grp.b.nixos.environment.variables.FROM_B = "yes"; } + ]; + + den.aspects.root.sub1.sub2.grp.c.nixos.environment.variables.FROM_C = "yes"; + + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.igloo.includes = [ + den.aspects.root.sub1.sub2.grp.a + den.aspects.root.sub1.sub2.grp.b + den.aspects.root.sub1.sub2.grp.c + ]; + + expr = { + hasA = igloo.environment.variables ? FROM_A; + hasB = igloo.environment.variables ? FROM_B; + hasC = igloo.environment.variables ? FROM_C; + }; + expected = { + hasA = true; + hasB = true; + hasC = true; + }; + } + ); + }; +} From 0b41bef62418062198950d9dacd64e3d6ffd3d09 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Mon, 1 Jun 2026 20:17:47 -0700 Subject: [PATCH 04/10] fix(aspects): give navigated nested aspects their own identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wrapChild only injected identity from __provider for content wrappers carrying __contentValues; a single-def navigated nested aspect carries __provider (its full path) but no __contentValues, so it fell through nameless and children.nix renamed it to /:. That gave the same nested aspect a different identity depending on the inclusion path — apps.gaming.steam reached via roles.gaming (host scope) vs. a per-user entity-named aspect's includes (applied by a policy at user scope) — defeating cross-scope dedup, so its nixos content (programs.steam.package) was defined twice. Derive name + meta.provider from __provider whenever a navigated child has no name. Adds a cross-scope dedup regression test. Full suite 861/862 (only the pre-existing nixpkgs issue-583). (cherry picked from commit beb2b4938af382e0789368c62a7c0e310364880d) --- nix/lib/aspects/fx/aspect/normalize.nix | 13 ++++- .../nested-aspect-include-identity.nix | 52 +++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 templates/ci/modules/features/deadbugs/nested-aspect-include-identity.nix diff --git a/nix/lib/aspects/fx/aspect/normalize.nix b/nix/lib/aspects/fx/aspect/normalize.nix index c9d2e21ba..87deed4b4 100644 --- a/nix/lib/aspects/fx/aspect/normalize.nix +++ b/nix/lib/aspects/fx/aspect/normalize.nix @@ -85,7 +85,16 @@ let # into includes so the pipeline resolves them. listOf doesn't call # providerType.merge per-element, so inner wrappers in includes lists # arrive here unprocessed. - else if builtins.isAttrs child && child ? __contentValues && !(child ? name) then + # A navigated nested aspect carries __provider (its full path) but may have + # no __contentValues (single-def keys forward their raw value directly). + # Either way, when it has no name yet, derive name + meta.provider from + # __provider so it resolves to its OWN identity (e.g. apps/gaming/steam) + # regardless of inclusion path. Without this it falls through nameless and + # children.nix renames it to /:, so the same aspect + # included via two paths gets two identities and fails to dedup. + else if + builtins.isAttrs child && (child ? __contentValues || child ? __provider) && !(child ? name) + then let prov = child.__provider or [ ]; provName = if prov != [ ] then lib.last prov else null; @@ -98,7 +107,7 @@ let in args != { } && !(args ? config) && !(args ? options) ) - ) child.__contentValues; + ) (child.__contentValues or [ ]); in child // lib.optionalAttrs (provName != null) { diff --git a/templates/ci/modules/features/deadbugs/nested-aspect-include-identity.nix b/templates/ci/modules/features/deadbugs/nested-aspect-include-identity.nix new file mode 100644 index 000000000..29eb71aaf --- /dev/null +++ b/templates/ci/modules/features/deadbugs/nested-aspect-include-identity.nix @@ -0,0 +1,52 @@ +# Regression: a deeply-nested aspect (deep.grp.svc) included via TWO scopes — +# host-scope through a role, and user-scope through a per-user entity-named +# aspect auto-included by a policy — must resolve to its OWN identity in both, +# so its nixos content dedups across scopes instead of applying twice. +# +# Reproduces apps.gaming.steam included via BOTH roles.gaming (host) and a +# per-user entity-named aspect's includes (user-aspect-auto-include policy): +# the navigated nested aspect carried __provider but no name, so wrapChild left +# it nameless and children.nix renamed it to /:. That gave a +# different identity on the user path than the host path, defeating cross-scope +# dedup, so steam's programs.steam.package was defined twice. +{ denTest, ... }: +{ + flake.tests.deadbugs.nested-aspect-include-identity = { + test-nested-include-dedups-across-scopes = denTest ( + { + den, + lib, + igloo, + ... + }: + { + # Mirror the consumer's policy: auto-include den.aspects.. + # at user scope if it exists. + den.schema.user.includes = [ + (den.lib.policy.mkPolicy "user-aspect-auto-include" ( + { host, user, ... }: + lib.optional (den.aspects ? ${host.name} && den.aspects.${host.name} ? ${user.name}) ( + den.lib.policy.include den.aspects.${host.name}.${user.name} + ) + )) + ]; + + den.aspects.deep.grp.svc.nixos.boot.kernelParams = [ "den-dedup-marker" ]; + + # Host-scope inclusion (via a role the host includes). + den.aspects.gaming.includes = [ den.aspects.deep.grp.svc ]; + + # User-scope inclusion (entity-named aspect, auto-included by the policy). + den.aspects.igloo.tux.includes = [ den.aspects.deep.grp.svc ]; + + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.aspects.igloo.includes = [ den.aspects.gaming ]; + + # Deduped across scopes → marker once; with distinct anon identities on + # the user path the nixos content applies twice → marker twice. + expr = builtins.length (builtins.filter (p: p == "den-dedup-marker") igloo.boot.kernelParams); + expected = 1; + } + ); + }; +} From a11f413eb73591835d61d2b380f5df328ac2002c Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Mon, 1 Jun 2026 20:39:08 -0700 Subject: [PATCH 05/10] test(deadbugs): stop issue-583 mock colliding with nixpkgs programs.atuin.flags The issue-583 forwarding test mocked options.programs.atuin.flags. nixpkgs gained that option in rev 64c08a7 (CI lock bumped in 4701e77a), so when the #583 fix landed on this branch the mock redeclared an option nixpkgs already owns -> "option programs.atuin.flags is already declared" -> the test failed (it passed in the PR's original, older-nixpkgs context). Forward into a custom `forwardTarget` option instead, so the mock can't collide with nixpkgs. Behaviour and intent unchanged; full suite now 862/862. (cherry picked from commit a2cb8708b39170392be8fdf77803af8169c58667) --- .../deadbugs/issue-583-forwarding-overwrite.nix | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/templates/ci/modules/features/deadbugs/issue-583-forwarding-overwrite.nix b/templates/ci/modules/features/deadbugs/issue-583-forwarding-overwrite.nix index e68ce9625..d443f5124 100644 --- a/templates/ci/modules/features/deadbugs/issue-583-forwarding-overwrite.nix +++ b/templates/ci/modules/features/deadbugs/issue-583-forwarding-overwrite.nix @@ -1,13 +1,17 @@ # Two aspects both contribute to the `atuin` class. The `atuin` aspect forwards -# its own `atuin` content into `programs.atuin` while `igloo` provides `atuin` -# to its users and also contributes flags. Forwarded content from the provider -# aspect must merge with the host's own contribution, not overwrite it. +# its own `atuin` content into a `forwardTarget` option while `igloo` provides +# `atuin` to its users and also contributes flags. Forwarded content from the +# provider aspect must merge with the host's own contribution, not overwrite it. +# +# The target is a custom option (not `programs.atuin`) so the test's mock module +# does not redeclare an option nixpkgs already owns — nixpkgs gained +# `programs.atuin.flags` in 64c08a7, which collided with the original mock. { denTest, ... }: let atuinModule = { lib, ... }: { - options.programs.atuin.flags = lib.mkOption { + options.forwardTarget.flags = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ ]; }; @@ -31,8 +35,7 @@ in fromClass = _: "atuin"; intoClass = _: "nixos"; intoPath = _: [ - "programs" - "atuin" + "forwardTarget" ]; fromAspect = _: lib.head aspect-chain; guard = _: true; @@ -66,7 +69,7 @@ in ]; }; - expr = lib.sort lib.lessThan igloo.programs.atuin.flags; + expr = lib.sort lib.lessThan igloo.forwardTarget.flags; expected = [ "--baz" From bb6482cc097dfb6fa17de6e416f2f43b9e7eb9c5 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 2 Jun 2026 10:06:57 -0700 Subject: [PATCH 06/10] fix(home-env): thread resolution-chain ctx into home-manager extraction The host-aspects battery re-resolved the host aspect tree for a user's classes (homeManager) in an isolated sub-pipeline seeded with only { host, user }, dropping the ancestor context the host scope actually carries (e.g. a parent `environment` entity). A parametric host quirk emit `{ environment, host, ... }: ...` was then stranded as a raw function at the {host,user} projection scope, crashing any homeManager consumer that read the pipe ("expected a set but found a function"). from-host now fires as a policy (receiving the full resolveCtx) and threads the ambient entity-kind chain bindings into the re-resolution, so re-fired parametric host aspects bind the same args they would at the host scope. The same threading is applied to home-env's userForward extraction path. Adds deadbugs/host-aspects-chain-ctx regression test. (cherry picked from commit eea3d6b1931ce94f53fb1b8ea9efd78d491c9cec) --- modules/aspects/batteries/host-aspects.nix | 43 +++++--- nix/lib/home-env.nix | 29 ++++-- .../deadbugs/host-aspects-chain-ctx.nix | 97 +++++++++++++++++++ 3 files changed, 150 insertions(+), 19 deletions(-) create mode 100644 templates/ci/modules/features/deadbugs/host-aspects-chain-ctx.nix diff --git a/modules/aspects/batteries/host-aspects.nix b/modules/aspects/batteries/host-aspects.nix index a83b346de..9d691a94e 100644 --- a/modules/aspects/batteries/host-aspects.nix +++ b/modules/aspects/batteries/host-aspects.nix @@ -14,28 +14,47 @@ let specifically for `user.classes`. ''; + # Resolution-chain entity bindings (e.g. a parent `environment`) carried in + # the ambient context. Threaded into the re-resolution below so the host + # aspect tree binds the same ancestor args it bound at the host scope. + entityKindAttrs = lib.genAttrs den.lib.schemaUtil.schemaEntityKinds (_: null); + + # Re-resolve the host's aspect tree for a user's classes, projecting host + # class content (e.g. homeManager) onto the user. A policy (not a bare + # parametric include) so it receives the full ambient resolveCtx — the host + # scope is a descendant of its parent entities (e.g. `environment`), and that + # ancestor context must survive into the re-resolution. Without it, + # parametric host aspects re-fired here (e.g. a quirk emit `{ environment, + # host, ... }: ...`) would be stranded unresolved. from-host = - { host, user }: + { host, user, ... }@ctx: let - # Tag host.aspect with user context so parametric includes like - # { user }: ... can resolve during host-aspects re-resolution. - ctx = { inherit host user; }; - scopeHandlers = den.lib.aspects.fx.handlers.constantHandler ctx; + chainCtx = builtins.intersectAttrs entityKindAttrs ctx // { + inherit host user; + }; + scopeHandlers = den.lib.aspects.fx.handlers.constantHandler chainCtx; aspectWithCtx = host.aspect // { __scopeHandlers = scopeHandlers; }; + projected = { + name = "host-aspects/${user.userName}@${host.name}"; + } + // lib.genAttrs (user.classes or [ "homeManager" ]) ( + class: den.lib.aspects.resolveImports class aspectWithCtx + ); in - { - name = "host-aspects/${user.userName}@${host.name}"; - } - // lib.genAttrs (user.classes or [ "homeManager" ]) ( - class: den.lib.aspects.resolveImports class aspectWithCtx - ); + [ (den.lib.policy.include projected) ]; in { den.batteries.host-aspects = { name = "host-aspects"; inherit description; - includes = [ from-host ]; + includes = [ + { + __isPolicy = true; + name = "host-aspects-project"; + fn = from-host; + } + ]; }; } diff --git a/nix/lib/home-env.nix b/nix/lib/home-env.nix index 1955d6bdd..90fa9ea97 100644 --- a/nix/lib/home-env.nix +++ b/nix/lib/home-env.nix @@ -85,14 +85,26 @@ let ]; }; + # Resolution-chain entity bindings (e.g. a parent `environment`) carried in + # the ambient policy context. Threaded into the home extraction below so a + # user scope created during home-manager extraction inherits the same + # ancestor bindings it would have as a normal descendant in the chain. + entityKindAttrs = lib.genAttrs den.lib.schemaUtil.schemaEntityKinds (_: null); + chainBindingsFrom = ctx: builtins.intersectAttrs entityKindAttrs ctx; + userForward = + chainCtx: { host, user }: den.batteries.forward { each = lib.singleton true; fromClass = _: className; intoClass = _: host.class; intoPath = _: forwardPathFn { inherit host user; }; - fromAspect = _: den.lib.resolveEntity "user" { inherit host user; }; + # Seed the home extraction with the ambient resolution-chain bindings + # so parametric host aspects re-fired at the user scope bind the same + # args (e.g. `environment`) they would bind at the host scope, instead + # of being stranded unresolved. (was: { inherit host user; }) + fromAspect = _: den.lib.resolveEntity "user" (chainCtx // { inherit host user; }); }; # Includes shared by both host-scope and user-scope detection. @@ -104,7 +116,7 @@ let ); policyFn = - { host, ... }: + { host, ... }@policyCtx: let enabled = mkDetectHost { inherit className supportedOses optionPath; @@ -114,10 +126,11 @@ let [ ] else let + chainCtx = chainBindingsFrom policyCtx; pairs = mkIntoClassUsers className { inherit host; }; resolves = map ( pair: - den.lib.policy.resolve.withIncludes ([ userForward ] ++ schemaIncludes) { + den.lib.policy.resolve.withIncludes ([ (userForward chainCtx) ] ++ schemaIncludes) { user = pair.user; } ) pairs; @@ -127,16 +140,18 @@ let # Complements the host-scope battery which only sees users # declared on host.users, not registry or policy-resolved users. userDetectFn = - { host, user, ... }: + { host, user, ... }@userCtx: let isOsSupported = builtins.elem host.class supportedOses; hasClass = lib.elem className user.classes; in lib.optionals (isOsSupported && hasClass) ( [ - (den.lib.policy.include (userForward { - inherit host user; - })) + (den.lib.policy.include ( + userForward (chainBindingsFrom userCtx) { + inherit host user; + } + )) ] ++ classIncludes ); diff --git a/templates/ci/modules/features/deadbugs/host-aspects-chain-ctx.nix b/templates/ci/modules/features/deadbugs/host-aspects-chain-ctx.nix new file mode 100644 index 000000000..96bed0adf --- /dev/null +++ b/templates/ci/modules/features/deadbugs/host-aspects-chain-ctx.nix @@ -0,0 +1,97 @@ +# Regression: the host-aspects battery re-resolves the host aspect tree for a +# user's classes (e.g. homeManager) in an isolated sub-pipeline. It seeded that +# re-resolution with only { host, user }, dropping the resolution-chain context +# the host scope actually carries (e.g. a parent `environment`). A parametric +# host quirk emit `{ environment, host, ... }: ...` was then stranded as a raw +# function at the {host,user} projection scope, crashing any homeManager +# consumer that read the pipe ("expected a set but found a function"). +# +# Fix: from-host threads the ambient resolution-chain entity bindings into the +# re-resolution, so parametric host aspects bind the same args they would at the +# host scope. +{ denTest, lib, ... }: +{ + flake.tests.host-aspects-chain-ctx = { + + test-parametric-host-quirk-survives-home-projection = denTest ( + { + den, + tuxHm, + lib, + ... + }: + { + # environment as a parent entity of host (mirrors a fleet topology). + den.schema.environment.isEntity = true; + den.schema.host.parent = "environment"; + + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.quirks.host-addrs.description = "Host address entries"; + + # Walk flake -> environment -> host, injecting `environment` into the + # host scope context. Replaces the default per-system host walking. + den.policies.to-env = _: [ + (den.lib.policy.resolve.to "environment" { + environment = { + name = "prod"; + domain = "example.com"; + }; + }) + ]; + den.policies.env-to-hosts = + { environment, ... }: + lib.concatMap ( + system: + lib.concatMap ( + hostName: + let + host = den.hosts.${system}.${hostName}; + in + [ + (den.lib.policy.resolve.to "host" { inherit host; }) + (den.lib.policy.instantiate host) + ] + ) (builtins.attrNames (den.hosts.${system} or { })) + ) (builtins.attrNames (den.hosts or { })); + + den.schema.flake.includes = [ den.policies.to-env ]; + den.schema.environment.includes = [ den.policies.env-to-hosts ]; + den.schema.flake-system.excludes = [ + den.policies.system-to-os-outputs + den.policies.system-to-hm-outputs + ]; + + # Host aspect tree: a parametric host-addrs emit requiring `environment` + # (a host-only ctx key), consumed by a homeManager aspect projected onto + # the user via the host-aspects battery. + den.aspects.igloo.includes = [ + den.aspects.net-hosts + den.aspects.ssh-home + ]; + den.aspects.net-hosts.host-addrs = + { environment, host, ... }: + { + hostname = host.name; + domain = environment.domain; + }; + den.aspects.ssh-home.homeManager = + { + host-addrs, + lib, + ... + }: + { + home.sessionVariables.SSH_HOSTS = lib.concatMapStringsSep "," ( + e: "${e.hostname}.${e.domain}" + ) host-addrs; + }; + + den.aspects.tux.includes = [ den._.host-aspects ]; + + expr = tuxHm.home.sessionVariables.SSH_HOSTS; + expected = "igloo.example.com"; + } + ); + }; +} From e711eebef24d94f5458960abfd4a073fa516a4da Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 2 Jun 2026 14:10:58 -0700 Subject: [PATCH 07/10] feat(namespaces): expose `_` provides bundle on namespace roots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_` (alias for `provides`) lived only on aspect leaves, so `den.aspects.foo._` worked but `foo._` — where `foo` is a namespace root — threw `attribute '_' missing`. A namespace root is a container, not an aspect, so it has no provides of its own. Add a synthetic, read-only `_` to the namespace container: an aggregate aspect whose includes are every aspect declared in the namespace, so `[ ns._ ]` pulls them all in — the container-level analog of an aspect's provides bundle. Structural keys (stages/schema/classes/_module/_) are excluded, and aspect-schema's class collection skips `_`. Reported in #588. --- modules/aspect-schema.nix | 1 + nix/lib/namespace-types.nix | 27 +++- .../issue-588-namespace-provides-alias.nix | 152 ++++++++++++++++++ 3 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 templates/ci/modules/features/deadbugs/issue-588-namespace-provides-alias.nix diff --git a/modules/aspect-schema.nix b/modules/aspect-schema.nix index 0e42795e3..2071562d8 100644 --- a/modules/aspect-schema.nix +++ b/modules/aspect-schema.nix @@ -41,6 +41,7 @@ let "schema" "classes" "_module" + "_" ]; nsNames = builtins.attrNames (config.den.ful or { }); nsCollected = map ( diff --git a/nix/lib/namespace-types.nix b/nix/lib/namespace-types.nix index 803c1cdea..94ed42afb 100644 --- a/nix/lib/namespace-types.nix +++ b/nix/lib/namespace-types.nix @@ -2,8 +2,18 @@ let inherit (den.lib.aspects) mkAspectsType; + # Keys the namespace container owns — everything else freeform is an aspect. + # Kept in step with modules/aspect-schema.nix's namespace-key filter. + structuralKeys = [ + "stages" + "schema" + "classes" + "_module" + "_" + ]; + namespaceType = lib.types.submodule ( - { name, ... }: + { name, config, ... }: { options.schema = lib.mkOption { description = "namespace schema — freeform deferred modules per entity kind"; @@ -19,6 +29,21 @@ let default = { }; type = lib.types.lazyAttrsOf lib.types.raw; }; + # Namespace-root provides bundle, mirroring an aspect's `_`. A namespace + # root is a container, not an aspect, so it has no provides of its own; + # here `_` is a synthetic aggregate aspect whose includes are every + # aspect declared in the namespace. Including it (`[ ns._ ]`) pulls them + # all in, the container-level analog of `den.aspects.foo._`. + options._ = lib.mkOption { + description = "Bundle of every aspect in this namespace; include to pull them all."; + readOnly = true; + type = lib.types.raw; + default = { + includes = map (k: config.${k}) ( + builtins.filter (k: !builtins.elem k structuralKeys) (builtins.attrNames config) + ); + }; + }; freeformType = (mkAspectsType { providerPrefix = [ name ]; }).aspectsType; } ); diff --git a/templates/ci/modules/features/deadbugs/issue-588-namespace-provides-alias.nix b/templates/ci/modules/features/deadbugs/issue-588-namespace-provides-alias.nix new file mode 100644 index 000000000..bc1c2c4e1 --- /dev/null +++ b/templates/ci/modules/features/deadbugs/issue-588-namespace-provides-alias.nix @@ -0,0 +1,152 @@ +# Issue 588: `._` on a namespace root. +# +# `_` (alias for `provides`) lives on aspect leaves, so `den.aspects.foo._` +# worked but `foo._` — where `foo` is a *namespace* root (a container, not an +# aspect) — threw `attribute '_' missing`. A namespace root now exposes a +# synthetic `_` bundle whose includes are every aspect in the namespace, the +# container-level analog of an aspect's provides bundle. +{ + denTest, + inputs, + ... +}: +{ + flake.tests.deadbugs.issue-588 = { + + # Baseline from the report: `den.aspects.foo` is an ASPECT with nested + # children; `den.aspects.foo._` is its provides bundle. + test-direct-aspect-underscore = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.foo.bar.nixos.programs.localsend.enable = true; + den.aspects.foo.barbar.nixos.system.stateVersion = "25.11"; + + den.aspects.igloo.includes = [ den.aspects.foo._ ]; + + expr = { + localsend = igloo.programs.localsend.enable; + stateVersion = igloo.system.stateVersion; + }; + expected = { + localsend = true; + stateVersion = "25.11"; + }; + } + ); + + # The fix: `foo._` where `foo` is the NAMESPACE ROOT bundles all its + # top-level aspects. + test-namespace-root-underscore = denTest ( + { + den, + foo, + igloo, + ... + }: + { + imports = [ (inputs.den.namespace "foo" false) ]; + den.hosts.x86_64-linux.igloo.users.tux = { }; + + foo.bar.nixos.programs.localsend.enable = true; + foo.barbar.nixos.system.stateVersion = "25.11"; + + den.aspects.igloo.includes = [ foo._ ]; + + expr = { + localsend = igloo.programs.localsend.enable; + stateVersion = igloo.system.stateVersion; + }; + expected = { + localsend = true; + stateVersion = "25.11"; + }; + } + ); + + # The bundle equals listing the namespace's aspects individually. + test-namespace-explicit-list = denTest ( + { + den, + foo, + igloo, + ... + }: + { + imports = [ (inputs.den.namespace "foo" false) ]; + den.hosts.x86_64-linux.igloo.users.tux = { }; + + foo.bar.nixos.programs.localsend.enable = true; + foo.barbar.nixos.system.stateVersion = "25.11"; + + den.aspects.igloo.includes = [ + foo.bar + foo.barbar + ]; + + expr = { + localsend = igloo.programs.localsend.enable; + stateVersion = igloo.system.stateVersion; + }; + expected = { + localsend = true; + stateVersion = "25.11"; + }; + } + ); + + # An aspect leaf inside a namespace still carries its own `_` (its nested + # children self-provide), distinct from the namespace-root bundle. + test-aspect-in-namespace-underscore = denTest ( + { + den, + ns, + igloo, + ... + }: + { + imports = [ (inputs.den.namespace "ns" false) ]; + den.hosts.x86_64-linux.igloo.users.tux = { }; + + ns.app.bar.nixos.programs.localsend.enable = true; + ns.app.barbar.nixos.system.stateVersion = "25.11"; + + den.aspects.igloo.includes = [ ns.app._ ]; + + expr = { + localsend = igloo.programs.localsend.enable; + stateVersion = igloo.system.stateVersion; + }; + expected = { + localsend = true; + stateVersion = "25.11"; + }; + } + ); + + # The bundle excludes structural namespace keys (schema/classes/_): only + # real aspects are pulled in, not the `classes` declaration. + test-bundle-excludes-structural = denTest ( + { + den, + foo, + igloo, + ... + }: + { + imports = [ (inputs.den.namespace "foo" false) ]; + den.hosts.x86_64-linux.igloo.users.tux = { }; + + foo.classes.extra.description = "structural, not an aspect"; + foo.bar.nixos.system.stateVersion = "25.11"; + + den.aspects.igloo.includes = [ foo._ ]; + + expr.stateVersion = igloo.system.stateVersion; + expected.stateVersion = "25.11"; + } + ); + + }; +} From 95b6277e86543a65f33a4ba55f9eed0d0641e9fd Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 2 Jun 2026 15:32:50 -0700 Subject: [PATCH 08/10] fix(namespaces): don't round-trip the synthetic `_` bundle on namespace re-import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The namespace-root `_` provides bundle is a computed, read-only option. It was being serialized into the exported `flake.denful.` and fed straight back as a definition on re-import (`den.namespace name [sources]`), colliding with the read-only option: forcing a re-imported `._` threw "The option 'den.ful.._' is read-only, but it's set multiple times". Existing namespace-provider tests passed only because they never force `._`. Drop `_` from the exported namespace and strip it in stripAliases on import — mirroring how stripAliases already drops the aspect-level `_` aliases — so the importing side recomputes its own bundle. Found in code review of #589 (the namespace-root `_` feature it introduced). --- nix/lib/namespace.nix | 31 ++++++++++++------- .../issue-588-namespace-provides-alias.nix | 26 ++++++++++++++++ 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/nix/lib/namespace.nix b/nix/lib/namespace.nix index 4484bc42e..e99fa5c0e 100644 --- a/nix/lib/namespace.nix +++ b/nix/lib/namespace.nix @@ -12,22 +12,31 @@ let # The _ → provides alias in aspectSubmodule means evaluated configs contain # both _ and provides with identical content. Re-importing both causes # listOf options (like includes) to merge duplicates. - stripAliases = lib.mapAttrs ( - _: v: - if builtins.isAttrs v then - builtins.removeAttrs v [ - "_" - "__functor" - ] - else - v - ); + # The root `_` is the synthetic namespace provides bundle (a computed, + # read-only option). Like the aspect-level `_` aliases inside each value, it + # must not round-trip as a definition — drop it before re-feeding so the + # importing side recomputes its own bundle instead of colliding with it. + stripAliases = + denful: + lib.mapAttrs ( + _: v: + if builtins.isAttrs v then + builtins.removeAttrs v [ + "_" + "__functor" + ] + else + v + ) (builtins.removeAttrs denful [ "_" ]); sourceModules = map (denful: { config.den.ful.${name} = stripAliases denful; }) denfuls; aliasModule = lib.mkAliasOptionModule [ name ] [ "den" "ful" name ]; outputModule = lib.optionalAttrs isOutput { - config.flake.denful.${name} = config.den.ful.${name}; + # Don't serialize the computed `_` bundle into the exported namespace; it's + # recomputed on import (and would otherwise collide with the read-only + # option — see stripAliases). + config.flake.denful.${name} = builtins.removeAttrs config.den.ful.${name} [ "_" ]; }; # Merge external source classes into den.classes. diff --git a/templates/ci/modules/features/deadbugs/issue-588-namespace-provides-alias.nix b/templates/ci/modules/features/deadbugs/issue-588-namespace-provides-alias.nix index bc1c2c4e1..612bd2b97 100644 --- a/templates/ci/modules/features/deadbugs/issue-588-namespace-provides-alias.nix +++ b/templates/ci/modules/features/deadbugs/issue-588-namespace-provides-alias.nix @@ -148,5 +148,31 @@ } ); + # The computed `_` bundle must NOT be serialized into the exported + # namespace, else re-import feeds a stale `_` into the read-only option. + test-export-omits-underscore = denTest ( + { config, ns, ... }: + { + imports = [ (inputs.den.namespace "ns" true) ]; + ns.foo.nixos.system.stateVersion = "25.11"; + + expr.exportHasUnderscore = config.flake.denful.ns ? _; + expected.exportHasUnderscore = false; + } + ); + + # Round-trip: forcing `_` on a RE-IMPORTED namespace must recompute the + # bundle locally, not collide with the exported one (read-only "set + # multiple times"). + test-reimport-namespace-bundle = denTest ( + { provider, ... }: + { + imports = [ (inputs.den.namespace "provider" [ inputs.provider ]) ]; + + expr.hasIncludes = provider._ ? includes; + expected.hasIncludes = true; + } + ); + }; } From c57ac6b8cffa1f9e4937c007b088cb6fce1a5d90 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 2 Jun 2026 16:08:14 -0700 Subject: [PATCH 09/10] feat(fx): spawn scope-tree-threaded resolution nodes + resolve-at-emitting-node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A general primitive — `spawnNode` — materializes a child resolution node from any parent scope, threaded with the parent pipeline's resolved scope-tree state (parent + siblings), so the node's own assemblePipes re-derives inherited/ collected pipe values with full fleet visibility. Paired with resolve-at-emitting- node: a pipeline-parametric pipe emit resolves to concrete data at its emitting node on every crossing (local/collected/exposed), never as a function; config-dependent emits stay deferred (__configThunk). Home extraction is the first and driving consumer: it replaces three isolated sub-pipelines (host-aspects resolveImports; makeHomeEnv/hm-host resolveEntity) with spawnNode, fixing the originating bug where a host-aspects-projected ssh app saw only the local host, never the fleet peers. The mechanism is general (den-hoag `spawn` with one read-only inherited edge) and applies to any parent->child entity relationship; home is just where it is currently exercised. - assemble-pipes: resolve-at-emitting-node on the collected and exposed crossings. - spawn-node: spawnNode primitive (re-walk for one class, merge parent state, own assemblePipes, class isolation). - policy.spawn effect + register-spawn handler + drain augmentation: a deferred node spawn resolved post-walk. - route: forward source resolves via spawnNode (from = parent scope); drop the chainCtx workaround in home-env.nix. - Includes the scopeParent-walk pipe inheritance keeper. - Tests: all-peers, resolved-users, in-tree==threaded equivalency, server-host membership. Full CI 870/870. (cherry picked from commit 7875506423eaa1b0136198267e4b9da5a38a23ac) --- modules/aspects/batteries/host-aspects.nix | 36 +- nix/lib/aspects/fx/assemble-pipes.nix | 433 +++++++------ nix/lib/aspects/fx/handlers/default.nix | 1 + .../aspects/fx/handlers/register-spawn.nix | 23 + nix/lib/aspects/fx/pipeline.nix | 2 + nix/lib/aspects/fx/policy/apply.nix | 12 +- nix/lib/aspects/fx/policy/classify.nix | 7 +- nix/lib/aspects/fx/policy/dispatch.nix | 1 + nix/lib/aspects/fx/policy/iterate.nix | 1 + nix/lib/aspects/fx/policy/schema.nix | 3 +- nix/lib/aspects/fx/resolve.nix | 220 +++++-- nix/lib/aspects/fx/route/apply.nix | 40 +- nix/lib/aspects/fx/spawn-node.nix | 143 +++++ nix/lib/home-env.nix | 33 +- nix/lib/policy-effects.nix | 15 + .../ci/modules/features/home-extraction.nix | 594 ++++++++++++++++++ 16 files changed, 1246 insertions(+), 318 deletions(-) create mode 100644 nix/lib/aspects/fx/handlers/register-spawn.nix create mode 100644 nix/lib/aspects/fx/spawn-node.nix create mode 100644 templates/ci/modules/features/home-extraction.nix diff --git a/modules/aspects/batteries/host-aspects.nix b/modules/aspects/batteries/host-aspects.nix index 9d691a94e..35182f8e8 100644 --- a/modules/aspects/batteries/host-aspects.nix +++ b/modules/aspects/batteries/host-aspects.nix @@ -14,36 +14,14 @@ let specifically for `user.classes`. ''; - # Resolution-chain entity bindings (e.g. a parent `environment`) carried in - # the ambient context. Threaded into the re-resolution below so the host - # aspect tree binds the same ancestor args it bound at the host scope. - entityKindAttrs = lib.genAttrs den.lib.schemaUtil.schemaEntityKinds (_: null); - - # Re-resolve the host's aspect tree for a user's classes, projecting host - # class content (e.g. homeManager) onto the user. A policy (not a bare - # parametric include) so it receives the full ambient resolveCtx — the host - # scope is a descendant of its parent entities (e.g. `environment`), and that - # ancestor context must survive into the re-resolution. Without it, - # parametric host aspects re-fired here (e.g. a quirk emit `{ environment, - # host, ... }: ...`) would be stranded unresolved. + # Emit a deferred node spawn request. Resolution happens post-walk (in + # resolve.nix's drain augmentation) where the parent scope-tree state (host + + # siblings) exists, so the projection sees the fleet — a host-aspects-projected + # homeManager consumer of a fleet-collected pipe lists every peer. Ancestor + # bindings like `environment` arrive via the threaded scope context, not + # manual chainCtx threading. from-host = - { host, user, ... }@ctx: - let - chainCtx = builtins.intersectAttrs entityKindAttrs ctx // { - inherit host user; - }; - scopeHandlers = den.lib.aspects.fx.handlers.constantHandler chainCtx; - aspectWithCtx = host.aspect // { - __scopeHandlers = scopeHandlers; - }; - projected = { - name = "host-aspects/${user.userName}@${host.name}"; - } - // lib.genAttrs (user.classes or [ "homeManager" ]) ( - class: den.lib.aspects.resolveImports class aspectWithCtx - ); - in - [ (den.lib.policy.include projected) ]; + { host, user, ... }: [ (den.lib.policy.spawn { classes = user.classes or [ "homeManager" ]; }) ]; in { den.batteries.host-aspects = { diff --git a/nix/lib/aspects/fx/assemble-pipes.nix b/nix/lib/aspects/fx/assemble-pipes.nix index d829ec91f..fc9a5ac7e 100644 --- a/nix/lib/aspects/fx/assemble-pipes.nix +++ b/nix/lib/aspects/fx/assemble-pipes.nix @@ -88,21 +88,27 @@ let resolveEntry = hostConfigs: scopeContexts: sourceScopeId: entry: if isConfigDependent entry then - let - thunkArgs = builtins.functionArgs entry; - scopeCtx = scopeContexts.${sourceScopeId} or { }; - ctxArgs = lib.genAttrs (builtins.filter (k: scopeCtx ? ${k}) (builtins.attrNames thunkArgs)) ( - k: scopeCtx.${k} - ); - result = entry ( - ctxArgs - // { - config = hostConfigs.${sourceScopeId} or { }; - inherit lib; - } - ); - in - if builtins.isList result then result else [ result ] + if hostConfigs == null then + # No host configs on this crossing path: defer the config-dependent emit. + # The local evalModules fixpoint resolves it (via __configThunk). Collected + # config-dependent entries are never marked, so this is a clean pass-through. + [ entry ] + else + let + thunkArgs = builtins.functionArgs entry; + scopeCtx = scopeContexts.${sourceScopeId} or { }; + ctxArgs = lib.genAttrs (builtins.filter (k: scopeCtx ? ${k}) (builtins.attrNames thunkArgs)) ( + k: scopeCtx.${k} + ); + result = entry ( + ctxArgs + // { + config = hostConfigs.${sourceScopeId} or { }; + inherit lib; + } + ); + in + if builtins.isList result then result else [ result ] else if isPipelineParametric entry then let thunkArgs = builtins.functionArgs entry; @@ -116,14 +122,12 @@ let else [ entry ]; - # Resolve all config-dependent thunks in a list of values. - # Only used for collected entries (cross-host resolution). + # Resolve pipeline-parametric emits eagerly on every crossing path so the + # value crosses as data, not a function. Config-dependent emits stay deferred + # (resolved in the evalModules fixpoint via __configThunk) when no hostConfigs. resolveThunks = hostConfigs: scopeContexts: scopeId: values: - if hostConfigs == null then - values - else - builtins.concatMap (resolveEntry hostConfigs scopeContexts scopeId) values; + builtins.concatMap (resolveEntry hostConfigs scopeContexts scopeId) values; # Apply a single transform stage to a value list. # Config thunk markers (__configThunk) pass through filter/transform unchanged. @@ -666,15 +670,24 @@ let scopeImports = scopedClassImports.${scopeId} or { }; # Also include data already exposed to this scope from its children. exposedForScope = afterChildren.${scopeId} or { }; + scopeCtx = scopeContexts.${scopeId} or { }; newExposed = lib.foldl' ( acc: effect: let inherit (effect) pipeName; rawEntries = scopeImports.${pipeName} or [ ]; baseValues = flattenAndExtract rawEntries; - # Include child-exposed data in the base for transform stages. + # Resolve pipeline-parametric local emits at the exposing node so the + # value crosses the P edge upward as data; mark config-dependent emits + # so they carry __configThunk as they cross (consumers re-mark + # idempotently via mkCombinedBase, but marking at the source keeps + # multi-level expose chains correct without relying on every consumer + # to re-mark). Mirrors mkCombinedBase on the local path. + resolvedBase = markConfigThunks (builtins.concatMap (resolveLocalParametric scopeCtx) baseValues); + # Child-exposed data is already concrete — each child resolved its + # own at its own node — so include it as-is for transform stages. exposedValues = exposedForScope.${pipeName} or [ ]; - combinedBase = baseValues ++ exposedValues; + combinedBase = resolvedBase ++ exposedValues; transformed = applyTransformStages combinedBase (effect.stages or [ ]); in acc @@ -723,84 +736,89 @@ let scopeParent ; }; + + # A scope binds pipe `pn` locally when it emits it, receives it via + # pipe.expose, or runs a pipe policy effect for it. A pure-consumer + # scope binds nothing and inherits `pn` from the nearest ancestor whose + # policy bound it (the source) — see pipeData below. + bindsPipeLocally = + sid: pn: + ((scopedClassImports.${sid} or { }).${pn} or [ ]) != [ ] + || ((allExposed.${sid} or { }).${pn} or [ ]) != [ ] + || builtins.any (e: e.pipeName == pn) (scopedPipeEffects.${sid} or [ ]); + + # Nearest ancestor (walking scopeParent) whose pipe policy bound `pn`. + # That scope's assembled value is the source the consumer inherits. + policyBoundAncestor = + sid: pn: + let + parent = scopeParent.${sid} or null; + in + if parent == null || parent == sid then + null + else if builtins.any (e: e.pipeName == pn) (scopedPipeEffects.${parent} or [ ]) then + parent + else + policyBoundAncestor parent pn; in # Pass 2: Build final contexts with exposed data merged in. - lib.mapAttrs ( - scopeId: scopeCtx: - let - scopeImports = scopedClassImports.${scopeId} or { }; - scopeEffects = scopedPipeEffects.${scopeId} or [ ]; - exposedForScope = allExposed.${scopeId} or { }; - - # Resolve and mark a pipe's base values (imports + exposed), used by pipe.as routing. - mkCombinedBase = - pn: - let - rawEntries = scopeImports.${pn} or [ ]; - baseValues = flattenAndExtract rawEntries; - resolvedBase = builtins.concatMap (resolveLocalParametric scopeCtx) baseValues; - markedBase = markConfigThunks resolvedBase; - exposedValues = exposedForScope.${pn} or [ ]; - markedExposed = markConfigThunks exposedValues; - in - markedBase ++ markedExposed; + let + assembled = lib.mapAttrs ( + scopeId: scopeCtx: + let + scopeImports = scopedClassImports.${scopeId} or { }; + scopeEffects = scopedPipeEffects.${scopeId} or [ ]; + exposedForScope = allExposed.${scopeId} or { }; - # For each pipe, separate untargeted and targeted effects. - pipeData = lib.genAttrs pipeNames ( - pipeName: - let - combinedBase = mkCombinedBase pipeName; - exposedValues = exposedForScope.${pipeName} or [ ]; - relevantEffects = builtins.filter (e: e.pipeName == pipeName) scopeEffects; - # Validate no pipe.as self-targeting in this scope. - _asCheck = builtins.deepSeq (map ( - e: - assert assertNoSelfAs e; - null - ) (builtins.filter hasAsStage relevantEffects)) null; - # Exclude expose, targeted, and pipe.as effects from untargeted processing. - untargetedEffects = builtins.seq _asCheck ( - builtins.filter (e: !hasToStage e && !hasExposeStage e && !hasAsStage e) relevantEffects - ); + # Resolve and mark a pipe's base values (imports + exposed), used by pipe.as routing. + mkCombinedBase = + pn: + let + rawEntries = scopeImports.${pn} or [ ]; + baseValues = flattenAndExtract rawEntries; + resolvedBase = builtins.concatMap (resolveLocalParametric scopeCtx) baseValues; + markedBase = markConfigThunks resolvedBase; + exposedValues = exposedForScope.${pn} or [ ]; + markedExposed = markConfigThunks exposedValues; + in + markedBase ++ markedExposed; - # Find pipe.as effects from OTHER pipes that target this pipeName (untargeted only). - # Exclude pipe.expose effects — they route upward, not laterally. - asInbound = builtins.filter ( - e: - hasAsStage e - && !hasToStage e - && !hasExposeStage e - && getAsTarget e == pipeName - && e.pipeName != pipeName - ) scopeEffects; - - # Process each inbound pipe.as effect against its SOURCE pipe's base values. - asResults = lib.concatMap ( - e: - let - combinedSrc = mkCombinedBase e.pipeName; - in - applyEffectStages { - inherit - scopeContexts - scopeParent - scopeEntityKind - scopedClassImports - hostConfigs - ; - currentScopeId = scopeId; - pipeName = e.pipeName; - } combinedSrc (stripAsStage (e.stages or [ ])) - ) asInbound; - - normalResult = - if untargetedEffects == [ ] && relevantEffects == [ ] && exposedValues == [ ] then - combinedBase - else if untargetedEffects == [ ] then - # All effects are targeted, expose, or pipe.as — scope-wide data is base values unchanged. - combinedBase - else - applyPipeEffects { + # For each pipe, separate untargeted and targeted effects. + localPipeData = lib.genAttrs pipeNames ( + pipeName: + let + combinedBase = mkCombinedBase pipeName; + exposedValues = exposedForScope.${pipeName} or [ ]; + relevantEffects = builtins.filter (e: e.pipeName == pipeName) scopeEffects; + # Validate no pipe.as self-targeting in this scope. + _asCheck = builtins.deepSeq (map ( + e: + assert assertNoSelfAs e; + null + ) (builtins.filter hasAsStage relevantEffects)) null; + # Exclude expose, targeted, and pipe.as effects from untargeted processing. + untargetedEffects = builtins.seq _asCheck ( + builtins.filter (e: !hasToStage e && !hasExposeStage e && !hasAsStage e) relevantEffects + ); + + # Find pipe.as effects from OTHER pipes that target this pipeName (untargeted only). + # Exclude pipe.expose effects — they route upward, not laterally. + asInbound = builtins.filter ( + e: + hasAsStage e + && !hasToStage e + && !hasExposeStage e + && getAsTarget e == pipeName + && e.pipeName != pipeName + ) scopeEffects; + + # Process each inbound pipe.as effect against its SOURCE pipe's base values. + asResults = lib.concatMap ( + e: + let + combinedSrc = mkCombinedBase e.pipeName; + in + applyEffectStages { inherit scopeContexts scopeParent @@ -808,104 +826,141 @@ let scopedClassImports hostConfigs ; - } pipeName scopeId combinedBase untargetedEffects; - in - normalResult ++ asResults - ); + currentScopeId = scopeId; + pipeName = e.pipeName; + } combinedSrc (stripAsStage (e.stages or [ ])) + ) asInbound; + + normalResult = + if untargetedEffects == [ ] && relevantEffects == [ ] && exposedValues == [ ] then + combinedBase + else if untargetedEffects == [ ] then + # All effects are targeted, expose, or pipe.as — scope-wide data is base values unchanged. + combinedBase + else + applyPipeEffects { + inherit + scopeContexts + scopeParent + scopeEntityKind + scopedClassImports + hostConfigs + ; + } pipeName scopeId combinedBase untargetedEffects; + in + normalResult ++ asResults + ); - # Build __pipeTargeted: { aspectName → { pipeName → values } } - pipeTargeted = - let - perPipe = lib.genAttrs pipeNames ( - pipeName: + # Pure-consumer scopes inherit a pipe's assembled value from the + # nearest ancestor whose policy bound it. A user/home scope thus reads + # the host scope's fleet-collected data rather than an empty or + # self-only local value. Scopes that bind the pipe locally keep theirs. + pipeData = lib.mapAttrs ( + pipeName: localVal: + if bindsPipeLocally scopeId pipeName then + localVal + else let - combinedBase = mkCombinedBase pipeName; - relevantEffects = builtins.filter (e: e.pipeName == pipeName) scopeEffects; - # Targeted effects on this pipe WITHOUT pipe.as (they stay under this pipeName). - targetedEffects = builtins.filter (e: hasToStage e && !hasAsStage e) relevantEffects; - - # Targeted pipe.as effects from OTHER pipes that rename to this pipeName. - # Exclude pipe.expose effects — they route upward, not laterally. - asTargetedInbound = builtins.filter ( - e: - hasAsStage e - && hasToStage e - && !hasExposeStage e - && getAsTarget e == pipeName - && e.pipeName != pipeName - ) scopeEffects; - - # Build targeted data from native targeted effects. - nativeTargeted = - if targetedEffects == [ ] then - { } - else - buildTargetedData { - inherit - scopeContexts - scopeParent - scopeEntityKind - scopedClassImports - hostConfigs - ; - currentScopeId = scopeId; - } combinedBase targetedEffects; - - # Build targeted data from inbound pipe.as effects (using source pipe's base). - asTargetedResults = lib.foldl' ( - acc: e: - let - combinedSrc = mkCombinedBase e.pipeName; - result = buildTargetedData { - inherit - scopeContexts - scopeParent - scopeEntityKind - scopedClassImports - hostConfigs - ; - currentScopeId = scopeId; - } combinedSrc [ (e // { stages = stripAsStage (e.stages or [ ]); }) ]; - in - # Merge: concatenate values per aspect name. - lib.foldl' ( - a: aspectName: a // { ${aspectName} = (a.${aspectName} or [ ]) ++ result.${aspectName}; } - ) acc (builtins.attrNames result) - ) { } asTargetedInbound; - + anc = policyBoundAncestor scopeId pipeName; in - # Merge native targeted + inbound pipe.as targeted. - lib.foldl' ( - acc: aspectName: - acc // { ${aspectName} = (acc.${aspectName} or [ ]) ++ (asTargetedResults.${aspectName} or [ ]); } - ) nativeTargeted (builtins.attrNames asTargetedResults) - ); - # Invert: { pipeName → { aspectName → vals } } → { aspectName → { pipeName → vals } } - allAspectNames = lib.unique ( - lib.concatMap (pipeName: builtins.attrNames (perPipe.${pipeName})) pipeNames + if anc != null then assembled.${anc}.${pipeName} or localVal else localVal + ) localPipeData; + + # Build __pipeTargeted: { aspectName → { pipeName → values } } + pipeTargeted = + let + perPipe = lib.genAttrs pipeNames ( + pipeName: + let + combinedBase = mkCombinedBase pipeName; + relevantEffects = builtins.filter (e: e.pipeName == pipeName) scopeEffects; + # Targeted effects on this pipe WITHOUT pipe.as (they stay under this pipeName). + targetedEffects = builtins.filter (e: hasToStage e && !hasAsStage e) relevantEffects; + + # Targeted pipe.as effects from OTHER pipes that rename to this pipeName. + # Exclude pipe.expose effects — they route upward, not laterally. + asTargetedInbound = builtins.filter ( + e: + hasAsStage e + && hasToStage e + && !hasExposeStage e + && getAsTarget e == pipeName + && e.pipeName != pipeName + ) scopeEffects; + + # Build targeted data from native targeted effects. + nativeTargeted = + if targetedEffects == [ ] then + { } + else + buildTargetedData { + inherit + scopeContexts + scopeParent + scopeEntityKind + scopedClassImports + hostConfigs + ; + currentScopeId = scopeId; + } combinedBase targetedEffects; + + # Build targeted data from inbound pipe.as effects (using source pipe's base). + asTargetedResults = lib.foldl' ( + acc: e: + let + combinedSrc = mkCombinedBase e.pipeName; + result = buildTargetedData { + inherit + scopeContexts + scopeParent + scopeEntityKind + scopedClassImports + hostConfigs + ; + currentScopeId = scopeId; + } combinedSrc [ (e // { stages = stripAsStage (e.stages or [ ]); }) ]; + in + # Merge: concatenate values per aspect name. + lib.foldl' ( + a: aspectName: a // { ${aspectName} = (a.${aspectName} or [ ]) ++ result.${aspectName}; } + ) acc (builtins.attrNames result) + ) { } asTargetedInbound; + + in + # Merge native targeted + inbound pipe.as targeted. + lib.foldl' ( + acc: aspectName: + acc // { ${aspectName} = (acc.${aspectName} or [ ]) ++ (asTargetedResults.${aspectName} or [ ]); } + ) nativeTargeted (builtins.attrNames asTargetedResults) + ); + # Invert: { pipeName → { aspectName → vals } } → { aspectName → { pipeName → vals } } + allAspectNames = lib.unique ( + lib.concatMap (pipeName: builtins.attrNames (perPipe.${pipeName})) pipeNames + ); + in + lib.genAttrs allAspectNames ( + aspectName: + lib.genAttrs (builtins.filter (pn: perPipe.${pn} ? ${aspectName}) pipeNames) ( + pipeName: perPipe.${pipeName}.${aspectName} + ) ); - in - lib.genAttrs allAspectNames ( - aspectName: - lib.genAttrs (builtins.filter (pn: perPipe.${pn} ? ${aspectName}) pipeNames) ( - pipeName: perPipe.${pipeName}.${aspectName} - ) - ); - hasTargeted = pipeTargeted != { }; + hasTargeted = pipeTargeted != { }; - # Flag pipes that contain config thunk markers for resolution - # inside evalModules (see class-module.nix wrapFunctionModule). - pipeConfigThunks = lib.genAttrs (builtins.filter ( - pipeName: builtins.any (v: v ? __configThunk) (pipeData.${pipeName}) - ) pipeNames) (_: true); - hasConfigThunks = pipeConfigThunks != { }; - in - scopeCtx - // pipeData - // lib.optionalAttrs hasTargeted { __pipeTargeted = pipeTargeted; } - // lib.optionalAttrs hasConfigThunks { __pipeConfigThunks = pipeConfigThunks; } - ) scopeContexts; + # Flag pipes that contain config thunk markers for resolution + # inside evalModules (see class-module.nix wrapFunctionModule). + pipeConfigThunks = lib.genAttrs (builtins.filter ( + pipeName: builtins.any (v: v ? __configThunk) (pipeData.${pipeName}) + ) pipeNames) (_: true); + hasConfigThunks = pipeConfigThunks != { }; + in + scopeCtx + // pipeData + // lib.optionalAttrs hasTargeted { __pipeTargeted = pipeTargeted; } + // lib.optionalAttrs hasConfigThunks { __pipeConfigThunks = pipeConfigThunks; } + ) scopeContexts; + in + assembled; in { inherit assemblePipes; diff --git a/nix/lib/aspects/fx/handlers/default.nix b/nix/lib/aspects/fx/handlers/default.nix index b07cb23d7..d03150e79 100644 --- a/nix/lib/aspects/fx/handlers/default.nix +++ b/nix/lib/aspects/fx/handlers/default.nix @@ -13,6 +13,7 @@ args: // (import ./forward.nix args) // (import ./provide.nix args) // (import ./register-pipe-effect.nix args) +// (import ./register-spawn.nix args) // (import ./push-scope.nix args) // (import ./restore-scope.nix args) // (import ./propagate-routes.nix args) diff --git a/nix/lib/aspects/fx/handlers/register-spawn.nix b/nix/lib/aspects/fx/handlers/register-spawn.nix new file mode 100644 index 000000000..0eac63502 --- /dev/null +++ b/nix/lib/aspects/fx/handlers/register-spawn.nix @@ -0,0 +1,23 @@ +# Effect handler: register-spawn +# Collects deferred node spawn requests into scopedSpawns. The drain +# augmentation in resolve.nix materializes them post-walk over the parent +# pipeline's full scope-tree state. +_: +let + inherit (import ./state-util.nix) scopedAppend; + + registerSpawnHandler = { + "register-spawn" = + { param, state }: + let + scope = state.currentScope; + in + { + resume = null; + state = scopedAppend state "scopedSpawns" scope (param // { sourceScopeId = scope; }); + }; + }; +in +{ + inherit registerSpawnHandler; +} diff --git a/nix/lib/aspects/fx/pipeline.nix b/nix/lib/aspects/fx/pipeline.nix index 193b9e763..87221d666 100644 --- a/nix/lib/aspects/fx/pipeline.nix +++ b/nix/lib/aspects/fx/pipeline.nix @@ -53,6 +53,7 @@ let // handlers.registerInstantiateHandler // handlers.provideHandler // handlers.registerPipeEffectHandler + // handlers.registerSpawnHandler // resolveEntityHandler // handlers.pushScopeHandler // handlers.restoreScopeHandler @@ -152,6 +153,7 @@ let scopedInstantiates = _: { }; scopedProvides = _: { }; scopedPipeEffects = _: { }; + scopedSpawns = _: { }; scopedEmittedLocs = _: { }; # --- Scope-prefixed bookkeeping (future: scope-prefixed keys) --- diff --git a/nix/lib/aspects/fx/policy/apply.nix b/nix/lib/aspects/fx/policy/apply.nix index aa22c85b4..a1ff865a7 100644 --- a/nix/lib/aspects/fx/policy/apply.nix +++ b/nix/lib/aspects/fx/policy/apply.nix @@ -76,13 +76,21 @@ let e: e.value // { __pipePolicyName = e.__pipePolicyName or null; } ); - # Emit excludes, route/instantiate/provide/pipe effects, then run a continuation. + # Emit spawn-home effects via register-spawn handler. + policyEmitSpawn = sendEach "register-spawn" ( + e: e.value // { __spawnPolicyName = e.__spawnPolicyName or null; } + ); + + # Emit excludes, route/instantiate/provide/pipe/spawn effects, then run a continuation. emitPolicyEffectsThen = effects: cont: fx.bind (policyEmitExcludes effects.excludeEffects) ( _: fx.bind (policyEmitEffects effects.routeEffects effects.instantiateEffects effects.provideEffects) ( - _: fx.bind (policyEmitPipeEffects (effects.pipeEffects or [ ])) (_: cont) + _: + fx.bind (policyEmitPipeEffects (effects.pipeEffects or [ ])) ( + _: fx.bind (policyEmitSpawn (effects.spawnEffects or [ ])) (_: cont) + ) ) ); diff --git a/nix/lib/aspects/fx/policy/classify.nix b/nix/lib/aspects/fx/policy/classify.nix index 00c370a5f..30de6bcf5 100644 --- a/nix/lib/aspects/fx/policy/classify.nix +++ b/nix/lib/aspects/fx/policy/classify.nix @@ -65,6 +65,7 @@ let instantiateEffects = filterEffect "instantiate" r.effects; provideEffects = filterEffect "provide" r.effects; pipeEffects = filterEffect "pipe" r.effects; + spawnEffects = filterEffect "spawn" r.effects; }; # Tag cross-provider schema effects with their paired includes. @@ -88,7 +89,8 @@ let || r.routeEffects != [ ] || r.instantiateEffects != [ ] || r.provideEffects != [ ] - || r.pipeEffects != [ ]; + || r.pipeEffects != [ ] + || r.spawnEffects != [ ]; # Collect all schema effects, attaching cross-provider includes and source policy name. collectSchemaEffects = @@ -143,6 +145,9 @@ let pipeEffects = builtins.concatMap ( r: map (pe: pe // { __pipePolicyName = r.policyName; }) r.pipeEffects ) classified; + spawnEffects = builtins.concatMap ( + r: map (se: se // { __spawnPolicyName = r.policyName; }) r.spawnEffects + ) classified; }; in { diff --git a/nix/lib/aspects/fx/policy/dispatch.nix b/nix/lib/aspects/fx/policy/dispatch.nix index 0de28bcdd..3ca0c04fa 100644 --- a/nix/lib/aspects/fx/policy/dispatch.nix +++ b/nix/lib/aspects/fx/policy/dispatch.nix @@ -15,6 +15,7 @@ let instantiate = true; provide = true; pipe = true; + spawn = true; }; # Validate that each effect returned by a policy has a valid __policyEffect tag. diff --git a/nix/lib/aspects/fx/policy/iterate.nix b/nix/lib/aspects/fx/policy/iterate.nix index 254eef39f..7261fb0b2 100644 --- a/nix/lib/aspects/fx/policy/iterate.nix +++ b/nix/lib/aspects/fx/policy/iterate.nix @@ -16,6 +16,7 @@ let routeEffects = [ ]; instantiateEffects = [ ]; provideEffects = [ ]; + spawnEffects = [ ]; }; # Merge new dispatch results into the accumulator. diff --git a/nix/lib/aspects/fx/policy/schema.nix b/nix/lib/aspects/fx/policy/schema.nix index 7cc432850..d35f02bb7 100644 --- a/nix/lib/aspects/fx/policy/schema.nix +++ b/nix/lib/aspects/fx/policy/schema.nix @@ -162,7 +162,8 @@ let || late.routeEffects != [ ] || late.instantiateEffects != [ ] || late.provideEffects != [ ] - || late.excludeEffects != [ ]; + || late.excludeEffects != [ ] + || late.spawnEffects != [ ]; in if filteredPolicies == { } || !hasLateEffects then fx.pure null diff --git a/nix/lib/aspects/fx/resolve.nix b/nix/lib/aspects/fx/resolve.nix index 1bec873b0..74b30a2f1 100644 --- a/nix/lib/aspects/fx/resolve.nix +++ b/nix/lib/aspects/fx/resolve.nix @@ -8,6 +8,7 @@ let inherit (import ./wrap-classes.nix { inherit lib den; }) wrapCollectedClasses; inherit (import ./assemble-pipes.nix { inherit lib den; }) assemblePipes; + inherit (import ./spawn-node.nix { inherit lib den; }) mkSpawnNode; route = import ./route { inherit lib den; }; handlers = den.lib.aspects.fx.handlers; @@ -146,9 +147,12 @@ let } ) acc allProvides; - # Phase 3: Apply routes. + # Phase 3: Apply routes. The first positional is the node spawn primitive + # (threaded with this pipeline's parent scope-tree state) used to resolve a + # complex-route forward SOURCE with full fleet visibility (replaces the old + # isolated fxResolve fallback). applyRoutes = - fxResolve: ctx: scopeContexts: rootScopeId: scopeParent: scopedRoutes: acc: + spawnNode: ctx: scopeContexts: rootScopeId: scopeParent: scopedRoutes: acc: route.applyRoutes { inherit scopedRoutes @@ -156,7 +160,7 @@ let scopeParent ctx rootScopeId - fxResolve + spawnNode ; wrappedPerScope = acc.perScope; classImports = acc.classImports; @@ -259,7 +263,7 @@ let scopedRoutes, scopeParent, scopeEntityClass ? (_: { }), - fxResolveFn, + spawnNodeFn, ctx, }: spec: @@ -310,7 +314,7 @@ let subtreePhase1 = wrapPerScope ctx subtreeContexts subtreeClassImports; subtreePhase2 = applyProvides ctx relevantContexts subtreeProvides subtreePhase1; subtreePhase3 = - applyRoutes fxResolveFn ctx relevantContexts hostScopeId scopeParent subtreeRoutes + applyRoutes spawnNodeFn ctx relevantContexts hostScopeId scopeParent subtreeRoutes subtreePhase2; in extractSubtreeModules subtreePhase3.perScope scopeParent hostScopeId hostClass @@ -356,7 +360,7 @@ let scopedRoutes, scopeParent, scopeEntityClass ? (_: { }), - fxResolveFn, + spawnNodeFn, ctx, }: classImports: @@ -369,7 +373,7 @@ let scopedRoutes scopeParent scopeEntityClass - fxResolveFn + spawnNodeFn ctx ; }; @@ -501,9 +505,11 @@ let lib.attrValues scopedClassImportsRaw ); - # Pipe-data-free host configs for cross-host config thunk resolution. - # Only computed when config-dependent thunks actually exist in the pipe - # data. When null, resolveThunks in assemblePipes short-circuits. + # Pipe-data-free host configs for cross-host config-dependent thunk + # resolution. Only computed when config-dependent thunks actually exist + # in the pipe data. When null, resolveThunks still resolves + # pipeline-parametric emits, but config-dependent collected emits are + # deferred (resolveEntry returns them unchanged). hostConfigs = if !hasAnyConfigThunk then null @@ -538,7 +544,7 @@ let scopeParent ; scopeEntityClass = result.state.scopeEntityClass or (_: { }); - fxResolveFn = fxResolve mkPipeline; + spawnNodeFn = spawnNode; inherit ctx; }; in @@ -555,6 +561,37 @@ let inherit scopeParent; }; + # Parent-state bundle for node spawns. Uses the RAW scopeContexts and + # scopedClassImports (not the augmented/drained maps): the spawned node + # re-derives pipes via its OWN assemblePipes over the merged state, so + # threading the augmented map would double-apply and feeding the drained + # map (which depends on this bundle) would cycle. scopeEntityKind is the + # already-unwrapped binding above. scopedClassImports here covers host + + # all siblings, which collectAll needs to find fleet peers. + parentState = { + inherit + scopeContexts + scopeParent + ctx + scopeEntityKind + ; + scopedClassImports = scopedClassImportsRaw; + scopedPipeEffects = result.state.scopedPipeEffects null; + }; + # Recursive: a nested complex forward inside a spawned node resolves its + # source via this SAME threaded primitive (not an isolated pipeline), so + # nested forwards stay fleet-visible and the resolver contract matches + # resolveSourceFallback's { from, class, aspect, bindings } call. Nix lets + # are lazy, so the self-reference is fine — selfRef is only invoked at + # runtime when a resolved aspect carries a complex non-collected forward, + # and a finite forward nesting terminates. + spawnNode = mkSpawnNode { + inherit wrapPerScope applyProvides applyRoutes; + inherit (den.lib.aspects) normalizeRoot; + inherit (den.lib.aspects.fx.aspect) ctxFromHandlers; + selfRef = spawnNode; + } mkPipeline parentState; + # Post-assembly drain: resolve deferred includes. # Two categories of deferred includes are drained here: # 1. Pipe-arg deferred: required args are pipe names, now available @@ -594,60 +631,103 @@ let inherited = lib.filterAttrs (k: _: !(ownCtx ? ${k})) ancestorCtx; in ownCtx // inherited; + + baseDrain = lib.foldl' ( + accImports: scopeId: + let + deferred = allDeferred.${scopeId} or [ ]; + scopeCtx = enrichedScopeCtx scopeId; + # Drain all deferred includes whose args are now satisfied, + # not just pipe-arg deferred ones. + drainable = builtins.filter (d: builtins.all (k: scopeCtx ? ${k}) (d.requiredArgs or [ ])) deferred; + in + if drainable == [ ] then + accImports + else + let + newEntries = lib.concatMap ( + d: + let + child = d.child; + classified = classifyKeys null child; + in + lib.concatMap ( + k: + let + modules = unwrapContentValuesList child.${k}; + in + map (module: { + __rawEntry = true; + class = k; + inherit module; + ctx = scopeCtx; + identity = child.name or ""; + aspectPolicy = child.meta.collisionPolicy or null; + globalPolicy = den.config.classModuleCollisionPolicy or "error"; + isContextDependent = false; + }) modules + ) classified.classKeys + ) drainable; + in + builtins.foldl' ( + acc: entry: + acc + // { + ${scopeId} = (acc.${scopeId} or { }) // { + ${entry.class} = ((acc.${scopeId} or { }).${entry.class} or [ ]) ++ [ entry ]; + }; + } + ) accImports newEntries + ) scopedClassImportsRaw (builtins.attrNames allDeferred); + + # Materialize deferred node spawn markers (policy.spawn) over + # the parent scope-tree state. Each marker lives at a user scope; the + # home class is re-walked from that user's host aspect with `user` + # bound, threaded with host + sibling state so fleet-collected pipes + # resolve to data and collectAll sees every peer. The result is folded + # into the user scope's class buckets so BOTH phase1 and the phase4 + # per-host re-walk (over drainedClassImportsRaw) deliver it. + allHomeNodes = (result.state.scopedSpawns or (_: { })) null; in lib.foldl' ( - accImports: scopeId: + acc: scopeId: let - deferred = allDeferred.${scopeId} or [ ]; - scopeCtx = enrichedScopeCtx scopeId; - # Drain all deferred includes whose args are now satisfied, - # not just pipe-arg deferred ones. - drainable = builtins.filter (d: builtins.all (k: scopeCtx ? ${k}) (d.requiredArgs or [ ])) deferred; + sctx = scopeContexts.${scopeId} or { }; + host = sctx.host or null; + user = sctx.user or null; + from = scopeParent.${scopeId} or null; + specs = allHomeNodes.${scopeId}; + defaultClasses = user.classes or [ "homeManager" ]; + classes = lib.unique ( + lib.concatMap (s: if s.classes != null then s.classes else defaultClasses) specs + ); in - if drainable == [ ] then - accImports + if host == null || from == null then + acc else - let - newEntries = lib.concatMap ( - d: - let - child = d.child; - classified = classifyKeys null child; - in - lib.concatMap ( - k: - let - modules = unwrapContentValuesList child.${k}; - in - map (module: { - __rawEntry = true; - class = k; - inherit module; - ctx = scopeCtx; - identity = child.name or ""; - aspectPolicy = child.meta.collisionPolicy or null; - globalPolicy = den.config.classModuleCollisionPolicy or "error"; - isContextDependent = false; - }) modules - ) classified.classKeys - ) drainable; - in - builtins.foldl' ( - acc: entry: - acc - // { - ${scopeId} = (acc.${scopeId} or { }) // { - ${entry.class} = ((acc.${scopeId} or { }).${entry.class} or [ ]) ++ [ entry ]; - }; - } - ) accImports newEntries - ) scopedClassImportsRaw (builtins.attrNames allDeferred); + acc + // { + ${scopeId} = + (acc.${scopeId} or { }) + // lib.genAttrs classes ( + cls: + ((acc.${scopeId} or { }).${cls} or [ ]) + ++ (spawnNode { + inherit from; + class = cls; + aspect = host.aspect; + bindings = { + inherit user; + }; + }).imports + ); + } + ) baseDrain (builtins.attrNames allHomeNodes); phase1 = wrapPerScope ctx augmentedScopeContexts drainedClassImportsRaw; phase2 = applyProvides ctx augmentedScopeContexts scopedProvides phase1; phase3 = - applyRoutes (fxResolve mkPipeline) ctx augmentedScopeContexts result.state.rootScopeId scopeParent - scopedRoutes + applyRoutes spawnNode ctx augmentedScopeContexts result.state.rootScopeId scopeParent scopedRoutes phase2; phase4 = applyInstantiates { scopedInstantiates = result.state.scopedInstantiates null; @@ -662,7 +742,7 @@ let # Pass drained class imports so pipe-arg deferred aspects are # included in per-host subtree assembly. scopedClassImportsRaw = drainedClassImportsRaw; - fxResolveFn = fxResolve mkPipeline; + spawnNodeFn = spawnNode; } phase3.classImports; in { @@ -684,19 +764,39 @@ let result = mkPipeline { inherit class; } { inherit self ctx; }; scopeContexts = result.state.scopeContexts null; scopedClassImportsRaw = result.state.scopedClassImports null; + scopeParent = result.state.scopeParent null; augmentedScopeContexts = assemblePipes { inherit scopeContexts; scopedClassImports = scopedClassImportsRaw; scopedPipeEffects = result.state.scopedPipeEffects null; - scopeParent = result.state.scopeParent null; + inherit scopeParent; + }; + + # Analogous parent-state bundle so a nested complex-route forward inside + # this (non-instantiating) resolution still resolves its source via a + # threaded spawned node rather than an isolated pipeline. No drain/phase4 + # here, so this only matters for nested node resolution. + parentState = { + inherit scopeContexts scopeParent ctx; + scopeEntityKind = (result.state.scopeEntityKind or (_: { })) null; + scopedClassImports = scopedClassImportsRaw; + scopedPipeEffects = result.state.scopedPipeEffects null; }; + # Recursive: see fxResolve above. selfRef is the threaded primitive itself + # so a nested complex forward inside a spawned node resolves its source via + # the same fleet-visible spawn (matching resolveSourceFallback's contract). + spawnNode = mkSpawnNode { + inherit wrapPerScope applyProvides applyRoutes; + inherit (den.lib.aspects) normalizeRoot; + inherit (den.lib.aspects.fx.aspect) ctxFromHandlers; + selfRef = spawnNode; + } mkPipeline parentState; phase1 = wrapPerScope ctx augmentedScopeContexts scopedClassImportsRaw; phase2 = applyProvides ctx augmentedScopeContexts (result.state.scopedProvides null) phase1; phase3 = - applyRoutes (fxResolveImports mkPipeline) ctx augmentedScopeContexts result.state.rootScopeId - (result.state.scopeParent null) + applyRoutes spawnNode ctx augmentedScopeContexts result.state.rootScopeId scopeParent (result.state.scopedRoutes null) phase2; in diff --git a/nix/lib/aspects/fx/route/apply.nix b/nix/lib/aspects/fx/route/apply.nix index 7cf4799f3..9166d2ea1 100644 --- a/nix/lib/aspects/fx/route/apply.nix +++ b/nix/lib/aspects/fx/route/apply.nix @@ -44,19 +44,26 @@ let acc.classImports.${spec.fromClass} or [ ]; resolveSourceFallback = - spec: fxResolve: scopeContexts: ctx: - if !(spec ? sourceAspect) || fxResolve == null then + spec: spawnNode: scopeParent: scopeContexts: ctx: + # Early-out to no source: the spec has nothing to resolve from (no source + # aspect/scope), or spawnNode wasn't threaded (non-home callers pass + # null via the applyRoutes default). + if !(spec ? sourceAspect) || spawnNode == null || !(spec ? sourceScopeId) then [ ] else - let - normalized = den.lib.aspects.normalizeRoot spec.sourceAspect; - sourceCtx = scopeContexts.${spec.sourceScopeId} or ctx; - in - (fxResolve { + (spawnNode { + # spec.sourceScopeId is the USER scope (the forward compiles at the + # current scope, per compile-forward.nix sourceScopeId = scope). `from` + # must be the HOST scope = the user scope's parent. Using sourceScopeId + # directly gives a self-parent edge -> policyBoundAncestor returns null + # -> zero fleet peers (and spawnNode's spawnRoot == from assert trips). + from = scopeParent.${spec.sourceScopeId} or spec.sourceScopeId; class = spec.fromClass; - self = normalized; - ctx = - sourceCtx // den.lib.aspects.fx.aspect.ctxFromHandlers (spec.sourceAspect.__scopeHandlers or { }); + aspect = den.lib.aspects.normalizeRoot spec.sourceAspect; + # The user binding is re-supplied by the source aspect's __scopeHandlers + # (ctxFromHandlers in spawnNode's seedCtx), so spawnRoot resolves to + # the user scope; do NOT strip it here. + bindings = { }; }).imports; appendToClass = acc: cls: sid: newMods: { @@ -76,8 +83,9 @@ let route, rootScopeId, scopeContexts, + scopeParent, ctx, - fxResolve, + spawnNode, buildForwardAspect, isDenDefaultModule, }: @@ -85,7 +93,10 @@ let spec = route; collected = getCollectedSource acc spec rootScopeId isDenDefaultModule scopeContexts; sourceModules = - if collected != [ ] then collected else resolveSourceFallback spec fxResolve scopeContexts ctx; + if collected != [ ] then + collected + else + resolveSourceFallback spec spawnNode scopeParent scopeContexts ctx; sourceModule = spec.mapModule { imports = sourceModules; }; newMods = collectClassMods spec.intoClass (buildForwardAspect spec sourceModule); in @@ -303,7 +314,7 @@ let scopeParent ? { }, scopeContexts ? { }, ctx ? { }, - fxResolve ? null, + spawnNode ? null, rootScopeId ? null, buildForwardAspect ? null, }: @@ -321,8 +332,9 @@ let route rootScopeId scopeContexts + scopeParent ctx - fxResolve + spawnNode buildForwardAspect isDenDefaultModule ; diff --git a/nix/lib/aspects/fx/spawn-node.nix b/nix/lib/aspects/fx/spawn-node.nix new file mode 100644 index 000000000..1e543d35b --- /dev/null +++ b/nix/lib/aspects/fx/spawn-node.nix @@ -0,0 +1,143 @@ +# Node-spawn primitive. +# +# A spawned node is an independent resolution node spawned from a parent +# (host) scope, threaded with the parent pipeline's resolved scope-tree state +# (host + ALL siblings) so its OWN assemblePipes pass re-derives +# inherited/collected pipe values with full fleet visibility. den-hoag: a +# `spawn` with one read-only inherited edge (neededBy the parent's resolved +# state) — parallel-schedulable, not a sequential route fold. +{ lib, den }: +let + inherit (import ./assemble-pipes.nix { inherit lib den; }) assemblePipes; + pipeNamesSet = lib.genAttrs (builtins.attrNames (den.quirks or { })) (_: true); +in +{ + # Phase helpers (wrapPerScope/applyProvides/applyRoutes) and the recursive + # nested-route resolver (selfRef) are injected to avoid a resolve.nix import + # cycle. mkPipeline + parentState are captured once per run; the inner + # { from, class, aspect, bindings } call materializes a single class. + mkSpawnNode = + { + wrapPerScope, + applyProvides, + applyRoutes, + normalizeRoot, + ctxFromHandlers, + selfRef, + }: + mkPipeline: parentState: + { + from, + class, + aspect, + bindings ? { }, + }: + let + normalized = normalizeRoot aspect; + seedCtx = + (parentState.scopeContexts.${from} or parentState.ctx) + // ctxFromHandlers (aspect.__scopeHandlers or { }) + // bindings; + + # 1. Walk the aspect for the single target class -> the spawned subtree state. + result = mkPipeline { inherit class; } { + self = normalized; + ctx = seedCtx; + }; + spawnRoot = result.state.rootScopeId; + + # The spawn root must be a distinct child of `from`. A self-parent edge + # (spawnRoot == from) collapses policyBoundAncestor to null -> zero peers, + # silently reproducing the single-host bug. Throw with context (not a bare + # assert) so a future regression names the collapsed scope and its cause. + _assertRoot = + if spawnRoot != from then + null + else + throw "den: spawnNode spawn root equals its parent scope '${from}' — a self-parent edge collapses policyBoundAncestor to null and yields zero fleet peers. The seed ctx likely lost its child binding (e.g. `user`)."; + + mergedPipeEffects = parentState.scopedPipeEffects // (result.state.scopedPipeEffects null); + + # A pipe `pn` is host-bound when the host scope (`from`) or one of its + # ancestors ran a pipe policy effect for it (e.g. a fleet `collectAll`). + ancestorBoundPipe = + pn: + let + go = + sid: + if sid == null || sid == spawnRoot then + false + else if builtins.any (e: e.pipeName == pn) (mergedPipeEffects.${sid} or [ ]) then + true + else + go (parentState.scopeParent.${sid} or null); + in + go from; + + # The node walk re-emits the host aspect's pipe-named keys (e.g. + # `host-addrs`) at the spawn root. For a HOST-BOUND pipe, that local + # re-emission makes the spawned scope bind the pipe locally (a self-only + # value), shadowing inheritance of the host's policy-assembled value + # (e.g. a fleet collectAll). The spawned scope is a pure consumer there, so + # strip those keys and let policyBoundAncestor inherit the host's value. + # Pipes with NO host-bound policy (a plain local emit-and-consume within + # the host aspect tree) keep their local emission — there is no ancestor + # value to inherit. Class keys (homeManager, nixos, …) are always kept. + strippableNames = builtins.filter ancestorBoundPipe (builtins.attrNames pipeNamesSet); + spawnedClassImports = lib.mapAttrs ( + _: scopeClasses: builtins.removeAttrs scopeClasses strippableNames + ) (result.state.scopedClassImports null); + + # 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; + mergedScopeParent = + parentState.scopeParent + // (result.state.scopeParent null) + // { + ${spawnRoot} = from; + }; + + # 3. Re-derive pipes over merged state. hostConfigs = null: config-dependent + # stay deferred (via __configThunk); pipeline-parametric resolve eagerly. + augmented = builtins.seq _assertRoot (assemblePipes { + scopeContexts = mergedScopeContexts; + scopedClassImports = mergedClassImports; + scopedPipeEffects = mergedPipeEffects; + scopeParent = mergedScopeParent; + scopeEntityKind = parentState.scopeEntityKind; + hostConfigs = null; + }); + + # 4. Phases 1-3 over the spawned subtree; class isolation -> one class emitted. + phase1 = wrapPerScope parentState.ctx augmented mergedClassImports; + phase2 = applyProvides parentState.ctx augmented (result.state.scopedProvides null) phase1; + phase3 = + applyRoutes selfRef parentState.ctx augmented spawnRoot mergedScopeParent + (result.state.scopedRoutes null) + phase2; + + # Restrict extraction to the spawned subtree (spawnRoot + descendants). + # phase3.classImports aggregates across ALL merged scopes — including the + # host and SIBLING user scopes (the pipe-collection peers, and other users + # on the same host) — so reading it directly would leak a peer user's + # homeManager content into this node. The fleet pipe values still resolve + # correctly because assemblePipes ran over the full merged state; only the + # final per-scope class buckets are subtree-restricted here. + isInSubtree = + sid: + sid == spawnRoot + || ( + let + parent = mergedScopeParent.${sid} or null; + in + parent != null && parent != sid && isInSubtree parent + ); + subtreeScopes = builtins.filter isInSubtree (builtins.attrNames phase3.perScope); + in + { + imports = lib.concatMap (sid: phase3.perScope.${sid}.${class} or [ ]) subtreeScopes; + }; +} diff --git a/nix/lib/home-env.nix b/nix/lib/home-env.nix index 90fa9ea97..11a46a4a7 100644 --- a/nix/lib/home-env.nix +++ b/nix/lib/home-env.nix @@ -85,26 +85,18 @@ let ]; }; - # Resolution-chain entity bindings (e.g. a parent `environment`) carried in - # the ambient policy context. Threaded into the home extraction below so a - # user scope created during home-manager extraction inherits the same - # ancestor bindings it would have as a normal descendant in the chain. - entityKindAttrs = lib.genAttrs den.lib.schemaUtil.schemaEntityKinds (_: null); - chainBindingsFrom = ctx: builtins.intersectAttrs entityKindAttrs ctx; - userForward = - chainCtx: { host, user }: den.batteries.forward { each = lib.singleton true; fromClass = _: className; intoClass = _: host.class; intoPath = _: forwardPathFn { inherit host user; }; - # Seed the home extraction with the ambient resolution-chain bindings - # so parametric host aspects re-fired at the user scope bind the same - # args (e.g. `environment`) they would bind at the host scope, instead - # of being stranded unresolved. (was: { inherit host user; }) - fromAspect = _: den.lib.resolveEntity "user" (chainCtx // { inherit host user; }); + # The forward source resolves via spawnNode (threaded with the + # parent scope-tree state), so parametric host aspects re-fired at the + # user scope bind the same ancestor args (e.g. `environment`) they + # would at the host scope — no manual chainCtx threading needed. + fromAspect = _: den.lib.resolveEntity "user" { inherit host user; }; }; # Includes shared by both host-scope and user-scope detection. @@ -116,7 +108,7 @@ let ); policyFn = - { host, ... }@policyCtx: + { host, ... }: let enabled = mkDetectHost { inherit className supportedOses optionPath; @@ -126,11 +118,10 @@ let [ ] else let - chainCtx = chainBindingsFrom policyCtx; pairs = mkIntoClassUsers className { inherit host; }; resolves = map ( pair: - den.lib.policy.resolve.withIncludes ([ (userForward chainCtx) ] ++ schemaIncludes) { + den.lib.policy.resolve.withIncludes ([ userForward ] ++ schemaIncludes) { user = pair.user; } ) pairs; @@ -140,18 +131,16 @@ let # Complements the host-scope battery which only sees users # declared on host.users, not registry or policy-resolved users. userDetectFn = - { host, user, ... }@userCtx: + { host, user, ... }: let isOsSupported = builtins.elem host.class supportedOses; hasClass = lib.elem className user.classes; in lib.optionals (isOsSupported && hasClass) ( [ - (den.lib.policy.include ( - userForward (chainBindingsFrom userCtx) { - inherit host user; - } - )) + (den.lib.policy.include (userForward { + inherit host user; + })) ] ++ classIncludes ); diff --git a/nix/lib/policy-effects.nix b/nix/lib/policy-effects.nix index cf81e815d..2d46e80fd 100644 --- a/nix/lib/policy-effects.nix +++ b/nix/lib/policy-effects.nix @@ -123,6 +123,21 @@ in value = spec; }; + # Request a deferred node spawn. Records a marker resolved post-walk + # over the parent pipeline's full scope-tree state (host + siblings), so the + # projected home content sees fleet-wide pipe values. `classes` defaults to + # the user's classes (or homeManager) at the drain site when null. + spawn = + { + classes ? null, + }: + { + __policyEffect = "spawn"; + value = { + inherit classes; + }; + }; + # Pipe transform builder — policies use pipe.from to attach transform # stages (filter, transform, fold, append, for) to a named pipe. pipe = { diff --git a/templates/ci/modules/features/home-extraction.nix b/templates/ci/modules/features/home-extraction.nix new file mode 100644 index 000000000..a376988c6 --- /dev/null +++ b/templates/ci/modules/features/home-extraction.nix @@ -0,0 +1,594 @@ +{ denTest, ... }: +{ + flake.tests.home-extraction = { + # A pipeline-parametric pipe collected across a fleet must resolve to + # concrete DATA at the consumer even when no host configs are available on + # the collected path. Before the fix, the raw `{ host, ... }: ...` lambda + # crossed the collected path unresolved when hostConfigs == null, crashing + # the consumer with "expected a set but found a function". + test-collected-parametric-no-config-resolves = denTest ( + { + den, + igloo, + lib, + ... + }: + let + inherit (den.lib.policy) pipe resolve instantiate; + in + { + den.quirks.host-addrs.description = "Host address entries"; + den.policies.to-fleet = _: [ + (resolve.to "fleet" { + fleet = { + name = "fleet"; + }; + }) + ]; + den.policies.fleet-to-hosts = + { fleet, ... }: + lib.concatMap ( + system: + lib.concatMap ( + hostName: + let + host = den.hosts.${system}.${hostName}; + in + [ + (resolve.to "host" { inherit host; }) + (instantiate host) + ] + ) (builtins.attrNames (den.hosts.${system} or { })) + ) (builtins.attrNames (den.hosts or { })); + den.policies.collect-addrs = _: [ + (pipe.from "host-addrs" [ (pipe.collectAll ({ host, ... }: true)) ]) + ]; + den.schema.flake.includes = [ den.policies.to-fleet ]; + den.schema.fleet.includes = [ den.policies.fleet-to-hosts ]; + den.schema.host.includes = [ den.policies.collect-addrs ]; + den.schema.flake-system.excludes = [ + den.policies.system-to-os-outputs + den.policies.system-to-hm-outputs + ]; + + den.hosts.x86_64-linux.igloo.users = { }; + den.hosts.x86_64-linux.iceberg.users = { }; + den.aspects.igloo.host-addrs = + { host, ... }: + { + hostname = host.name; + }; + den.aspects.iceberg.host-addrs = + { host, ... }: + { + hostname = host.name; + }; + den.aspects.igloo.nixos = + { + host-addrs, + lib, + ... + }: + { + networking.extraHosts = lib.concatStringsSep "," ( + lib.sort (a: b: a < b) (map (e: e.hostname) host-addrs) + ); + }; + + expr = igloo.networking.extraHosts; + expected = "iceberg,igloo"; + } + ); + + # A pipeline-parametric pipe exposed upward (child → parent) must resolve to + # concrete DATA at the exposing scope (the user node) before crossing the + # P edge to the host consumer. Before the fix, the raw `{ user, ... }: ...` + # lambda crossed the expose path unresolved, crashing the host consumer with + # "expected a set but found a function". + test-exposed-parametric-resolves = denTest ( + { + den, + igloo, + lib, + ... + }: + let + inherit (den.lib.policy) pipe; + in + { + den.quirks.resolved-users.description = "Users resolved onto a host"; + den.policies.expose-users = { user, ... }: [ (pipe.from "resolved-users" [ pipe.expose ]) ]; + den.schema.user.includes = [ den.policies.expose-users ]; + den.hosts.x86_64-linux.igloo.users = { + tux = { }; + pingu = { }; + }; + den.aspects.tux.resolved-users = + { user, ... }: + { + name = user.userName; + }; + den.aspects.pingu.resolved-users = + { user, ... }: + { + name = user.userName; + }; + den.aspects.igloo.nixos = + { resolved-users, lib, ... }: + { + users.groups.wheel.members = lib.sort (a: b: a < b) (map (u: u.name) resolved-users); + }; + expr = igloo.users.groups.wheel.members; + expected = [ + "pingu" + "tux" + ]; + } + ); + + # A config-dependent pipe entry exposed upward must STAY deferred: the + # __configThunk marker (now also stamped at the emitting child node, in + # addition to the consuming host's mkCombinedBase) must survive the expose + # crossing and be resolved in the host's evalModules fixpoint. The user emits + # a thunk reading the host's NixOS config; the host consumer reads the resolved value. + test-exposed-config-thunk-defers = denTest ( + { + den, + igloo, + lib, + ... + }: + let + inherit (den.lib.policy) pipe; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.quirks.host-marks.description = "Host-config-derived marks from users"; + + # Host sets its hostname statically (not pipe-dependent), so the + # config-dependent thunk can read it without a circular dependency. + den.aspects.set-hostname = { + nixos = + { host, ... }: + { + networking.hostName = host.name; + }; + }; + + den.policies.expose-marks = { user, ... }: [ (pipe.from "host-marks" [ pipe.expose ]) ]; + den.schema.user.includes = [ den.policies.expose-marks ]; + den.schema.host.includes = [ den.aspects.set-hostname ]; + + # Config-dependent emit at the user node: must defer (marked + # __configThunk) and resolve against the host's evalModules config. + den.aspects.tux.host-marks = { config, ... }: [ "mark-${config.networking.hostName}" ]; + + den.aspects.igloo.nixos = + { host-marks, lib, ... }: + { + networking.domain = lib.concatStringsSep "," (lib.sort (a: b: a < b) host-marks); + }; + + expr = igloo.networking.domain; + expected = "mark-igloo"; + } + ); + + # host-aspects-projected homeManager consumer of a fleet-collected pipe must + # see ALL fleet peers, not just its own host. The projection is materialized + # via a deferred policy.spawn marker resolved post-walk over the parent's + # full scope-tree state (host + siblings), so collectAll finds every host. + test-host-aspects-all-peers = denTest ( + { + den, + tuxHm, + lib, + ... + }: + let + inherit (den.lib.policy) pipe resolve instantiate; + in + { + den.quirks.host-addrs.description = "Host address entries"; + den.policies.to-fleet = _: [ + (resolve.to "fleet" { + fleet = { + name = "fleet"; + }; + }) + ]; + den.policies.fleet-to-hosts = + { fleet, ... }: + lib.concatMap ( + system: + lib.concatMap ( + hostName: + let + host = den.hosts.${system}.${hostName}; + in + [ + (resolve.to "host" { inherit host; }) + (instantiate host) + ] + ) (builtins.attrNames (den.hosts.${system} or { })) + ) (builtins.attrNames (den.hosts or { })); + den.policies.collect-addrs = _: [ + (pipe.from "host-addrs" [ (pipe.collectAll ({ host, ... }: true)) ]) + ]; + den.schema.flake.includes = [ den.policies.to-fleet ]; + den.schema.fleet.includes = [ den.policies.fleet-to-hosts ]; + den.schema.host.includes = [ den.policies.collect-addrs ]; + den.schema.flake-system.excludes = [ + den.policies.system-to-os-outputs + den.policies.system-to-hm-outputs + ]; + + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.hosts.x86_64-linux.iceberg.users.alice = { }; + den.aspects.igloo.host-addrs = + { host, ... }: + { + hostname = host.name; + }; + den.aspects.iceberg.host-addrs = + { host, ... }: + { + hostname = host.name; + }; + # Host aspect projects an HM consumer of the fleet-collected pipe onto users. + den.aspects.igloo.homeManager = + { host-addrs, lib, ... }: + { + home.sessionVariables.HOSTS = lib.concatStringsSep "," ( + lib.sort (a: b: a < b) (map (e: e.hostname) host-addrs) + ); + }; + den.aspects.tux.includes = [ den.batteries.host-aspects ]; + expr = tuxHm.home.sessionVariables.HOSTS; + expected = "iceberg,igloo"; + } + ); + + # The user's OWN homeManager (makeHomeEnv forward source, NOT the + # host-aspects battery) consuming a fleet-collected pipe must also see ALL + # peers. Its forward SOURCE resolves via spawnNode threaded with the + # parent scope-tree state (host + siblings), so collectAll finds every host + # — exactly as the host-aspects projection does. + test-user-own-all-peers = denTest ( + { + den, + tuxHm, + lib, + ... + }: + let + inherit (den.lib.policy) pipe resolve instantiate; + in + { + den.quirks.host-addrs.description = "Host address entries"; + den.policies.to-fleet = _: [ + (resolve.to "fleet" { + fleet = { + name = "fleet"; + }; + }) + ]; + den.policies.fleet-to-hosts = + { fleet, ... }: + lib.concatMap ( + system: + lib.concatMap ( + hostName: + let + host = den.hosts.${system}.${hostName}; + in + [ + (resolve.to "host" { inherit host; }) + (instantiate host) + ] + ) (builtins.attrNames (den.hosts.${system} or { })) + ) (builtins.attrNames (den.hosts or { })); + den.policies.collect-addrs = _: [ + (pipe.from "host-addrs" [ (pipe.collectAll ({ host, ... }: true)) ]) + ]; + den.schema.flake.includes = [ den.policies.to-fleet ]; + den.schema.fleet.includes = [ den.policies.fleet-to-hosts ]; + den.schema.host.includes = [ den.policies.collect-addrs ]; + den.schema.flake-system.excludes = [ + den.policies.system-to-os-outputs + den.policies.system-to-hm-outputs + ]; + + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.hosts.x86_64-linux.iceberg.users.alice = { }; + den.aspects.igloo.host-addrs = + { host, ... }: + { + hostname = host.name; + }; + den.aspects.iceberg.host-addrs = + { host, ... }: + { + hostname = host.name; + }; + # The user's OWN homeManager consumes the host-collected pipe + # (no host-aspects battery). + den.aspects.tux.homeManager = + { host-addrs, lib, ... }: + { + home.sessionVariables.HOSTS = lib.concatStringsSep "," ( + lib.sort (a: b: a < b) (map (e: e.hostname) host-addrs) + ); + }; + expr = tuxHm.home.sessionVariables.HOSTS; + expected = "iceberg,igloo"; + } + ); + + # A COMPLEX forward (carries an adapterModule, so it is not a Tier-1 simple + # route and its source is NOT pre-collected) into homeManager, living in a + # host aspect projected onto the user via host-aspects. Because the source + # is uncollected, the route fold takes the resolveSourceFallback path inside + # the spawned home node — exercising spawn-node.nix's `applyRoutes selfRef` + # with the { from, class, aspect, bindings } resolver contract. The source + # aspect consumes the fleet-collected host-addrs pipe, so the result guards + # BOTH: (a) selfRef must be the spawnNode primitive (an isolated + # fxResolveImports would crash on the new signature), and (b) the fallback's + # `from = scopeParent.` host-scope lookup (a degenerate + # `from = sourceScopeId` self-parent edge yields zero fleet peers / trips + # spawnNode's spawnRoot==from assert). + test-complex-forward-source-fallback-all-peers = denTest ( + { + den, + tuxHm, + lib, + ... + }: + let + inherit (den.lib.policy) pipe resolve instantiate; + in + { + den.quirks.host-addrs.description = "Host address entries"; + den.policies.to-fleet = _: [ + (resolve.to "fleet" { + fleet = { + name = "fleet"; + }; + }) + ]; + den.policies.fleet-to-hosts = + { fleet, ... }: + lib.concatMap ( + system: + lib.concatMap ( + hostName: + let + host = den.hosts.${system}.${hostName}; + in + [ + (resolve.to "host" { inherit host; }) + (instantiate host) + ] + ) (builtins.attrNames (den.hosts.${system} or { })) + ) (builtins.attrNames (den.hosts or { })); + den.policies.collect-addrs = _: [ + (pipe.from "host-addrs" [ (pipe.collectAll ({ host, ... }: true)) ]) + ]; + den.schema.flake.includes = [ den.policies.to-fleet ]; + den.schema.fleet.includes = [ den.policies.fleet-to-hosts ]; + den.schema.host.includes = [ den.policies.collect-addrs ]; + den.schema.flake-system.excludes = [ + den.policies.system-to-os-outputs + den.policies.system-to-hm-outputs + ]; + + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.hosts.x86_64-linux.iceberg.users.alice = { }; + den.aspects.igloo.host-addrs = + { host, ... }: + { + hostname = host.name; + }; + den.aspects.iceberg.host-addrs = + { host, ... }: + { + hostname = host.name; + }; + + # A complex forward (adapterModule -> needsAdapter -> NOT a Tier-1 route, + # source uncollected) from a forward-only class into homeManager. The + # forward source is the user entity (mirrors makeHomeEnv's userForward), + # so spawnNode re-establishes the user scope (spawnRoot != from) and + # the source class key consumes the fleet-collected host-addrs pipe. The + # forward lives in the host aspect tree, projected onto the user via + # host-aspects, so its source is resolved inside the spawned home node. + den.aspects.igloo.includes = [ + (den.batteries.forward { + each = [ true ]; + fromClass = _: "hm-addrs-src"; + intoClass = _: "homeManager"; + intoPath = _: [ + "home" + "sessionVariables" + ]; + adapterModule = _: { }; + fromAspect = + _: + den.lib.resolveEntity "user" { + host = den.hosts.x86_64-linux.igloo; + user = den.hosts.x86_64-linux.igloo.users.tux; + }; + }) + ]; + + # The user's own aspect supplies the forward source class key, consuming + # the fleet-collected pipe. + den.aspects.tux.hm-addrs-src = + { host-addrs, lib, ... }: + { + HOSTS = lib.concatStringsSep "," (lib.sort (a: b: a < b) (map (e: e.hostname) host-addrs)); + }; + + den.aspects.tux.includes = [ den.batteries.host-aspects ]; + expr = tuxHm.home.sessionVariables.HOSTS; + expected = "iceberg,igloo"; + } + ); + + # Equivalency invariant: in-tree resolution ≡ threaded (spawned-node) + # resolution. A single fleet-collected pipe (host-addrs) is consumed BOTH by + # a host-scope nixos consumer (resolved in-tree, on the main walk) AND by a + # host-aspects-projected homeManager consumer (resolved in a spawned home + # node threaded with the parent scope-tree state). Both must derive the + # IDENTICAL fleet-wide value — any divergence between the two resolution + # paths fails the test. This is the decisive guard that spawnNode is + # behaviour-preserving: the threaded node sees exactly what the in-tree + # consumer sees. Both consumers apply the SAME reduction over host-addrs, so + # equality isolates the resolved pipe data as the only variable (don't + # simplify one consumer's reduction — that would silently weaken the guard). + test-equivalency-intree-eq-threaded = denTest ( + { + den, + igloo, + tuxHm, + lib, + ... + }: + let + inherit (den.lib.policy) pipe resolve instantiate; + in + { + den.quirks.host-addrs.description = "Host address entries"; + den.policies.to-fleet = _: [ + (resolve.to "fleet" { + fleet = { + name = "fleet"; + }; + }) + ]; + den.policies.fleet-to-hosts = + { fleet, ... }: + lib.concatMap ( + system: + lib.concatMap ( + hostName: + let + host = den.hosts.${system}.${hostName}; + in + [ + (resolve.to "host" { inherit host; }) + (instantiate host) + ] + ) (builtins.attrNames (den.hosts.${system} or { })) + ) (builtins.attrNames (den.hosts or { })); + den.policies.collect-addrs = _: [ + (pipe.from "host-addrs" [ (pipe.collectAll ({ host, ... }: true)) ]) + ]; + den.schema.flake.includes = [ den.policies.to-fleet ]; + den.schema.fleet.includes = [ den.policies.fleet-to-hosts ]; + den.schema.host.includes = [ den.policies.collect-addrs ]; + den.schema.flake-system.excludes = [ + den.policies.system-to-os-outputs + den.policies.system-to-hm-outputs + ]; + + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.hosts.x86_64-linux.iceberg.users.alice = { }; + den.aspects.igloo.host-addrs = + { host, ... }: + { + hostname = host.name; + }; + den.aspects.iceberg.host-addrs = + { host, ... }: + { + hostname = host.name; + }; + + # In-tree consumer: host-scope nixos, resolved on the main walk. + den.aspects.igloo.nixos = + { host-addrs, lib, ... }: + { + networking.extraHosts = lib.concatStringsSep "," ( + lib.sort (a: b: a < b) (map (e: e.hostname) host-addrs) + ); + }; + # Threaded consumer: homeManager projected onto tux via host-aspects, + # resolved in a spawned home node. Same-shaped derivation of the same + # fleet-collected pipe. + den.aspects.igloo.homeManager = + { host-addrs, lib, ... }: + { + home.sessionVariables.HOSTS = lib.concatStringsSep "," ( + lib.sort (a: b: a < b) (map (e: e.hostname) host-addrs) + ); + }; + den.aspects.tux.includes = [ den.batteries.host-aspects ]; + + expr = { + inTree = igloo.networking.extraHosts; + threaded = tuxHm.home.sessionVariables.HOSTS; + }; + expected = { + inTree = "iceberg,igloo"; + threaded = "iceberg,igloo"; + }; + } + ); + + # Server-host membership (per-host-resolved boundary). Two hosts, each with a + # distinct NON-admin user, each user exposing `resolved-users` upward. The + # host aggregates ONLY the users resolved onto THAT host — collectAllExposed + # walks each parent's own children, so host A's aggregation contains exactly + # host A's users and NEVER host B's. This surfaces the boundary an admin-less + # server relies on: a consumer like initrd-SSH reading `resolved-users` here + # sees only this host's (non-admin) members, not a global registry. The test + # asserts the TRUE current per-host behaviour — it does not change which users + # resolve onto a host (a separate nix-config concern). + test-server-host-membership-per-host = denTest ( + { + den, + igloo, + lib, + ... + }: + let + inherit (den.lib.policy) pipe; + in + { + den.quirks.resolved-users.description = "Users resolved onto a host"; + den.policies.expose-users = { user, ... }: [ (pipe.from "resolved-users" [ pipe.expose ]) ]; + den.schema.user.includes = [ den.policies.expose-users ]; + + # Two separate hosts, each with its own distinct non-admin user. Neither + # user is in wheel/admin — this models an admin-less server. + den.hosts.x86_64-linux.igloo.users.svc-igloo = { }; + den.hosts.x86_64-linux.iceberg.users.svc-iceberg = { }; + + den.aspects.svc-igloo.resolved-users = + { user, ... }: + { + name = user.userName; + }; + den.aspects.svc-iceberg.resolved-users = + { user, ... }: + { + name = user.userName; + }; + + # The host consumer enumerates resolved-users. The assertion makes the + # per-host boundary visible: igloo sees only svc-igloo, NOT svc-iceberg. + den.aspects.igloo.nixos = + { resolved-users, lib, ... }: + { + users.groups.svc.members = lib.sort (a: b: a < b) (map (u: u.name) resolved-users); + }; + + expr = igloo.users.groups.svc.members; + # Exactly this host's resolved users — iceberg's user must NOT appear. + expected = [ "svc-igloo" ]; + } + ); + }; +} From 439c3dc5b8889049d876f833be5c6ea7d7555598 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 2 Jun 2026 17:56:00 -0700 Subject: [PATCH 10/10] fix(policy): scope runtime-include policies to their registering subtree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The late-sibling dispatch re-fired every policy registered at the parent OR any sibling scope at every sibling (entity-kind filter only). So a policy a user registered via its own includes — opting into the host-aspects battery, a per-user `to-users` policy, etc. — fanned to every other user on the host, regardless of opt-in. Make eligibility ancestor-or-self: at each sibling, only policies registered at the parent (the host, whose subtree spans every user) plus the sibling's own fire. A user's runtime includes stay in its own subtree; host-registered provides still fan to all users (the legacy mutual-provider pattern). Fixes host-aspects projecting a host's homeManager onto users who never included the battery (a pre-existing leak, not from the spawnNode work). host-aspects.nix is unchanged — this is a dispatch fix, not a per-battery guard. Tests: host-aspects-sibling-leak (regression guard); user-host-mutual-config re-patterns the user->siblings case to host-level registration and adds test-user-include-stays-in-subtree. (cherry picked from commit 4200f372e72721b2c8fc657a2296a9e21adc5ca6) --- nix/lib/aspects/fx/policy/schema.nix | 28 ++++++---- .../deadbugs/host-aspects-sibling-leak.nix | 32 +++++++++++ .../features/user-host-mutual-config.nix | 53 ++++++++++++++++--- 3 files changed, 96 insertions(+), 17 deletions(-) create mode 100644 templates/ci/modules/features/deadbugs/host-aspects-sibling-leak.nix diff --git a/nix/lib/aspects/fx/policy/schema.nix b/nix/lib/aspects/fx/policy/schema.nix index d35f02bb7..a18ccae19 100644 --- a/nix/lib/aspects/fx/policy/schema.nix +++ b/nix/lib/aspects/fx/policy/schema.nix @@ -114,8 +114,17 @@ let # Emit late policy effects into a single sibling scope. emitLateForSibling = - parentScope: parentFiredPolicies: allAspectPolicies: firedPerScope: sib: + parentScope: parentFiredPolicies: scopedAspectPolicies: firedPerScope: sib: let + # Runtime-include policies are subtree-scoped: a policy registered via a + # scope's own includes applies only to that scope's subtree, never to + # siblings. The policies eligible at `sib` are therefore exactly those + # registered at an ancestor-or-self scope — the parent (host), whose + # subtree contains every user, plus `sib`'s own. A sibling user's includes + # (an opt-in battery's policy, a per-user `to-users` policy, …) never fire + # at OTHER users. + allAspectPolicies = + (scopedAspectPolicies.${parentScope} or { }) // (scopedAspectPolicies.${sib.scopeId} or { }); dispatchKey = "${sib.targetKind}@${sib.scopeId}"; alreadyFired = firedPerScope.${dispatchKey} or { }; # Filter policies to those not already fired AND whose entity-kind @@ -206,15 +215,12 @@ let scopedAspectPolicies = (state.scopedAspectPolicies or (_: { })) null; firedPerScope = (state.firedPolicyNames or (_: { })) null; parentScope = state.currentScope; - # Only collect policies from the parent scope and sibling scopes — - # not the global flat set. Using flatAspectPolicies caused cross-host - # contamination: unguarded policies (to-users, to-hosts) from sibling - # host scopes would fire in unrelated host contexts. - siblingScopes = map (s: s.scopeId) siblingMetas; - relevantScopes = [ parentScope ] ++ siblingScopes; - subtreePolicies = builtins.foldl' ( - acc: sid: acc // (scopedAspectPolicies.${sid} or { }) - ) { } relevantScopes; + # Eligibility is decided per-sibling in emitLateForSibling (ancestor-or- + # self scoping): parent-registered policies fan to all children, but a + # sibling's own runtime-include policies stay in its own subtree. This + # supersedes the older parent+all-siblings union, which leaked a + # sibling's includes across to other siblings (cross-host contamination + # was already excluded; cross-sibling-within-host was not). # Collect all policies that already fired at the parent scope # (under any entity kind). These should not re-fire at children # via late dispatch — they already produced their effects. @@ -225,7 +231,7 @@ let builtins.foldl' ( acc: sib: fx.bind acc ( - _: emitLateForSibling parentScope parentFiredPolicies subtreePolicies firedPerScope sib + _: emitLateForSibling parentScope parentFiredPolicies scopedAspectPolicies firedPerScope sib ) ) (fx.pure null) siblingMetas ) diff --git a/templates/ci/modules/features/deadbugs/host-aspects-sibling-leak.nix b/templates/ci/modules/features/deadbugs/host-aspects-sibling-leak.nix new file mode 100644 index 000000000..503660abd --- /dev/null +++ b/templates/ci/modules/features/deadbugs/host-aspects-sibling-leak.nix @@ -0,0 +1,32 @@ +{ denTest, ... }: +{ + flake.tests.host-aspects-sibling-leak = { + # Host with two users; only `tux` opts into host-aspects. `pingu` must NOT + # receive the host's homeManager projection. + test-sibling-no-leak = denTest ( + { + den, + tuxHm, + pinguHm, + ... + }: + { + den.hosts.x86_64-linux.igloo.users = { + tux = { }; + pingu = { }; + }; + den.aspects.igloo.homeManager.programs.vim.enable = true; + den.aspects.tux.includes = [ den._.host-aspects ]; + # pingu does NOT include host-aspects + expr = { + tux = tuxHm.programs.vim.enable or false; + pingu = pinguHm.programs.vim.enable or false; + }; + expected = { + tux = true; + pingu = false; + }; + } + ); + }; +} diff --git a/templates/ci/modules/features/user-host-mutual-config.nix b/templates/ci/modules/features/user-host-mutual-config.nix index 6b8fd912b..1a43ca677 100644 --- a/templates/ci/modules/features/user-host-mutual-config.nix +++ b/templates/ci/modules/features/user-host-mutual-config.nix @@ -274,7 +274,13 @@ } ); - test-user-provides-to-all-users = denTest ( + # Host-level selective provide-to-users (the legacy mutual-provider pattern). + # Registered at the HOST (`den.aspects.igloo`), whose subtree spans every + # user, so it legitimately fans to all of them — and may select per user. + # (Was `test-user-provides-to-all-users`, registered at `den.aspects.tux`; + # a user reaching siblings is no longer allowed — see + # `test-user-include-stays-in-subtree` below.) + test-host-provides-selectively-to-users = denTest ( { den, lib, @@ -292,20 +298,20 @@ carl = { }; }; - den.aspects.tux.policies.to-users = + den.aspects.igloo.policies.to-users = { host, user, ... }: lib.optional (user.name != "tux") (include { homeManager.programs.vim.enable = true; }); - den.aspects.tux.policies.to-alice = + den.aspects.igloo.policies.to-alice = { host, user, ... }: lib.optional (user.name == "alice") (include { homeManager.programs.tmux.enable = true; }); - den.aspects.tux.includes = [ - den.aspects.tux.policies.to-users - den.aspects.tux.policies.to-alice + den.aspects.igloo.includes = [ + den.aspects.igloo.policies.to-users + den.aspects.igloo.policies.to-alice ]; expr = with igloo.home-manager.users; { @@ -329,5 +335,40 @@ } ); + # A policy registered via a USER's own includes is subtree-scoped: it + # applies to that user, never to sibling users. (To configure all users, + # register at the host — see `test-host-provides-selectively-to-users`.) + test-user-include-stays-in-subtree = denTest ( + { + den, + tuxHm, + pinguHm, + ... + }: + let + inherit (den.lib.policy) include; + in + { + den.hosts.x86_64-linux.igloo.users = { + tux = { }; + pingu = { }; + }; + + den.aspects.tux.policies.to-users = + { host, user, ... }: [ (include { homeManager.programs.vim.enable = true; }) ]; + den.aspects.tux.includes = [ den.aspects.tux.policies.to-users ]; + + expr = [ + tuxHm.programs.vim.enable + pinguHm.programs.vim.enable + ]; + # tux's own include reaches tux only; pingu (a sibling) is untouched. + expected = [ + true + false + ]; + } + ); + }; }