From ea965dc00971fd874a80a597f497cc156ad8fa06 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Thu, 25 Jun 2026 19:40:27 -0700 Subject: [PATCH 1/4] feat: pipe.broadcast cross-scope push + collect reads exposed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `pipe.broadcast pred` — a push routing primitive, the dual of `pipe.expose`. A scope distributes its source-transformed pipe value to every other scope whose context matches the receiver predicate, fleet-wide; receivers read the pipe normally. Receiver-only predicate (same signature as `collectAll`), self-excluded. Implemented as a `collectAllBroadcast` pass (1b) mirroring `collectAllExposed`, reusing `findMatchingAll` entity-kind filtering and `resolveThunks` cross-host config resolution. `bindsPipeLocally` gains a broadcast clause so a pure receiver does not fall through to ancestor inheritance. Also make `collect`/`collectAll` read raw + exposed values at each source scope rather than raw emits alone, so a peer's collect sees data that children `pipe.expose`d up into a host. Fixes the expose-then-fleet-collect path (witness test-expose-then-fleet-collect). Tests: new pipe-broadcast suite (all-to-all, user->remote-host, source-transform, predicate scoping incl. { host, user } compound, self-exclusion) and pipe-broadcast-isolation suite (cross-pipe-name, host-target-excludes-home entity-kind isolation, no-match predicate, and the broadcast<->collect boundary: broadcast-injected values are not re-collected). Full CI 1036/1036. --- nix/lib/aspects/fx/assemble-pipes.nix | 136 ++++++- nix/lib/policy-effects.nix | 4 + .../public-api/pipe-broadcast-isolation.nix | 220 +++++++++++ .../ci/modules/public-api/pipe-broadcast.nix | 357 ++++++++++++++++++ .../ci/modules/public-api/pipe-scope.nix | 118 ++++++ 5 files changed, 830 insertions(+), 5 deletions(-) create mode 100644 templates/ci/modules/public-api/pipe-broadcast-isolation.nix create mode 100644 templates/ci/modules/public-api/pipe-broadcast.nix diff --git a/nix/lib/aspects/fx/assemble-pipes.nix b/nix/lib/aspects/fx/assemble-pipes.nix index 0de6b8dcc..117c038bb 100644 --- a/nix/lib/aspects/fx/assemble-pipes.nix +++ b/nix/lib/aspects/fx/assemble-pipes.nix @@ -292,6 +292,7 @@ let scopeParent, scopeEntityKind ? { }, scopedClassImports, + allExposed ? { }, currentScopeId, pipeName, hostConfigs ? null, @@ -325,8 +326,13 @@ let entries = (scopedClassImports.${sid} or { }).${pipeName} or [ ]; rawValues = flattenAndExtract entries; resolved = resolveThunks hostConfigs scopeContexts sid rawValues; + # Also collect data that sid's children exposed UP into sid (pipe.expose). + # collectAllExposed already resolved these at the exposing node, so they + # cross as concrete data — a peer's collect sees a host's exposed-up + # user data, not just its raw host-scope emits. + exposed = (allExposed.${sid} or { }).${pipeName} or [ ]; in - map (functor.seed sid) resolved + map (functor.seed sid) (resolved ++ exposed) ) matchingScopes; in builtins.foldl' ( @@ -411,6 +417,7 @@ let scopeParent, scopeEntityKind ? { }, scopedClassImports, + allExposed ? { }, currentScopeId, pipeName, hostConfigs ? null, @@ -432,6 +439,7 @@ let scopeParent scopeEntityKind scopedClassImports + allExposed currentScopeId pipeName hostConfigs @@ -448,6 +456,7 @@ let scopeParent, scopeEntityKind ? { }, scopedClassImports, + allExposed ? { }, hostConfigs ? null, }: pipeName: scopeId: baseValues: effects: @@ -470,6 +479,7 @@ let scopeParent scopeEntityKind scopedClassImports + allExposed hostConfigs ; currentScopeId = scopeId; @@ -492,6 +502,7 @@ let scopeParent, scopeEntityKind ? { }, scopedClassImports, + allExposed ? { }, currentScopeId, hostConfigs ? null, }: @@ -507,6 +518,7 @@ let scopeContexts scopeParent scopedClassImports + allExposed currentScopeId hostConfigs ; @@ -533,6 +545,17 @@ let # Check whether a pipe effect has a pipe.expose routing stage. hasExposeStage = e: builtins.any (s: (s.__pipeStage or "") == "expose") (e.stages or [ ]); + # Check whether a pipe effect has a pipe.broadcast routing stage. + hasBroadcastStage = e: builtins.any (s: (s.__pipeStage or "") == "broadcast") (e.stages or [ ]); + + # Extract the receiver predicate from a pipe.broadcast stage. + getBroadcastPred = + e: + let + bStage = lib.findFirst (s: (s.__pipeStage or "") == "broadcast") null (e.stages or [ ]); + in + if bStage == null then null else bStage.fn; + # Collect exposed data bottom-up from child scopes. # Returns: { parentScopeId → { pipeName → [values] } } collectAllExposed = @@ -631,6 +654,86 @@ let in builtins.foldl' processTree { } rootScopes; + # Distribute broadcast data laterally: each broadcaster S pushes its + # (source-transformed) pipe value to every OTHER scope whose context matches + # the broadcast predicate. The push dual of pipe.expose (which routes to the + # parent); mechanically a fan-out gather, so it reuses findMatchingAll's + # entity-kind filtering and resolveThunks' cross-host config resolution. + # Source values are the broadcaster's RAW emits (user scopes are leaves with + # no children to expose) — not the post-expose assembled value. + # Returns: { receiverScopeId → { pipeName → [values] } } + collectAllBroadcast = + { + scopeContexts, + scopedClassImports, + scopedPipeEffects, + scopeEntityKind ? { }, + hostConfigs ? null, + }: + let + allScopeIds = builtins.attrNames scopeContexts; + perBroadcaster = + sourceId: + let + scopeEffects = scopedPipeEffects.${sourceId} or [ ]; + rawBroadcast = builtins.filter hasBroadcastStage scopeEffects; + # Dedup by (pipeName, policyName) — a policy may fire for multiple + # entity kinds in the same scope, producing duplicate effects. + broadcastEffects = + let + go = + seen: effs: + if effs == [ ] then + [ ] + else + let + e = builtins.head effs; + rest = builtins.tail effs; + key = "${e.pipeName}/${e.__pipePolicyName or ""}"; + in + if seen ? ${key} then go seen rest else [ e ] ++ go (seen // { ${key} = true; }) rest; + in + go { } rawBroadcast; + scopeImports = scopedClassImports.${sourceId} or { }; + in + lib.concatMap ( + effect: + let + inherit (effect) pipeName; + rawEntries = scopeImports.${pipeName} or [ ]; + baseValues = flattenAndExtract rawEntries; + # Resolve the source value to data as it crosses to the receiver: + # pipeline-parametric eagerly, config-dependent against the SOURCE + # host's config (hostConfigs) — NOT deferred, since the receiver may + # be on another host. Then apply the source-side transform stages + # (the broadcast routing stage is ignored by applyTransformStages). + resolvedBase = resolveThunks hostConfigs scopeContexts sourceId baseValues; + transformed = applyTransformStages resolvedBase (effect.stages or [ ]); + receivers = findMatchingAll { + inherit scopeContexts scopeEntityKind; + currentScopeId = sourceId; + } (getBroadcastPred effect); + in + map (receiverId: { + inherit receiverId pipeName; + values = transformed; + }) receivers + ) broadcastEffects; + allEntries = builtins.concatMap perBroadcaster allScopeIds; + in + builtins.foldl' ( + acc: entry: + let + existing = acc.${entry.receiverId} or { }; + in + acc + // { + ${entry.receiverId} = existing // { + ${entry.pipeName} = (existing.${entry.pipeName} or [ ]) ++ entry.values; + }; + } + ) { } allEntries; + assemblePipes = { scopeContexts, @@ -654,14 +757,28 @@ let ; }; + # Pass 1b: Distribute broadcast data laterally (push, fleet-wide). + allBroadcast = collectAllBroadcast { + inherit + scopeContexts + scopedClassImports + scopedPipeEffects + scopeEntityKind + hostConfigs + ; + }; + # 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. + # pipe.expose, receives a pipe.broadcast targeting it, 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 — see pipeData + # below. The broadcast clause keeps a pure-receiver scope (no local emit + # or effect) from falling through to ancestor inheritance. bindsPipeLocally = sid: pn: ((scopedClassImports.${sid} or { }).${pn} or [ ]) != [ ] || ((allExposed.${sid} or { }).${pn} or [ ]) != [ ] + || ((allBroadcast.${sid} or { }).${pn} or [ ]) != [ ] || builtins.any (e: e.pipeName == pn) (scopedPipeEffects.${sid} or [ ]); # Nearest ancestor (walking scopeParent) whose pipe policy bound `pn`. @@ -741,6 +858,7 @@ let scopeParent scopeEntityKind scopedClassImports + allExposed hostConfigs ; currentScopeId = scopeId; @@ -761,11 +879,17 @@ let scopeParent scopeEntityKind scopedClassImports + allExposed hostConfigs ; } pipeName scopeId combinedBase untargetedEffects; + + # Values pushed to this scope by peers' pipe.broadcast (S≠R). + # The source already applied its transform stages, so these are + # concrete data — appended alongside the scope's own base. + broadcastReceived = (allBroadcast.${scopeId} or { }).${pipeName} or [ ]; in - normalResult ++ asResults + normalResult ++ asResults ++ broadcastReceived ); # Pure-consumer scopes inherit a pipe's assembled value from the @@ -816,6 +940,7 @@ let scopeParent scopeEntityKind scopedClassImports + allExposed hostConfigs ; currentScopeId = scopeId; @@ -832,6 +957,7 @@ let scopeParent scopeEntityKind scopedClassImports + allExposed hostConfigs ; currentScopeId = scopeId; diff --git a/nix/lib/policy-effects.nix b/nix/lib/policy-effects.nix index 952527be5..51d6d2e1d 100644 --- a/nix/lib/policy-effects.nix +++ b/nix/lib/policy-effects.nix @@ -335,6 +335,10 @@ in expose = { __pipeStage = "expose"; }; + broadcast = pred: { + __pipeStage = "broadcast"; + fn = pred; + }; collect = pred: { __pipeStage = "collect"; fn = pred; diff --git a/templates/ci/modules/public-api/pipe-broadcast-isolation.nix b/templates/ci/modules/public-api/pipe-broadcast-isolation.nix new file mode 100644 index 000000000..9de74cb6d --- /dev/null +++ b/templates/ci/modules/public-api/pipe-broadcast-isolation.nix @@ -0,0 +1,220 @@ +# Defensive isolation coverage for pipe.broadcast — proving a broadcast does +# NOT leak across pipe names, entity kinds, predicate misses, or into collect. +{ denTest, lib, ... }: +{ + flake.tests.pipe-broadcast-isolation = { + + # Pipe-name isolation: a broadcast on `alpha` must not bleed into `beta`. + # alice consumes BOTH pipes; only alpha carries tux's broadcast. + test-broadcast-pipe-name-isolation = denTest ( + { + den, + iceberg, + lib, + ... + }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.hosts.x86_64-linux.iceberg.users.alice = { }; + + den.quirks.alpha.description = "pipe A"; + den.quirks.beta.description = "pipe B"; + + den.aspects.tux.alpha = [ { who = "tux-alpha"; } ]; + den.aspects.alice.homeManager = + { + alpha, + beta, + ... + }: + { + home.sessionVariables.ALPHA = lib.concatStringsSep "," (map (p: p.who) alpha); + home.sessionVariables.BETA = lib.concatStringsSep "," (map (p: p.who) beta); + }; + + # Broadcast ONLY alpha to all users. + den.policies.broadcast-alpha = + { host, user, ... }: + let + inherit (den.lib.policy) pipe; + in + [ (pipe.from "alpha" [ (pipe.broadcast ({ user, ... }: true)) ]) ]; + den.schema.user.includes = [ den.policies.broadcast-alpha ]; + + expr = { + alpha = iceberg.home-manager.users.alice.home.sessionVariables.ALPHA; + beta = iceberg.home-manager.users.alice.home.sessionVariables.BETA; + }; + expected = { + # alice receives tux's alpha broadcast. + alpha = "tux-alpha"; + # beta is untouched — no cross-pipe leak. + beta = ""; + }; + } + ); + + # Entity-kind isolation through shared context: a broadcast to HOST scopes + # ({ host, ... }: true) must NOT leak to a home/user scope, even though user + # scopes carry `host` in their context. The receiver's OWN entity kind + # (user) is an extra kind not named by the predicate, so it is rejected. + test-broadcast-host-target-excludes-home = denTest ( + { + den, + iceberg, + lib, + ... + }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.hosts.x86_64-linux.iceberg.users.alice = { }; + + den.quirks.peer-dev.description = "per-user device records"; + + # tux emits + broadcasts to HOST scopes. alice emits nothing. + den.aspects.tux.peer-dev = [ { who = "tux"; } ]; + den.aspects.alice.homeManager = + { peer-dev, ... }: + { + home.sessionVariables.PEERS = lib.concatStringsSep "," (map (p: p.who) peer-dev); + }; + + # A pure-consumer HOST aspect on iceberg. + den.aspects.iceberg.includes = [ den.aspects.host-consumer ]; + den.aspects.host-consumer.nixos = + { peer-dev, ... }: + { + networking.domain = lib.concatStringsSep "," (map (p: p.who) peer-dev); + }; + + den.policies.broadcast-to-hosts = + { host, user, ... }: + let + inherit (den.lib.policy) pipe; + in + [ (pipe.from "peer-dev" [ (pipe.broadcast ({ host, ... }: true)) ]) ]; + den.schema.user.includes = [ den.policies.broadcast-to-hosts ]; + + expr = { + # iceberg HOST scope is a valid receiver of the host-targeted broadcast. + icebergHost = iceberg.networking.domain; + # alice's HOME (a user scope) is NOT — host-targeted broadcast must not + # reach it. alice binds locally (own broadcast effect) with empty base, + # so this is a direct-reception check, not ancestor inheritance. + aliceHome = iceberg.home-manager.users.alice.home.sessionVariables.PEERS; + }; + expected = { + icebergHost = "tux"; + aliceHome = ""; + }; + } + ); + + # No-match predicate: a broadcast whose predicate matches no scope makes no + # distribution and does not error. Every user sees only its own base. + test-broadcast-no-match-predicate = denTest ( + { + den, + tuxHm, + pinguHm, + lib, + ... + }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.hosts.x86_64-linux.igloo.users.pingu = { }; + + den.quirks.peer-dev.description = "per-user device records"; + + den.aspects.tux = { + peer-dev = [ { who = "tux"; } ]; + homeManager = + { peer-dev, ... }: + { + home.sessionVariables.PEERS = lib.concatStringsSep "," (map (p: p.who) peer-dev); + }; + }; + den.aspects.pingu = { + peer-dev = [ { who = "pingu"; } ]; + homeManager = + { peer-dev, ... }: + { + home.sessionVariables.PEERS = lib.concatStringsSep "," (map (p: p.who) peer-dev); + }; + }; + + # Predicate matches a non-existent user — nobody receives. + den.policies.broadcast-ghost = + { host, user, ... }: + let + inherit (den.lib.policy) pipe; + in + [ (pipe.from "peer-dev" [ (pipe.broadcast ({ user, ... }: user.name == "ghost")) ]) ]; + den.schema.user.includes = [ den.policies.broadcast-ghost ]; + + expr = { + tux = tuxHm.home.sessionVariables.PEERS; + pingu = pinguHm.home.sessionVariables.PEERS; + }; + expected = { + tux = "tux"; + pingu = "pingu"; + }; + } + ); + + # Broadcast ↔ collect boundary: values pushed INTO a scope by a peer's + # broadcast must NOT be re-collected by a collectAll on the same pipe. + # collect reads raw (+ exposed) emits, never broadcast-injected data — so a + # fleet collectAll counts each user's raw emit ONCE, not the broadcast- + # amplified per-user view. + test-broadcast-not-recollected = denTest ( + { + den, + igloo, + lib, + ... + }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.hosts.x86_64-linux.iceberg.users.alice = { }; + + den.quirks.peer-dev.description = "per-user device records"; + + # Both users emit AND broadcast to all users (so each user's assembled + # view is amplified to 2 entries). + den.aspects.tux.peer-dev = [ { who = "tux"; } ]; + den.aspects.alice.peer-dev = [ { who = "alice"; } ]; + + den.policies.broadcast-peer-dev = + { host, user, ... }: + let + inherit (den.lib.policy) pipe; + in + [ (pipe.from "peer-dev" [ (pipe.broadcast ({ user, ... }: true)) ]) ]; + den.schema.user.includes = [ den.policies.broadcast-peer-dev ]; + + # A host-scope collectAll over USER scopes. Reads RAW emits only: tux + alice = 2. + # If broadcast leaked into collect, each user scope would report 2 and the + # total would be 4. + den.policies.collect-peer-dev = + { host, ... }: + let + inherit (den.lib.policy) pipe; + in + [ (pipe.from "peer-dev" [ (pipe.collectAll ({ user, ... }: true)) ]) ]; + den.schema.host.includes = [ den.policies.collect-peer-dev ]; + + den.aspects.igloo.includes = [ den.aspects.counter ]; + den.aspects.counter.nixos = + { peer-dev, ... }: + { + networking.domain = toString (builtins.length peer-dev); + }; + + expr = igloo.networking.domain; + expected = "2"; + } + ); + }; +} diff --git a/templates/ci/modules/public-api/pipe-broadcast.nix b/templates/ci/modules/public-api/pipe-broadcast.nix new file mode 100644 index 000000000..a4f94db70 --- /dev/null +++ b/templates/ci/modules/public-api/pipe-broadcast.nix @@ -0,0 +1,357 @@ +# Tests for pipe.broadcast — push primitive, dual of pipe.expose. +# A scope broadcasts a pipe's (post-transform) value to every OTHER scope +# matching a receiver predicate, fleet-wide. Receivers read the pipe normally. +{ denTest, lib, ... }: +{ + flake.tests.pipe-broadcast = { + + # Basic all-to-all: each user broadcasts peer-dev to every user scope + # fleet-wide. tux's home sees its own base (tux) + alice's broadcast. + test-broadcast-basic = denTest ( + { + den, + tuxHm, + lib, + ... + }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.hosts.x86_64-linux.iceberg.users.alice = { }; + + den.quirks.peer-dev.description = "per-user device records"; + + den.aspects.tux = { + peer-dev = [ { who = "tux@igloo"; } ]; + homeManager = + { peer-dev, ... }: + { + home.sessionVariables.PEERS = lib.concatStringsSep "," ( + lib.sort (a: b: a < b) (map (p: p.who) peer-dev) + ); + }; + }; + den.aspects.alice = { + peer-dev = [ { who = "alice@iceberg"; } ]; + }; + + # USER scope: broadcast peer-dev to all user scopes fleet-wide. + den.policies.broadcast-peer-dev = + { host, user, ... }: + let + inherit (den.lib.policy) pipe; + in + [ (pipe.from "peer-dev" [ (pipe.broadcast ({ user, ... }: true)) ]) ]; + den.schema.user.includes = [ den.policies.broadcast-peer-dev ]; + + # tux's home sees BOTH its own and alice's broadcast peer-dev. + expr = tuxHm.home.sessionVariables.PEERS; + expected = "alice@iceberg,tux@igloo"; + } + ); + + # User → REMOTE host. alice (a user on iceberg) broadcasts her device + # record to every HOST scope ({ host, ... }: true). igloo — a host on the + # OTHER side of the fleet — consumes it at host scope. Crosses both the + # entity-kind boundary (user → host) and the host boundary. + test-broadcast-to-remote-host = denTest ( + { + den, + igloo, + lib, + ... + }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.hosts.x86_64-linux.iceberg.users.alice = { }; + + den.quirks.peer-dev.description = "per-user device records"; + + den.aspects.alice = { + peer-dev = [ { who = "alice@iceberg"; } ]; + }; + + # USER scope: broadcast to all HOST scopes fleet-wide. + den.policies.broadcast-to-hosts = + { host, user, ... }: + let + inherit (den.lib.policy) pipe; + in + [ (pipe.from "peer-dev" [ (pipe.broadcast ({ host, ... }: true)) ]) ]; + den.schema.user.includes = [ den.policies.broadcast-to-hosts ]; + + # igloo (remote relative to alice) consumes the broadcast at host scope. + den.aspects.igloo = { + includes = [ den.aspects.peer-consumer ]; + }; + den.aspects.peer-consumer = { + nixos = + { peer-dev, lib, ... }: + { + networking.domain = lib.concatStringsSep "," (lib.sort (a: b: a < b) (map (p: p.who) peer-dev)); + }; + }; + + expr = igloo.networking.domain; + expected = "alice@iceberg"; + } + ); + + # Source-side transform stages apply BEFORE distribution: the broadcast + # value is the transformed view, identical at every receiver. + test-broadcast-source-transform = denTest ( + { + den, + tuxHm, + lib, + ... + }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.hosts.x86_64-linux.iceberg.users.alice = { }; + + den.quirks.peer-dev.description = "per-user device records"; + + den.aspects.tux = { + peer-dev = [ { who = "tux"; } ]; + homeManager = + { peer-dev, ... }: + { + home.sessionVariables.PEERS = lib.concatStringsSep "," ( + lib.sort (a: b: a < b) (map (p: p.who) peer-dev) + ); + }; + }; + den.aspects.alice = { + peer-dev = [ { who = "alice"; } ]; + }; + + # Transform (uppercase-style tag) runs source-side, then broadcast. + den.policies.broadcast-peer-dev = + { host, user, ... }: + let + inherit (den.lib.policy) pipe; + in + [ + (pipe.from "peer-dev" [ + (pipe.transform (p: { + who = "dev:${p.who}"; + })) + (pipe.broadcast ({ user, ... }: true)) + ]) + ]; + den.schema.user.includes = [ den.policies.broadcast-peer-dev ]; + + # tux's own value is transformed too (own untargeted path) + alice's + # transformed broadcast → uniform "dev:" view everywhere. + expr = tuxHm.home.sessionVariables.PEERS; + expected = "dev:alice,dev:tux"; + } + ); + + # Predicate scoping (negative): a broadcast targeting USER scopes is NOT + # visible to a HOST consumer — the receiver predicate gates by entity kind. + test-broadcast-predicate-excludes-host = denTest ( + { + den, + igloo, + lib, + ... + }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.hosts.x86_64-linux.iceberg.users.alice = { }; + + den.quirks.peer-dev.description = "per-user device records"; + + den.aspects.tux = { + peer-dev = [ { who = "tux@igloo"; } ]; + }; + den.aspects.alice = { + peer-dev = [ { who = "alice@iceberg"; } ]; + }; + + # Broadcast to USER scopes only. + den.policies.broadcast-peer-dev = + { host, user, ... }: + let + inherit (den.lib.policy) pipe; + in + [ (pipe.from "peer-dev" [ (pipe.broadcast ({ user, ... }: true)) ]) ]; + den.schema.user.includes = [ den.policies.broadcast-peer-dev ]; + + # HOST consumer reads peer-dev — should be empty (host is not a user). + den.aspects.igloo = { + includes = [ den.aspects.host-consumer ]; + }; + den.aspects.host-consumer = { + nixos = + { peer-dev, lib, ... }: + { + networking.domain = lib.concatStringsSep "," (map (p: p.who) peer-dev); + }; + }; + + expr = igloo.networking.domain; + expected = ""; + } + ); + + # Self-exclusion (S≠R): a lone broadcaster sees only its own base, NOT a + # duplicate of its own broadcast value. + test-broadcast-self-excluded = denTest ( + { + den, + tuxHm, + lib, + ... + }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.quirks.peer-dev.description = "per-user device records"; + + den.aspects.tux = { + peer-dev = [ { who = "tux@igloo"; } ]; + homeManager = + { peer-dev, ... }: + { + home.sessionVariables.PEERS = lib.concatStringsSep "," (map (p: p.who) peer-dev); + }; + }; + + den.policies.broadcast-peer-dev = + { host, user, ... }: + let + inherit (den.lib.policy) pipe; + in + [ (pipe.from "peer-dev" [ (pipe.broadcast ({ user, ... }: true)) ]) ]; + den.schema.user.includes = [ den.policies.broadcast-peer-dev ]; + + # Only tux's own base — no self-broadcast duplicate. + expr = tuxHm.home.sessionVariables.PEERS; + expected = "tux@igloo"; + } + ); + + # No leak: a narrow predicate reaches ONLY matching scopes. Every user + # broadcasts to tux alone ({ user }: user.name == "tux"). tux receives + # pingu's record; pingu receives NOTHING (tux's broadcast must not leak to + # a non-matching peer). Both homes inspected. + test-broadcast-targeted-no-leak = denTest ( + { + den, + tuxHm, + pinguHm, + lib, + ... + }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.hosts.x86_64-linux.igloo.users.pingu = { }; + + den.quirks.peer-dev.description = "per-user device records"; + + den.aspects.tux = { + peer-dev = [ { who = "tux"; } ]; + homeManager = + { peer-dev, ... }: + { + home.sessionVariables.PEERS = lib.concatStringsSep "," ( + lib.sort (a: b: a < b) (map (p: p.who) peer-dev) + ); + }; + }; + den.aspects.pingu = { + peer-dev = [ { who = "pingu"; } ]; + homeManager = + { peer-dev, ... }: + { + home.sessionVariables.PEERS = lib.concatStringsSep "," ( + lib.sort (a: b: a < b) (map (p: p.who) peer-dev) + ); + }; + }; + + den.policies.broadcast-to-tux = + { host, user, ... }: + let + inherit (den.lib.policy) pipe; + in + [ (pipe.from "peer-dev" [ (pipe.broadcast ({ user, ... }: user.name == "tux")) ]) ]; + den.schema.user.includes = [ den.policies.broadcast-to-tux ]; + + expr = { + tux = tuxHm.home.sessionVariables.PEERS; + pingu = pinguHm.home.sessionVariables.PEERS; + }; + expected = { + # tux receives pingu's broadcast + own base. + tux = "pingu,tux"; + # pingu is not a target — sees only its own base. No leak. + pingu = "pingu"; + }; + } + ); + + # Compound { host, user } targeting: a predicate requiring BOTH host and + # user selects USER scopes (host scopes lack `user`) and can filter on the + # receiver's host. alice@iceberg broadcasts to user scopes on igloo only. + # tux@igloo receives; alice@iceberg (wrong host) does not. + test-broadcast-target-host-user = denTest ( + { + den, + iceberg, + tuxHm, + lib, + ... + }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.hosts.x86_64-linux.iceberg.users.alice = { }; + + den.quirks.peer-dev.description = "per-user device records"; + + den.aspects.tux = { + peer-dev = [ { who = "tux@igloo"; } ]; + homeManager = + { peer-dev, ... }: + { + home.sessionVariables.PEERS = lib.concatStringsSep "," ( + lib.sort (a: b: a < b) (map (p: p.who) peer-dev) + ); + }; + }; + den.aspects.alice = { + peer-dev = [ { who = "alice@iceberg"; } ]; + homeManager = + { peer-dev, ... }: + { + home.sessionVariables.PEERS = lib.concatStringsSep "," ( + lib.sort (a: b: a < b) (map (p: p.who) peer-dev) + ); + }; + }; + + # Target user scopes whose host is igloo (requires host AND user in ctx). + den.policies.broadcast-to-igloo-users = + { host, user, ... }: + let + inherit (den.lib.policy) pipe; + in + [ (pipe.from "peer-dev" [ (pipe.broadcast ({ host, user, ... }: host.name == "igloo")) ]) ]; + den.schema.user.includes = [ den.policies.broadcast-to-igloo-users ]; + + expr = { + tux = tuxHm.home.sessionVariables.PEERS; + alice = iceberg.home-manager.users.alice.home.sessionVariables.PEERS; + }; + expected = { + # tux (user on igloo) receives alice's broadcast + own base. + tux = "alice@iceberg,tux@igloo"; + # alice (user on iceberg) is not targeted — own base only. + alice = "alice@iceberg"; + }; + } + ); + }; +} diff --git a/templates/ci/modules/public-api/pipe-scope.nix b/templates/ci/modules/public-api/pipe-scope.nix index c93453683..e2043f76b 100644 --- a/templates/ci/modules/public-api/pipe-scope.nix +++ b/templates/ci/modules/public-api/pipe-scope.nix @@ -990,5 +990,123 @@ expected = "2"; } ); + + # CLAIM UNDER TEST (syncthing replicateHome §3): a USER-scope emit, + # pipe.expose'd up to its host, then visible to a FLEET collectAll on a PEER + # host — expose (user→host) THEN host rebroadcast THEN fleet collect. + # TRUE → igloo's host consumer sees its OWN exposed user (tux@igloo) AND + # iceberg's exposed user (alice@iceberg). + # FALSE (adversarial-review claim) → igloo sees only tux@igloo. + test-expose-then-fleet-collect = denTest ( + { + den, + igloo, + lib, + ... + }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.hosts.x86_64-linux.iceberg.users.alice = { }; + + den.quirks.peer-dev = { + description = "per-user device records"; + }; + + # ONLY user aspects emit — isolates the user→host→fleet path (no host emit). + den.aspects.tux = { + peer-dev = [ { who = "tux@igloo"; } ]; + }; + den.aspects.alice = { + peer-dev = [ { who = "alice@iceberg"; } ]; + }; + + # user scope: expose each user's emit up to its host. + den.policies.expose-peer-dev = + { host, user, ... }: + let + inherit (den.lib.policy) pipe; + in + [ (pipe.from "peer-dev" [ pipe.expose ]) ]; + den.default.includes = [ den.policies.expose-peer-dev ]; + + # host scope: fleet-collect peer-dev across all hosts. + den.policies.collect-peer-dev = + { host, ... }: + let + inherit (den.lib.policy) pipe; + in + [ (pipe.from "peer-dev" [ (pipe.collectAll ({ host, ... }: true)) ]) ]; + den.schema.host.includes = [ den.policies.collect-peer-dev ]; + + den.aspects.igloo = { + includes = [ den.aspects.peer-consumer ]; + }; + den.aspects.peer-consumer = { + nixos = + { peer-dev, lib, ... }: + { + networking.domain = lib.concatStringsSep "," (lib.sort (a: b: a < b) (map (p: p.who) peer-dev)); + }; + }; + + expr = igloo.networking.domain; + # rebroadcast WORKS → both; FAILS → "tux@igloo" only. + expected = "alice@iceberg,tux@igloo"; + } + ); + + # ALTERNATIVE shape: a HOST-scope emit that maps over host.users, one record + # per user (entity context only — no expose). If host emits are fleet-collectable + # (they are: see test-pipe-collect / test-pipe-collect-fleet), each member sees + # every host's every user. + test-host-peruser-emit-fleet-collect = denTest ( + { + den, + igloo, + lib, + ... + }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.hosts.x86_64-linux.iceberg.users.alice = { }; + + den.quirks.peer-dev = { + description = "per-user device records, emitted at host scope"; + }; + + # HOST-scope emit: iterate the host's own users, emit one record each. + den.aspects.emit-peers = { + peer-dev = + { host, ... }: + lib.mapAttrsToList (uname: _u: { who = "${uname}@${host.name}"; }) (host.users or { }); + }; + + den.policies.collect-peer-dev = + { host, ... }: + let + inherit (den.lib.policy) pipe; + in + [ (pipe.from "peer-dev" [ (pipe.collectAll ({ host, ... }: true)) ]) ]; + + den.schema.host.includes = [ + den.aspects.emit-peers + den.policies.collect-peer-dev + ]; + + den.aspects.igloo = { + includes = [ den.aspects.peer-consumer ]; + }; + den.aspects.peer-consumer = { + nixos = + { peer-dev, lib, ... }: + { + networking.domain = lib.concatStringsSep "," (lib.sort (a: b: a < b) (map (p: p.who) peer-dev)); + }; + }; + + expr = igloo.networking.domain; + expected = "alice@iceberg,tux@igloo"; + } + ); }; } From 49e94e79d15c4a5e4f5e5907bb18ee6aed28162f Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Thu, 25 Jun 2026 20:35:53 -0700 Subject: [PATCH 2/4] feat: producer-class config-thunk resolution + broadcast review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve pipe config-thunks against the PRODUCING class module + scope, not the consuming one. The prior behavior was a latent bug: a host- produced thunk consumed in a home resolved against the home config. - Cross-scope (broadcast/collect): resolveEntry resolves a source's config-thunk against the producer's class config — host -> nixos, user/home -> home-manager (hostConfigs..home-manager.users.). - Deferred (__configThunk): the marker carries the producer's class + name; the class-module wrapper resolves it against the producing class's config, handing each thunk both `config` (producer class) and `osConfig` (the enclosing host), mirroring home-manager. osConfig is requested only when a marker needs the host config, so standalone homes keep working. - isConfigDependent now also detects osConfig-only thunks. Review fixes: pin the bindsPipeLocally broadcast clause with a pure- receiver test; extract a shared dedupEffectsByPolicy helper; document the host-broadcaster raw-only boundary. Tests: new pipe-config-scope suite (host->home, user->own-home, user->host) plus broadcast config-thunk cases; the exposed-config-thunk witness now reads osConfig. Full CI 1041/1041. --- nix/lib/aspects/fx/assemble-pipes.nix | 213 ++++++++++++------ nix/lib/aspects/fx/class-module.nix | 75 +++++- nix/lib/aspects/fx/edges/provides.nix | 1 + nix/lib/aspects/fx/wrap-classes.nix | 2 +- .../modules/internal-api/home-extraction.nix | 6 +- .../ci/modules/public-api/pipe-broadcast.nix | 143 ++++++++++++ .../modules/public-api/pipe-config-scope.nix | 112 +++++++++ 7 files changed, 474 insertions(+), 78 deletions(-) create mode 100644 templates/ci/modules/public-api/pipe-config-scope.nix diff --git a/nix/lib/aspects/fx/assemble-pipes.nix b/nix/lib/aspects/fx/assemble-pipes.nix index 117c038bb..77da53f52 100644 --- a/nix/lib/aspects/fx/assemble-pipes.nix +++ b/nix/lib/aspects/fx/assemble-pipes.nix @@ -26,14 +26,30 @@ let if builtins.isList val then val else [ val ] ) entries; - # Detect config-dependent thunks: functions that take `config` as an argument. - # Config-dependent thunks require `config` in their args and are resolved - # lazily against instantiated host configs. - isConfigDependent = val: builtins.isFunction val && (builtins.functionArgs val) ? config; + # Detect config-dependent thunks: functions taking `config` (the producer's + # class config) and/or `osConfig` (the enclosing host config). Both bind to + # the evalModules fixpoint, so such thunks are deferred/resolved against it. + isConfigDependent = + val: + builtins.isFunction val + && ( + let + a = builtins.functionArgs val; + in + a ? config || a ? osConfig + ); # Pipeline-parametric values require pipeline context args (host, user, etc.) - # but not config. These are resolved eagerly using scope context. - isPipelineParametric = val: builtins.isFunction val && !(builtins.functionArgs val) ? config; + # but neither config nor osConfig. These are resolved eagerly using scope context. + isPipelineParametric = + val: + builtins.isFunction val + && ( + let + a = builtins.functionArgs val; + in + !(a ? config) && !(a ? osConfig) + ); # Resolve a local pipeline-parametric value eagerly using scope context. # These are quirk values like `{ host, ... }: { addr = host.addr; }` that @@ -66,27 +82,92 @@ let [ val ]; # Mark a config-dependent value for deferred resolution inside evalModules. - # The marker is transparent to the module wrapper, which resolves it - # using the evalModules fixpoint config. + # `producer` tags the marker with the PRODUCING scope's class (entity kind) + # and name so the module wrapper resolves it against the producing class + + # scope's config — not the consuming module's. Already-marked values (re-mark + # on an exposed/inherited path) pass through unchanged, keeping their original + # producer tag. markConfigThunk = - v: + producer: v: if isConfigDependent v then { __configThunk = true; __fn = v; + __producerKind = producer.kind or null; + __producerName = producer.name or null; } else v; - # Mark all config-dependent entries in a value list. - markConfigThunks = map markConfigThunk; + # Mark all config-dependent entries in a value list with their producer. + markConfigThunks = producer: map (markConfigThunk producer); + + # Producer tag (class/kind + name) for a scope, read from pipeline state. + producerOf = + scopeEntityKind: scopeContexts: sid: + let + ctx = scopeContexts.${sid} or { }; + in + { + kind = scopeEntityKind.${sid} or null; + name = ctx.user.name or ctx.home.name or null; + }; - # Resolve a config-dependent thunk against instantiated host configs. - # Used for COLLECTED entries (cross-host) where the source host's config - # is needed. Provides scope context args (host, user, etc.) alongside config. - # Returns a list (auto-flattens list-valued results). + # The PRODUCER's `config` (class config) and `osConfig` (enclosing host + # config) for a scope, used to resolve cross-scope config-dependent emits at + # their SOURCE (not the consumer). A host scope's producing class is nixos — + # its own host config (a hostConfigs key), where config == osConfig. A user/ + # home scope's is home-manager — its config nested under the enclosing host at + # `home-manager.users.`, with osConfig the enclosing host config. This + # keeps a user emit reading `config.home.*` (or `osConfig.networking.*`) + # correct and matches the "producing class + scope" rule. Cross-host can't + # reach a remote real fixpoint, so it leans on the precomputed hostConfigs. + producerConfigs = + { + hostConfigs, + scopeContexts, + scopeParent ? { }, + }: + scopeId: + if hostConfigs == null then + { + config = { }; + osConfig = { }; + } + else if hostConfigs ? ${scopeId} then + { + config = hostConfigs.${scopeId}; + osConfig = hostConfigs.${scopeId}; + } + else + let + # Nearest enclosing scope that owns a host config. + findHost = + sid: + if sid == null then + null + else if hostConfigs ? ${sid} then + sid + else + findHost (scopeParent.${sid} or null); + hostScope = findHost (scopeParent.${scopeId} or null); + hostCfg = if hostScope == null then { } else hostConfigs.${hostScope}; + ctx = scopeContexts.${scopeId} or { }; + name = ctx.user.name or ctx.home.name or null; + hmUsers = hostCfg.home-manager.users or { }; + in + { + config = if name != null && hmUsers ? ${name} then hmUsers.${name} else { }; + osConfig = hostCfg; + }; + + # Resolve a config-dependent thunk against the producer's class config. + # Used for COLLECTED / BROADCAST entries (cross-scope) where the SOURCE + # scope's config is needed. Provides scope context args (host, user, etc.) + # alongside `config` (producer class) and `osConfig` (enclosing host). Returns + # a list (auto-flattens list-valued results). resolveEntry = - hostConfigs: scopeContexts: sourceScopeId: entry: + hostConfigs: producerConfigFor: scopeContexts: sourceScopeId: entry: if isConfigDependent entry then if hostConfigs == null then # No host configs on this crossing path: defer the config-dependent emit. @@ -100,10 +181,11 @@ let ctxArgs = lib.genAttrs (builtins.filter (k: scopeCtx ? ${k}) (builtins.attrNames thunkArgs)) ( k: scopeCtx.${k} ); + pc = producerConfigFor sourceScopeId; result = entry ( ctxArgs // { - config = hostConfigs.${sourceScopeId} or { }; + inherit (pc) config osConfig; inherit lib; } ); @@ -126,8 +208,8 @@ let # 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: - builtins.concatMap (resolveEntry hostConfigs scopeContexts scopeId) values; + hostConfigs: producerConfigFor: scopeContexts: scopeId: values: + builtins.concatMap (resolveEntry hostConfigs producerConfigFor scopeContexts scopeId) values; # Value functor: lets ONE stage interpreter run over either bare values (the # plain path) or provenance-tagged values ({ __pv = value; __ps = scopeId; }). @@ -316,6 +398,7 @@ let ) stages; # Tag initial values at the current scope (identity for the plain path). taggedInitial = map (functor.seed currentScopeId) initialValues; + producerConfigFor = producerConfigs { inherit hostConfigs scopeContexts scopeParent; }; # Resolve a list of matching scopes into collected values, each tagged with # its SOURCE scope id (not currentScopeId). collectTagged = @@ -325,7 +408,7 @@ let let entries = (scopedClassImports.${sid} or { }).${pipeName} or [ ]; rawValues = flattenAndExtract entries; - resolved = resolveThunks hostConfigs scopeContexts sid rawValues; + resolved = resolveThunks hostConfigs producerConfigFor scopeContexts sid rawValues; # Also collect data that sid's children exposed UP into sid (pipe.expose). # collectAllExposed already resolved these at the exposing node, so they # cross as concrete data — a peer's collect sees a host's exposed-up @@ -556,6 +639,26 @@ let in if bStage == null then null else bStage.fn; + # Dedup pipe effects by (pipeName, policyName). A policy may fire for several + # entity kinds in one scope, producing duplicate effects for a single routing + # — used by the expose (collectAllExposed) and broadcast (collectAllBroadcast) + # passes, which both fan a scope's routing effects out once. + dedupEffectsByPolicy = + let + go = + seen: effs: + if effs == [ ] then + [ ] + else + let + e = builtins.head effs; + rest = builtins.tail effs; + key = "${e.pipeName}/${e.__pipePolicyName or ""}"; + in + if seen ? ${key} then go seen rest else [ e ] ++ go (seen // { ${key} = true; }) rest; + in + go { }; + # Collect exposed data bottom-up from child scopes. # Returns: { parentScopeId → { pipeName → [values] } } collectAllExposed = @@ -564,6 +667,7 @@ let scopedClassImports, scopedPipeEffects, scopeParent, + scopeEntityKind ? { }, }: let allScopeIds = builtins.attrNames scopeContexts; @@ -585,23 +689,7 @@ let isRoot = parentId == null || parentId == scopeId; scopeEffects = scopedPipeEffects.${scopeId} or [ ]; rawExposeEffects = builtins.filter hasExposeStage scopeEffects; - # Dedup expose effects by (pipeName, policyName) — policies may fire - # for multiple entity kinds in the same scope, producing duplicates. - exposeEffects = - let - go = - seen: effs: - if effs == [ ] then - [ ] - else - let - e = builtins.head effs; - rest = builtins.tail effs; - key = "${e.pipeName}/${e.__pipePolicyName or ""}"; - in - if seen ? ${key} then go seen rest else [ e ] ++ go (seen // { ${key} = true; }) rest; - in - go { } rawExposeEffects; + exposeEffects = dedupEffectsByPolicy rawExposeEffects; in if isRoot || exposeEffects == [ ] then afterChildren @@ -623,7 +711,9 @@ let # 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); + resolvedBase = markConfigThunks (producerOf scopeEntityKind scopeContexts scopeId) ( + 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 [ ]; @@ -659,41 +749,28 @@ let # the broadcast predicate. The push dual of pipe.expose (which routes to the # parent); mechanically a fan-out gather, so it reuses findMatchingAll's # entity-kind filtering and resolveThunks' cross-host config resolution. - # Source values are the broadcaster's RAW emits (user scopes are leaves with - # no children to expose) — not the post-expose assembled value. + # Source values are the broadcaster's RAW emits — not the post-expose + # assembled value. The marquee source is a user scope (a leaf with no children + # to expose); a HOST broadcaster therefore does NOT fold in its users' + # exposed-up data, an intentional asymmetry with collect's raw+exposed read. # Returns: { receiverScopeId → { pipeName → [values] } } collectAllBroadcast = { scopeContexts, scopedClassImports, scopedPipeEffects, + scopeParent ? { }, scopeEntityKind ? { }, hostConfigs ? null, }: let allScopeIds = builtins.attrNames scopeContexts; + producerConfigFor = producerConfigs { inherit hostConfigs scopeContexts scopeParent; }; perBroadcaster = sourceId: let scopeEffects = scopedPipeEffects.${sourceId} or [ ]; - rawBroadcast = builtins.filter hasBroadcastStage scopeEffects; - # Dedup by (pipeName, policyName) — a policy may fire for multiple - # entity kinds in the same scope, producing duplicate effects. - broadcastEffects = - let - go = - seen: effs: - if effs == [ ] then - [ ] - else - let - e = builtins.head effs; - rest = builtins.tail effs; - key = "${e.pipeName}/${e.__pipePolicyName or ""}"; - in - if seen ? ${key} then go seen rest else [ e ] ++ go (seen // { ${key} = true; }) rest; - in - go { } rawBroadcast; + broadcastEffects = dedupEffectsByPolicy (builtins.filter hasBroadcastStage scopeEffects); scopeImports = scopedClassImports.${sourceId} or { }; in lib.concatMap ( @@ -703,11 +780,12 @@ let rawEntries = scopeImports.${pipeName} or [ ]; baseValues = flattenAndExtract rawEntries; # Resolve the source value to data as it crosses to the receiver: - # pipeline-parametric eagerly, config-dependent against the SOURCE - # host's config (hostConfigs) — NOT deferred, since the receiver may - # be on another host. Then apply the source-side transform stages - # (the broadcast routing stage is ignored by applyTransformStages). - resolvedBase = resolveThunks hostConfigs scopeContexts sourceId baseValues; + # pipeline-parametric eagerly, config-dependent against the SOURCE's + # PRODUCER class config (host→nixos, user/home→home-manager) — NOT + # deferred, since the receiver may be on another host. Then apply the + # source-side transform stages (the broadcast routing stage is + # ignored by applyTransformStages). + resolvedBase = resolveThunks hostConfigs producerConfigFor scopeContexts sourceId baseValues; transformed = applyTransformStages resolvedBase (effect.stages or [ ]); receivers = findMatchingAll { inherit scopeContexts scopeEntityKind; @@ -754,6 +832,7 @@ let scopedClassImports scopedPipeEffects scopeParent + scopeEntityKind ; }; @@ -763,6 +842,7 @@ let scopeContexts scopedClassImports scopedPipeEffects + scopeParent scopeEntityKind hostConfigs ; @@ -811,9 +891,12 @@ let rawEntries = scopeImports.${pn} or [ ]; baseValues = flattenAndExtract rawEntries; resolvedBase = builtins.concatMap (resolveLocalParametric scopeCtx) baseValues; - markedBase = markConfigThunks resolvedBase; + # Own emits are produced at THIS scope; exposed values keep the + # producer tag set at their exposing node (re-mark is a no-op). + producer = producerOf scopeEntityKind scopeContexts scopeId; + markedBase = markConfigThunks producer resolvedBase; exposedValues = exposedForScope.${pn} or [ ]; - markedExposed = markConfigThunks exposedValues; + markedExposed = markConfigThunks producer exposedValues; in markedBase ++ markedExposed; diff --git a/nix/lib/aspects/fx/class-module.nix b/nix/lib/aspects/fx/class-module.nix index a3cba1043..41657cda7 100644 --- a/nix/lib/aspects/fx/class-module.nix +++ b/nix/lib/aspects/fx/class-module.nix @@ -110,6 +110,7 @@ let ctx, aspectPolicy, globalPolicy, + class ? null, }: let allArgs = builtins.functionArgs module; @@ -147,25 +148,71 @@ let denArgsWithThunks = builtins.filter (k: pipeThunks ? ${k}) denArgNames; hasConfigThunks = denArgsWithThunks != [ ]; - # If any den args have config thunks, we need `config` from the module - # system to resolve them — force wrapper path even if no other remaining args. + # The consuming scope's own user/home name — a producer marker whose name + # matches resolves against `config` directly (same home; standalone-safe). + consumerName = ctx.user.name or ctx.home.name or null; + + # Each deferred thunk is handed `config` (its PRODUCER class config) and + # `osConfig` (the enclosing host config), mirroring home-manager. In a + # home module `osConfig` comes from the module system; it is requested + # only when a marker needs the host config to resolve (host producer, or + # a different home) OR the thunk reads osConfig itself — never for a + # pure same-home thunk, so standalone homes (no osConfig) keep working. + allMarkers = builtins.filter (v: v ? __configThunk) ( + lib.concatMap (k: ctx.${k} or [ ]) denArgsWithThunks + ); + markerNeedsOsConfig = + m: + (m.__producerKind or null) == "host" + || (m.__producerName or null) != consumerName + || (builtins.functionArgs (m.__fn or (_: { }))) ? osConfig; + needsOsConfig = + class == "homeManager" && hasConfigThunks && builtins.any markerNeedsOsConfig allMarkers; + + # If any den args have config thunks, we need `config` (and possibly + # `osConfig`) from the module system to resolve them — force the wrapper + # path even if no other remaining args. effectiveRemainingArgs = - if hasConfigThunks then remainingArgs // { config = true; } else remainingArgs; + if hasConfigThunks then + remainingArgs // { config = true; } // lib.optionalAttrs needsOsConfig { osConfig = true; } + else + remainingArgs; - # Resolve config thunk markers using both the scope context (for pipeline - # args like host/user) and the evalModules fixpoint config. + # Resolve config thunk markers against the PRODUCING class+scope's config + # (not the consuming module's): a host producer → the host config (the + # consumer's `config` for a non-home class, else `osConfig`); a user/home + # producer → its home-manager config, reached from that host config. resolveMarkers = - config: values: + config: osConfig: values: + let + hostCfg = if class == "homeManager" then osConfig else config; + in builtins.concatMap ( v: if v ? __configThunk then let - # Provide scope context args (host, user, etc.) plus config from fixpoint. thunkArgs = builtins.functionArgs v.__fn; ctxArgs = lib.genAttrs (builtins.filter (k: ctx ? ${k}) (builtins.attrNames thunkArgs)) ( k: ctx.${k} ); - result = v.__fn (ctxArgs // { inherit config lib; }); + pk = v.__producerKind or null; + producerConfig = + if pk == null then + config + else if pk == "host" then + hostCfg + else if class == "homeManager" && (v.__producerName or null) == consumerName then + config + else + (hostCfg.home-manager.users.${v.__producerName} or { }); + result = v.__fn ( + ctxArgs + // { + config = producerConfig; + osConfig = hostCfg; + inherit lib; + } + ); in if builtins.isList result then result else [ result ] else @@ -191,7 +238,7 @@ let lib.mapAttrs ( k: v: if builtins.elem k denArgsWithThunks && builtins.isList v then - resolveMarkers (moduleArgs.config or { }) v + resolveMarkers (moduleArgs.config or { }) (moduleArgs.osConfig or { }) v else v ) denWinsDen @@ -218,9 +265,17 @@ let ctx, aspectPolicy, globalPolicy, + class ? null, }: let - result = wrapDeferredImports { inherit ctx aspectPolicy globalPolicy; } module.imports; + result = wrapDeferredImports { + inherit + ctx + aspectPolicy + globalPolicy + class + ; + } module.imports; policy = resolveCollisionPolicy { inherit ctx aspectPolicy globalPolicy; }; denArgNames = builtins.attrNames ctx; validator = mkCollisionValidator policy denArgNames; diff --git a/nix/lib/aspects/fx/edges/provides.nix b/nix/lib/aspects/fx/edges/provides.nix index ea10a8507..a74405db6 100644 --- a/nix/lib/aspects/fx/edges/provides.nix +++ b/nix/lib/aspects/fx/edges/provides.nix @@ -66,6 +66,7 @@ let rawModule = if path == [ ] then spec.module else lib.setAttrByPath path spec.module; wrapped = den.lib.aspects.fx.aspect.wrapClassModule { inherit ctx; + class = targetClass; module = rawModule; aspectPolicy = null; globalPolicy = null; diff --git a/nix/lib/aspects/fx/wrap-classes.nix b/nix/lib/aspects/fx/wrap-classes.nix index 9ef5221cc..96a753cd9 100644 --- a/nix/lib/aspects/fx/wrap-classes.nix +++ b/nix/lib/aspects/fx/wrap-classes.nix @@ -133,7 +133,7 @@ let enrichment = mergeEnrichment (applyPipeTargeting enrichedCtx entry) entry.ctx; inherit (enrichment) enrichmentKeys ctx; result = den.lib.aspects.fx.aspect.wrapClassModule { - inherit ctx; + inherit ctx class; inherit (entry) module aspectPolicy globalPolicy; }; # Don't strip den arg keys that the wrapper intentionally advertises diff --git a/templates/ci/modules/internal-api/home-extraction.nix b/templates/ci/modules/internal-api/home-extraction.nix index a376988c6..7e10a58bb 100644 --- a/templates/ci/modules/internal-api/home-extraction.nix +++ b/templates/ci/modules/internal-api/home-extraction.nix @@ -161,8 +161,10 @@ 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}" ]; + # __configThunk). Under producer-class resolution the user's `config` is + # its home-manager config, so a HOST-derived mark reads the enclosing + # host via `osConfig` (home-manager convention). + den.aspects.tux.host-marks = { osConfig, ... }: [ "mark-${osConfig.networking.hostName}" ]; den.aspects.igloo.nixos = { host-marks, lib, ... }: diff --git a/templates/ci/modules/public-api/pipe-broadcast.nix b/templates/ci/modules/public-api/pipe-broadcast.nix index a4f94db70..e6fd3c5ee 100644 --- a/templates/ci/modules/public-api/pipe-broadcast.nix +++ b/templates/ci/modules/public-api/pipe-broadcast.nix @@ -353,5 +353,148 @@ }; } ); + + # Config-dependent emit broadcast from a HOST source resolves against the + # producer's class config — the host's own nixos config. + test-broadcast-config-thunk-host = denTest ( + { + den, + igloo, + lib, + ... + }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.hosts.x86_64-linux.iceberg.users.alice = { }; + + den.quirks.peer-dev.description = "per-user device records"; + + den.aspects.set-hostname.nixos = + { host, ... }: + { + networking.hostName = host.name; + }; + + # iceberg HOST emits a config-dependent record and broadcasts to hosts. + den.aspects.iceberg.peer-dev = { config, ... }: [ { who = "h-${config.networking.hostName}"; } ]; + den.policies.broadcast-to-hosts = + { host, ... }: + let + inherit (den.lib.policy) pipe; + in + [ (pipe.from "peer-dev" [ (pipe.broadcast ({ host, ... }: true)) ]) ]; + den.schema.host.includes = [ + den.aspects.set-hostname + den.policies.broadcast-to-hosts + ]; + + den.aspects.igloo.includes = [ den.aspects.peer-consumer ]; + den.aspects.peer-consumer.nixos = + { peer-dev, ... }: + { + networking.domain = lib.concatStringsSep "," (map (p: p.who) peer-dev); + }; + + expr = igloo.networking.domain; + expected = "h-iceberg"; + } + ); + + # A config-dependent emit broadcast from a USER source resolves against the + # PRODUCER's class config — the user's home-manager config (not the cross- + # host nixos config, which has no entry for a user scope). alice reads her + # own home field; the resolved value reaches a peer host's consumer. + test-broadcast-config-thunk-user = denTest ( + { + den, + igloo, + lib, + ... + }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.hosts.x86_64-linux.iceberg.users.alice = { }; + + den.quirks.peer-dev.description = "per-user device records"; + + # alice (USER) emits a config-dependent record reading her HOME config, + # broadcast to hosts. Resolves against alice's home-manager config. + den.aspects.alice.peer-dev = { config, ... }: [ { who = "u-${config.home.username}"; } ]; + den.policies.broadcast-to-hosts = + { host, user, ... }: + let + inherit (den.lib.policy) pipe; + in + [ (pipe.from "peer-dev" [ (pipe.broadcast ({ host, ... }: true)) ]) ]; + den.schema.user.includes = [ den.policies.broadcast-to-hosts ]; + + den.aspects.igloo.includes = [ den.aspects.peer-consumer ]; + den.aspects.peer-consumer.nixos = + { peer-dev, ... }: + { + networking.domain = lib.concatStringsSep "," (map (p: p.who) peer-dev); + }; + + expr = igloo.networking.domain; + expected = "u-alice"; + } + ); + + # Pure-receiver binding: a user with NO own emit/effect, on a host that runs + # a peer-dev policy (so its policyBoundAncestor is non-null), receives a + # peer's broadcast. The bindsPipeLocally broadcast clause makes tux read the + # broadcast ("alice"); WITHOUT it tux would fall through to ancestor + # inheritance and read igloo host's collected value ("igloo-host"). + test-broadcast-pure-receiver-binds = denTest ( + { + den, + tuxHm, + lib, + ... + }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.hosts.x86_64-linux.iceberg.users.alice = { }; + + den.quirks.peer-dev.description = "per-user device records"; + + # alice-specific broadcast (NOT schema.user — so tux has no peer-dev policy). + den.policies.broadcast-peer-dev = + { user, ... }: + let + inherit (den.lib.policy) pipe; + in + [ (pipe.from "peer-dev" [ (pipe.broadcast ({ user, ... }: true)) ]) ]; + den.aspects.alice = { + peer-dev = [ { who = "alice"; } ]; + includes = [ den.policies.broadcast-peer-dev ]; + }; + + # igloo host binds peer-dev (policy effect → tux's policyBoundAncestor) + # with a DISTINCT value, so inheritance is observable. + den.policies.host-collect = + { host, ... }: + let + inherit (den.lib.policy) pipe; + in + [ (pipe.from "peer-dev" [ (pipe.collectAll ({ host, ... }: true)) ]) ]; + den.aspects.igloo = { + peer-dev = [ { who = "igloo-host"; } ]; + includes = [ den.policies.host-collect ]; + }; + + # tux: pure receiver — only a home consumer. + den.aspects.tux.homeManager = + { peer-dev, ... }: + { + home.sessionVariables.PEERS = lib.concatStringsSep "," ( + lib.sort (a: b: a < b) (map (p: p.who) peer-dev) + ); + }; + + expr = tuxHm.home.sessionVariables.PEERS; + expected = "alice"; + } + ); }; } diff --git a/templates/ci/modules/public-api/pipe-config-scope.nix b/templates/ci/modules/public-api/pipe-config-scope.nix new file mode 100644 index 000000000..552e89e75 --- /dev/null +++ b/templates/ci/modules/public-api/pipe-config-scope.nix @@ -0,0 +1,112 @@ +# Producer-class resolution for the DEFERRED (__configThunk) path: a pipe +# config-thunk must resolve against the PRODUCING class module + scope, not the +# consuming one. Same-host; the cross-host eager path is covered by pipe-broadcast. +{ denTest, lib, ... }: +{ + flake.tests.pipe-config-scope = { + + # Host-PRODUCED config-thunk (reads a nixos field) CONSUMED in a home (a + # different class). Must resolve against the host's nixos config (producing + # class), not the home config — which would throw `networking missing`. + test-host-produced-consumed-in-home = denTest ( + { + den, + tuxHm, + lib, + ... + }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.quirks.dev.description = "device"; + + den.aspects.set-hostname.nixos = + { host, ... }: + { + networking.hostName = host.name; + }; + den.policies.bind-dev = + { host, ... }: + let + inherit (den.lib.policy) pipe; + in + [ (pipe.from "dev" [ ]) ]; + den.schema.host.includes = [ + den.aspects.set-hostname + den.policies.bind-dev + ]; + + # PRODUCED at host scope, reads a NIXOS field. + den.aspects.igloo.dev = { config, ... }: [ "h:${config.networking.hostName}" ]; + + # CONSUMED in tux's home (different class) via pure-consumer inheritance. + den.aspects.tux.homeManager = + { dev, ... }: + { + home.sessionVariables.DEV = builtins.head dev; + }; + + expr = tuxHm.home.sessionVariables.DEV; + expected = "h:igloo"; + } + ); + + # Same-scope same-class (the common case) keeps working: a user-produced + # config-thunk reading a HOME field, consumed in the same user's home. + test-user-produced-consumed-in-own-home = denTest ( + { + den, + tuxHm, + lib, + ... + }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.quirks.dev.description = "device"; + + den.aspects.tux = { + dev = { config, ... }: [ "u:${config.home.username}" ]; + homeManager = + { dev, ... }: + { + home.sessionVariables.DEV = builtins.head dev; + }; + }; + + expr = tuxHm.home.sessionVariables.DEV; + expected = "u:tux"; + } + ); + + # User-PRODUCED config-thunk reading a HOME field, exposed up and CONSUMED in + # the host's nixos (cross-class user→host). Resolves against the producer's + # home-manager config — the user's own home, not the consuming host config. + test-user-produced-consumed-in-host = denTest ( + { + den, + igloo, + lib, + ... + }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.quirks.dev.description = "device"; + + den.policies.expose-dev = + { user, ... }: [ (den.lib.policy.pipe.from "dev" [ den.lib.policy.pipe.expose ]) ]; + den.schema.user.includes = [ den.policies.expose-dev ]; + + # PRODUCED at the user node, reads a HOME field. + den.aspects.tux.dev = { config, ... }: [ "u:${config.home.username}" ]; + + den.aspects.igloo.nixos = + { dev, ... }: + { + networking.domain = builtins.head dev; + }; + + expr = igloo.networking.domain; + expected = "u:tux"; + } + ); + }; +} From dac099b2b59426545894e3ddaf4662a68dfc379b Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Thu, 25 Jun 2026 20:55:24 -0700 Subject: [PATCH 3/4] refactor: derive config-thunk host navigation from the class registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The producer-class config-thunk resolution hard-coded the home-manager battery's delivery convention into core fx (the `home-manager.users.` path and the `"homeManager"` class literal). Replace that with a registry-driven route. - New `den.classes..hostPath` (name -> path): where a class's members nest inside the enclosing host config. The home-manager battery registers it from the same value as its forward delivery path (single source of truth); host-level classes (nixos, darwin) leave it null. - assemble-pipes (producerConfigs / producerOf) and class-module (resolveMarkers) now navigate host<->producer config via the producer's registered hostPath and decide "is this class host-nested" from the registry, keyed by scopeEntityClass — no `home-manager`/`homeManager` literals in core fx. A new home-style class works by registering hostPath, no core edits. - Marker carries `__producerClass` (was `__producerKind`); scopeEntityClass threaded into assemblePipes alongside scopeEntityKind. The osConfig handed to deferred thunks remains den's own host-config link (home.nix extraSpecialArgs.osConfig, derived from the host's intoAttr). Behavior unchanged; full CI 1042/1042. --- modules/aspects/batteries/home-manager.nix | 17 +++-- modules/options.nix | 11 +++ nix/lib/aspects/fx/assemble-pipes.nix | 82 +++++++++++++++------- nix/lib/aspects/fx/class-module.nix | 44 +++++++----- nix/lib/aspects/fx/resolve.nix | 4 ++ 5 files changed, 108 insertions(+), 50 deletions(-) diff --git a/modules/aspects/batteries/home-manager.nix b/modules/aspects/batteries/home-manager.nix index 5de0806b3..1ec509d38 100644 --- a/modules/aspects/batteries/home-manager.nix +++ b/modules/aspects/batteries/home-manager.nix @@ -6,18 +6,20 @@ ... }: let + # Where a home-manager user's config nests inside the enclosing host config. + # Single source of truth for both the forward delivery target and the + # den.classes.homeManager.hostPath the pipe layer resolves producers against. + userHostPath = userName: [ + "home-manager" + "users" + userName + ]; result = den.lib.home-env.makeHomeEnv { className = "homeManager"; ctxName = "hm"; optionPath = "home-manager"; getModule = { host, ... }: inputs.home-manager."${host.class}Modules".home-manager; - forwardPathFn = - { user, ... }: - [ - "home-manager" - "users" - user.userName - ]; + forwardPathFn = { user, ... }: userHostPath user.userName; schemaIncludes = config.den.schema.hm-host.includes or [ ]; }; @@ -29,4 +31,5 @@ in den.schema.user.includes = [ result.userDetect ]; den.classes.homeManager.description = "Home Manager user environment"; + den.classes.homeManager.hostPath = userHostPath; } diff --git a/modules/options.nix b/modules/options.nix index 599e7e344..5efc6328a 100644 --- a/modules/options.nix +++ b/modules/options.nix @@ -32,6 +32,17 @@ let type = lib.types.nullOr lib.types.raw; default = null; }; + options.hostPath = lib.mkOption { + description = '' + For a class whose members nest inside an enclosing host config (e.g. + home-manager), a function `name -> path` locating a named member within + that host config — the same route the class's content is delivered to. + The pipe layer uses it to resolve a producer's config at its producing + class + scope. null for host-level classes (nixos, darwin). + ''; + type = lib.types.nullOr lib.types.raw; + default = null; + }; } ); diff --git a/nix/lib/aspects/fx/assemble-pipes.nix b/nix/lib/aspects/fx/assemble-pipes.nix index 77da53f52..31964d2ee 100644 --- a/nix/lib/aspects/fx/assemble-pipes.nix +++ b/nix/lib/aspects/fx/assemble-pipes.nix @@ -82,18 +82,17 @@ let [ val ]; # Mark a config-dependent value for deferred resolution inside evalModules. - # `producer` tags the marker with the PRODUCING scope's class (entity kind) - # and name so the module wrapper resolves it against the producing class + - # scope's config — not the consuming module's. Already-marked values (re-mark - # on an exposed/inherited path) pass through unchanged, keeping their original - # producer tag. + # `producer` tags the marker with the PRODUCING scope's class and name so the + # module wrapper resolves it against the producing class + scope's config — + # not the consuming module's. Already-marked values (re-mark on an exposed/ + # inherited path) pass through unchanged, keeping their original producer tag. markConfigThunk = producer: v: if isConfigDependent v then { __configThunk = true; __fn = v; - __producerKind = producer.kind or null; + __producerClass = producer.class or null; __producerName = producer.name or null; } else @@ -102,31 +101,34 @@ let # Mark all config-dependent entries in a value list with their producer. markConfigThunks = producer: map (markConfigThunk producer); - # Producer tag (class/kind + name) for a scope, read from pipeline state. + # Producer tag (class + name) for a scope, read from pipeline state. The class + # selects the producer's config-resolution route via den.classes..hostPath. producerOf = - scopeEntityKind: scopeContexts: sid: + scopeEntityClass: scopeContexts: sid: let ctx = scopeContexts.${sid} or { }; in { - kind = scopeEntityKind.${sid} or null; + class = scopeEntityClass.${sid} or null; name = ctx.user.name or ctx.home.name or null; }; # The PRODUCER's `config` (class config) and `osConfig` (enclosing host # config) for a scope, used to resolve cross-scope config-dependent emits at - # their SOURCE (not the consumer). A host scope's producing class is nixos — - # its own host config (a hostConfigs key), where config == osConfig. A user/ - # home scope's is home-manager — its config nested under the enclosing host at - # `home-manager.users.`, with osConfig the enclosing host config. This - # keeps a user emit reading `config.home.*` (or `osConfig.networking.*`) - # correct and matches the "producing class + scope" rule. Cross-host can't - # reach a remote real fixpoint, so it leans on the precomputed hostConfigs. + # their SOURCE (not the consumer). A host scope owns a host config directly (a + # hostConfigs key), where config == osConfig. A nested scope (user/home) reads + # its config from the enclosing host config at its class's registered + # `den.classes..hostPath` — the same route its content is delivered to — + # with osConfig the enclosing host config. This keeps a user emit reading + # `config.home.*` (or `osConfig.networking.*`) correct and matches the + # "producing class + scope" rule. Cross-host can't reach a remote real + # fixpoint, so it leans on the precomputed hostConfigs. producerConfigs = { hostConfigs, scopeContexts, scopeParent ? { }, + scopeEntityClass ? { }, }: scopeId: if hostConfigs == null then @@ -152,12 +154,16 @@ let findHost (scopeParent.${sid} or null); hostScope = findHost (scopeParent.${scopeId} or null); hostCfg = if hostScope == null then { } else hostConfigs.${hostScope}; + # The producer's class hostPath (registered by its battery, e.g. + # home-manager) locates its config within the enclosing host config — + # the same route its content is delivered to. No path → not host-nested. + cls = scopeEntityClass.${scopeId} or null; + pathFn = if cls != null && den.classes ? ${cls} then den.classes.${cls}.hostPath else null; ctx = scopeContexts.${scopeId} or { }; name = ctx.user.name or ctx.home.name or null; - hmUsers = hostCfg.home-manager.users or { }; in { - config = if name != null && hmUsers ? ${name} then hmUsers.${name} else { }; + config = if pathFn != null && name != null then lib.attrByPath (pathFn name) { } hostCfg else { }; osConfig = hostCfg; }; @@ -375,6 +381,7 @@ let scopeEntityKind ? { }, scopedClassImports, allExposed ? { }, + scopeEntityClass ? { }, currentScopeId, pipeName, hostConfigs ? null, @@ -398,7 +405,14 @@ let ) stages; # Tag initial values at the current scope (identity for the plain path). taggedInitial = map (functor.seed currentScopeId) initialValues; - producerConfigFor = producerConfigs { inherit hostConfigs scopeContexts scopeParent; }; + producerConfigFor = producerConfigs { + inherit + hostConfigs + scopeContexts + scopeParent + scopeEntityClass + ; + }; # Resolve a list of matching scopes into collected values, each tagged with # its SOURCE scope id (not currentScopeId). collectTagged = @@ -501,6 +515,7 @@ let scopeEntityKind ? { }, scopedClassImports, allExposed ? { }, + scopeEntityClass ? { }, currentScopeId, pipeName, hostConfigs ? null, @@ -523,6 +538,7 @@ let scopeEntityKind scopedClassImports allExposed + scopeEntityClass currentScopeId pipeName hostConfigs @@ -540,6 +556,7 @@ let scopeEntityKind ? { }, scopedClassImports, allExposed ? { }, + scopeEntityClass ? { }, hostConfigs ? null, }: pipeName: scopeId: baseValues: effects: @@ -563,6 +580,7 @@ let scopeEntityKind scopedClassImports allExposed + scopeEntityClass hostConfigs ; currentScopeId = scopeId; @@ -586,6 +604,7 @@ let scopeEntityKind ? { }, scopedClassImports, allExposed ? { }, + scopeEntityClass ? { }, currentScopeId, hostConfigs ? null, }: @@ -602,6 +621,7 @@ let scopeParent scopedClassImports allExposed + scopeEntityClass currentScopeId hostConfigs ; @@ -667,7 +687,7 @@ let scopedClassImports, scopedPipeEffects, scopeParent, - scopeEntityKind ? { }, + scopeEntityClass ? { }, }: let allScopeIds = builtins.attrNames scopeContexts; @@ -711,7 +731,7 @@ let # 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 (producerOf scopeEntityKind scopeContexts scopeId) ( + resolvedBase = markConfigThunks (producerOf scopeEntityClass scopeContexts scopeId) ( builtins.concatMap (resolveLocalParametric scopeCtx) baseValues ); # Child-exposed data is already concrete — each child resolved its @@ -761,11 +781,19 @@ let scopedPipeEffects, scopeParent ? { }, scopeEntityKind ? { }, + scopeEntityClass ? { }, hostConfigs ? null, }: let allScopeIds = builtins.attrNames scopeContexts; - producerConfigFor = producerConfigs { inherit hostConfigs scopeContexts scopeParent; }; + producerConfigFor = producerConfigs { + inherit + hostConfigs + scopeContexts + scopeParent + scopeEntityClass + ; + }; perBroadcaster = sourceId: let @@ -819,6 +847,7 @@ let scopedPipeEffects ? { }, scopeParent ? { }, scopeEntityKind ? { }, + scopeEntityClass ? { }, hostConfigs ? null, }: if pipeNames == [ ] then @@ -832,7 +861,7 @@ let scopedClassImports scopedPipeEffects scopeParent - scopeEntityKind + scopeEntityClass ; }; @@ -844,6 +873,7 @@ let scopedPipeEffects scopeParent scopeEntityKind + scopeEntityClass hostConfigs ; }; @@ -893,7 +923,7 @@ let resolvedBase = builtins.concatMap (resolveLocalParametric scopeCtx) baseValues; # Own emits are produced at THIS scope; exposed values keep the # producer tag set at their exposing node (re-mark is a no-op). - producer = producerOf scopeEntityKind scopeContexts scopeId; + producer = producerOf scopeEntityClass scopeContexts scopeId; markedBase = markConfigThunks producer resolvedBase; exposedValues = exposedForScope.${pn} or [ ]; markedExposed = markConfigThunks producer exposedValues; @@ -942,6 +972,7 @@ let scopeEntityKind scopedClassImports allExposed + scopeEntityClass hostConfigs ; currentScopeId = scopeId; @@ -963,6 +994,7 @@ let scopeEntityKind scopedClassImports allExposed + scopeEntityClass hostConfigs ; } pipeName scopeId combinedBase untargetedEffects; @@ -1024,6 +1056,7 @@ let scopeEntityKind scopedClassImports allExposed + scopeEntityClass hostConfigs ; currentScopeId = scopeId; @@ -1041,6 +1074,7 @@ let scopeEntityKind scopedClassImports allExposed + scopeEntityClass hostConfigs ; currentScopeId = scopeId; diff --git a/nix/lib/aspects/fx/class-module.nix b/nix/lib/aspects/fx/class-module.nix index 41657cda7..f61afb50d 100644 --- a/nix/lib/aspects/fx/class-module.nix +++ b/nix/lib/aspects/fx/class-module.nix @@ -148,26 +148,31 @@ let denArgsWithThunks = builtins.filter (k: pipeThunks ? ${k}) denArgNames; hasConfigThunks = denArgsWithThunks != [ ]; - # The consuming scope's own user/home name — a producer marker whose name - # matches resolves against `config` directly (same home; standalone-safe). + # The consuming scope's own user/home name — a producer marker for the + # same member resolves against `config` directly (standalone-safe). consumerName = ctx.user.name or ctx.home.name or null; + # A class's hostPath (registered by its battery) is the route its members + # nest into the enclosing host config — null for host-level classes + # (nixos, darwin). This is the single signal for "does this class nest". + classHostPath = c: if c != null && den.classes ? ${c} then den.classes.${c}.hostPath else null; + consumerNested = (classHostPath class) != null; + # Each deferred thunk is handed `config` (its PRODUCER class config) and - # `osConfig` (the enclosing host config), mirroring home-manager. In a - # home module `osConfig` comes from the module system; it is requested - # only when a marker needs the host config to resolve (host producer, or - # a different home) OR the thunk reads osConfig itself — never for a - # pure same-home thunk, so standalone homes (no osConfig) keep working. + # `osConfig` (the enclosing host config). In a nested (e.g. home) module + # `osConfig` comes from the module system; it is requested only when a + # marker needs the host config to resolve — a host-level producer, a + # different member, or a thunk reading osConfig — never for a pure same- + # member thunk, so standalone homes (no osConfig) keep working. allMarkers = builtins.filter (v: v ? __configThunk) ( lib.concatMap (k: ctx.${k} or [ ]) denArgsWithThunks ); markerNeedsOsConfig = m: - (m.__producerKind or null) == "host" + (classHostPath (m.__producerClass or null)) == null || (m.__producerName or null) != consumerName || (builtins.functionArgs (m.__fn or (_: { }))) ? osConfig; - needsOsConfig = - class == "homeManager" && hasConfigThunks && builtins.any markerNeedsOsConfig allMarkers; + needsOsConfig = consumerNested && hasConfigThunks && builtins.any markerNeedsOsConfig allMarkers; # If any den args have config thunks, we need `config` (and possibly # `osConfig`) from the module system to resolve them — force the wrapper @@ -179,13 +184,13 @@ let remainingArgs; # Resolve config thunk markers against the PRODUCING class+scope's config - # (not the consuming module's): a host producer → the host config (the - # consumer's `config` for a non-home class, else `osConfig`); a user/home - # producer → its home-manager config, reached from that host config. + # (not the consuming module's): a host-level producer → the host config + # (the consumer's `config` for a host-level class, else `osConfig`); a + # nested producer → its config at the registered hostPath of the host config. resolveMarkers = config: osConfig: values: let - hostCfg = if class == "homeManager" then osConfig else config; + hostCfg = if consumerNested then osConfig else config; in builtins.concatMap ( v: @@ -195,16 +200,17 @@ let ctxArgs = lib.genAttrs (builtins.filter (k: ctx ? ${k}) (builtins.attrNames thunkArgs)) ( k: ctx.${k} ); - pk = v.__producerKind or null; + pcls = v.__producerClass or null; + pPath = classHostPath pcls; producerConfig = - if pk == null then + if pcls == null then config - else if pk == "host" then + else if pPath == null then hostCfg - else if class == "homeManager" && (v.__producerName or null) == consumerName then + else if consumerNested && pcls == class && (v.__producerName or null) == consumerName then config else - (hostCfg.home-manager.users.${v.__producerName} or { }); + lib.attrByPath (pPath v.__producerName) { } hostCfg; result = v.__fn ( ctxArgs // { diff --git a/nix/lib/aspects/fx/resolve.nix b/nix/lib/aspects/fx/resolve.nix index 4798b1a0e..3b9212502 100644 --- a/nix/lib/aspects/fx/resolve.nix +++ b/nix/lib/aspects/fx/resolve.nix @@ -471,8 +471,10 @@ let # Local config thunks are marked for deferred resolution inside evalModules. # Cross-host config thunks (from pipe.collect) are resolved using hostConfigs. scopeEntityKind = (result.state.scopeEntityKind or (_: { })) null; + scopeEntityClassMap = (result.state.scopeEntityClass or (_: { })) null; augmentedScopeContexts = assemblePipes { inherit scopeContexts hostConfigs scopeEntityKind; + scopeEntityClass = scopeEntityClassMap; scopedClassImports = scopedClassImportsRaw; scopedPipeEffects = result.state.scopedPipeEffects null; inherit scopeParent; @@ -504,6 +506,7 @@ let # hostConfigs or augmentedScopeContexts. augmentedScopeContextsNoCfg = assemblePipes { inherit scopeContexts scopeEntityKind; + scopeEntityClass = scopeEntityClassMap; hostConfigs = null; scopedClassImports = scopedClassImportsRaw; scopedPipeEffects = result.state.scopedPipeEffects null; @@ -1091,6 +1094,7 @@ let augmentedScopeContexts = assemblePipes { inherit scopeContexts; + scopeEntityClass = (result.state.scopeEntityClass or (_: { })) null; scopedClassImports = scopedClassImportsRaw; scopedPipeEffects = result.state.scopedPipeEffects null; inherit scopeParent; From a55556ba07efe5124b31d387c07eab59ebc3fe1e Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Thu, 25 Jun 2026 21:07:07 -0700 Subject: [PATCH 4/4] refactor: make config-thunk resolution class-neutral (no host/osConfig literals) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalize the producer-class config-thunk machinery so it no longer assumes NixOS — den can describe a config-owner that isn't a host (e.g. terranix, nixidy) without core edits. - `den.classes..hostPath` -> `parentPath` (name -> path within the enclosing config-owner), and a new `parentArg` (the module arg by which a nested member reaches the owner config; home-manager registers "osConfig"). Both null for root classes that own a top-level config. - The home-manager battery registers parentPath + parentArg = "osConfig". - assemble-pipes: `isConfigDependent` detects `config` or ANY registered parentArg (not the `osConfig` literal); `producerConfigs` returns { config, owner, parentArg } and navigates via the producer class's parentPath; resolveEntry hands a nested producer's thunk the owner config under its class's parentArg. Naming reframed host -> enclosing config-owner. - class-module: the wrapper reads each class's parentPath/parentArg from the registry — a nested consumer fetches the owner via its own parentArg, and a thunk is handed the owner under its producer's parentArg. No osConfig literal. `hostConfigs` was already generic over instantiates (any entity with an intoAttr output), so the model already supported non-host owners; this removes the remaining NixOS-flavored literals from the new config-thunk path. Behavior unchanged; full CI 1042/1042. --- modules/aspects/batteries/home-manager.nix | 4 +- modules/options.nix | 23 ++++-- nix/lib/aspects/fx/assemble-pipes.nix | 83 +++++++++++++--------- nix/lib/aspects/fx/class-module.nix | 79 ++++++++++++-------- 4 files changed, 120 insertions(+), 69 deletions(-) diff --git a/modules/aspects/batteries/home-manager.nix b/modules/aspects/batteries/home-manager.nix index 1ec509d38..023ee9e2c 100644 --- a/modules/aspects/batteries/home-manager.nix +++ b/modules/aspects/batteries/home-manager.nix @@ -31,5 +31,7 @@ in den.schema.user.includes = [ result.userDetect ]; den.classes.homeManager.description = "Home Manager user environment"; - den.classes.homeManager.hostPath = userHostPath; + # home-manager nests under its host; a member reaches the host config via osConfig. + den.classes.homeManager.parentPath = userHostPath; + den.classes.homeManager.parentArg = "osConfig"; } diff --git a/modules/options.nix b/modules/options.nix index 5efc6328a..ea8a1f77f 100644 --- a/modules/options.nix +++ b/modules/options.nix @@ -32,17 +32,28 @@ let type = lib.types.nullOr lib.types.raw; default = null; }; - options.hostPath = lib.mkOption { + options.parentPath = lib.mkOption { description = '' - For a class whose members nest inside an enclosing host config (e.g. - home-manager), a function `name -> path` locating a named member within - that host config — the same route the class's content is delivered to. - The pipe layer uses it to resolve a producer's config at its producing - class + scope. null for host-level classes (nixos, darwin). + For a class whose members nest inside an enclosing config-owner (e.g. + home-manager inside a host), a function `name -> path` locating a named + member within that owner's config — the same route the class's content + is delivered to. The pipe layer uses it to resolve a producer's config + at its producing class + scope. null for root classes that own a + top-level config (nixos, darwin, terranix, …). ''; type = lib.types.nullOr lib.types.raw; default = null; }; + options.parentArg = lib.mkOption { + description = '' + For a nested class, the module argument by which a member reaches the + enclosing config-owner (home-manager exposes the host config as + `osConfig`). The pipe layer hands a deferred config-thunk the owner + config under this name. null for root classes. + ''; + type = lib.types.nullOr lib.types.str; + default = null; + }; } ); diff --git a/nix/lib/aspects/fx/assemble-pipes.nix b/nix/lib/aspects/fx/assemble-pipes.nix index 31964d2ee..3d1fa245f 100644 --- a/nix/lib/aspects/fx/assemble-pipes.nix +++ b/nix/lib/aspects/fx/assemble-pipes.nix @@ -26,9 +26,17 @@ let if builtins.isList val then val else [ val ] ) entries; + # Parent-config arg names registered by nested classes (home-manager exposes + # the owner config as `osConfig`). A thunk reading `config` or any of these + # binds to the evalModules fixpoint. + parentArgNames = builtins.filter (a: a != null) ( + map (c: c.parentArg or null) (builtins.attrValues (den.classes or { })) + ); + readsParentArg = a: builtins.any (k: a ? ${k}) parentArgNames; + # Detect config-dependent thunks: functions taking `config` (the producer's - # class config) and/or `osConfig` (the enclosing host config). Both bind to - # the evalModules fixpoint, so such thunks are deferred/resolved against it. + # class config) and/or a registered parent-config arg (the enclosing owner + # config). Both bind to the evalModules fixpoint, so they are deferred there. isConfigDependent = val: builtins.isFunction val @@ -36,11 +44,11 @@ let let a = builtins.functionArgs val; in - a ? config || a ? osConfig + a ? config || readsParentArg a ); # Pipeline-parametric values require pipeline context args (host, user, etc.) - # but neither config nor osConfig. These are resolved eagerly using scope context. + # but neither config nor a parent-config arg. Resolved eagerly via scope context. isPipelineParametric = val: builtins.isFunction val @@ -48,7 +56,7 @@ let let a = builtins.functionArgs val; in - !(a ? config) && !(a ? osConfig) + !(a ? config) && !(readsParentArg a) ); # Resolve a local pipeline-parametric value eagerly using scope context. @@ -102,7 +110,7 @@ let markConfigThunks = producer: map (markConfigThunk producer); # Producer tag (class + name) for a scope, read from pipeline state. The class - # selects the producer's config-resolution route via den.classes..hostPath. + # selects the producer's config-resolution route via den.classes..parentPath. producerOf = scopeEntityClass: scopeContexts: sid: let @@ -113,16 +121,15 @@ let name = ctx.user.name or ctx.home.name or null; }; - # The PRODUCER's `config` (class config) and `osConfig` (enclosing host - # config) for a scope, used to resolve cross-scope config-dependent emits at - # their SOURCE (not the consumer). A host scope owns a host config directly (a - # hostConfigs key), where config == osConfig. A nested scope (user/home) reads - # its config from the enclosing host config at its class's registered - # `den.classes..hostPath` — the same route its content is delivered to — - # with osConfig the enclosing host config. This keeps a user emit reading - # `config.home.*` (or `osConfig.networking.*`) correct and matches the - # "producing class + scope" rule. Cross-host can't reach a remote real - # fixpoint, so it leans on the precomputed hostConfigs. + # The PRODUCER's `config` (class config), the enclosing config-`owner` config, + # and the class's `parentArg` name, used to resolve cross-scope config- + # dependent emits at their SOURCE (not the consumer). A scope that owns a + # config directly (a hostConfigs key — e.g. a host) has config == owner and a + # null parentArg. A nested scope (e.g. a home) reads its config from the + # owner config at its class's registered `den.classes..parentPath` — + # the same route its content is delivered to — and reaches the owner via + # `parentArg`. This matches the "producing class + scope" rule. Cross-host + # can't reach a remote real fixpoint, so it leans on the precomputed hostConfigs. producerConfigs = { hostConfigs, @@ -131,47 +138,54 @@ let scopeEntityClass ? { }, }: scopeId: + let + cls = scopeEntityClass.${scopeId} or null; + classDef = if cls != null && den.classes ? ${cls} then den.classes.${cls} else { }; + parentArg = classDef.parentArg or null; + in if hostConfigs == null then { config = { }; - osConfig = { }; + owner = { }; + inherit parentArg; } else if hostConfigs ? ${scopeId} then { config = hostConfigs.${scopeId}; - osConfig = hostConfigs.${scopeId}; + owner = hostConfigs.${scopeId}; + inherit parentArg; } else let - # Nearest enclosing scope that owns a host config. - findHost = + # Nearest enclosing scope that owns a config. + findOwner = sid: if sid == null then null else if hostConfigs ? ${sid} then sid else - findHost (scopeParent.${sid} or null); - hostScope = findHost (scopeParent.${scopeId} or null); - hostCfg = if hostScope == null then { } else hostConfigs.${hostScope}; - # The producer's class hostPath (registered by its battery, e.g. - # home-manager) locates its config within the enclosing host config — - # the same route its content is delivered to. No path → not host-nested. - cls = scopeEntityClass.${scopeId} or null; - pathFn = if cls != null && den.classes ? ${cls} then den.classes.${cls}.hostPath else null; + findOwner (scopeParent.${sid} or null); + ownerScope = findOwner (scopeParent.${scopeId} or null); + ownerCfg = if ownerScope == null then { } else hostConfigs.${ownerScope}; + # The producer's class parentPath locates its config within the owner + # config — the same route its content is delivered to. No path → it owns + # its config (but isn't a hostConfigs key here, so resolves to empty). + pathFn = classDef.parentPath or null; ctx = scopeContexts.${scopeId} or { }; name = ctx.user.name or ctx.home.name or null; in { - config = if pathFn != null && name != null then lib.attrByPath (pathFn name) { } hostCfg else { }; - osConfig = hostCfg; + config = if pathFn != null && name != null then lib.attrByPath (pathFn name) { } ownerCfg else { }; + owner = ownerCfg; + inherit parentArg; }; # Resolve a config-dependent thunk against the producer's class config. # Used for COLLECTED / BROADCAST entries (cross-scope) where the SOURCE # scope's config is needed. Provides scope context args (host, user, etc.) - # alongside `config` (producer class) and `osConfig` (enclosing host). Returns - # a list (auto-flattens list-valued results). + # alongside `config` (producer class) and, for a nested producer, the owner + # config under the class's parentArg. Returns a list (auto-flattens lists). resolveEntry = hostConfigs: producerConfigFor: scopeContexts: sourceScopeId: entry: if isConfigDependent entry then @@ -191,7 +205,10 @@ let result = entry ( ctxArgs // { - inherit (pc) config osConfig; + config = pc.config; + } + // lib.optionalAttrs (pc.parentArg != null) { ${pc.parentArg} = pc.owner; } + // { inherit lib; } ); diff --git a/nix/lib/aspects/fx/class-module.nix b/nix/lib/aspects/fx/class-module.nix index f61afb50d..edd86a084 100644 --- a/nix/lib/aspects/fx/class-module.nix +++ b/nix/lib/aspects/fx/class-module.nix @@ -152,45 +152,56 @@ let # same member resolves against `config` directly (standalone-safe). consumerName = ctx.user.name or ctx.home.name or null; - # A class's hostPath (registered by its battery) is the route its members - # nest into the enclosing host config — null for host-level classes - # (nixos, darwin). This is the single signal for "does this class nest". - classHostPath = c: if c != null && den.classes ? ${c} then den.classes.${c}.hostPath else null; - consumerNested = (classHostPath class) != null; + # A class's parentPath/parentArg (registered by its battery) describe how + # its members nest into the enclosing config-owner — null for root + # classes (nixos, darwin, …). parentPath is the single "does this nest" + # signal; parentArg is the module arg by which a member reaches the owner. + classParentPath = c: if c != null && den.classes ? ${c} then den.classes.${c}.parentPath else null; + classParentArg = c: if c != null && den.classes ? ${c} then den.classes.${c}.parentArg else null; + consumerNested = (classParentPath class) != null; + consumerParentArg = classParentArg class; - # Each deferred thunk is handed `config` (its PRODUCER class config) and - # `osConfig` (the enclosing host config). In a nested (e.g. home) module - # `osConfig` comes from the module system; it is requested only when a - # marker needs the host config to resolve — a host-level producer, a - # different member, or a thunk reading osConfig — never for a pure same- - # member thunk, so standalone homes (no osConfig) keep working. + # Each deferred thunk is handed `config` (its PRODUCER class config) and, + # for a nested producer, the OWNER config under the producer class's + # parentArg. A nested consumer fetches the owner config from the module + # system via its own parentArg — requested only when a marker needs it (a + # root-class producer, a different member, or a thunk reading its + # parentArg) — never for a pure same-member thunk, so standalone members + # (no owner arg) keep working. allMarkers = builtins.filter (v: v ? __configThunk) ( lib.concatMap (k: ctx.${k} or [ ]) denArgsWithThunks ); - markerNeedsOsConfig = + markerNeedsOwner = m: - (classHostPath (m.__producerClass or null)) == null + let + pa = classParentArg (m.__producerClass or null); + in + (classParentPath (m.__producerClass or null)) == null || (m.__producerName or null) != consumerName - || (builtins.functionArgs (m.__fn or (_: { }))) ? osConfig; - needsOsConfig = consumerNested && hasConfigThunks && builtins.any markerNeedsOsConfig allMarkers; + || (pa != null && (builtins.functionArgs (m.__fn or (_: { }))) ? ${pa}); + needsOwner = consumerNested && hasConfigThunks && builtins.any markerNeedsOwner allMarkers; - # If any den args have config thunks, we need `config` (and possibly - # `osConfig`) from the module system to resolve them — force the wrapper - # path even if no other remaining args. + # If any den args have config thunks, we need `config` (and possibly the + # owner config, via the consumer's parentArg) from the module system to + # resolve them — force the wrapper path even if no other remaining args. effectiveRemainingArgs = if hasConfigThunks then - remainingArgs // { config = true; } // lib.optionalAttrs needsOsConfig { osConfig = true; } + remainingArgs + // { + config = true; + } + // lib.optionalAttrs (needsOwner && consumerParentArg != null) { ${consumerParentArg} = true; } else remainingArgs; # Resolve config thunk markers against the PRODUCING class+scope's config - # (not the consuming module's): a host-level producer → the host config - # (the consumer's `config` for a host-level class, else `osConfig`); a - # nested producer → its config at the registered hostPath of the host config. + # (not the consuming module's): a root-class producer → the owner config + # (the consumer's `config` for a root class, else the fetched owner); a + # nested producer → its config at the registered parentPath of the owner. resolveMarkers = - config: osConfig: values: + config: owner: values: let - hostCfg = if consumerNested then osConfig else config; + ownerCfg = if consumerNested then owner else config; in builtins.concatMap ( v: @@ -201,21 +212,24 @@ let k: ctx.${k} ); pcls = v.__producerClass or null; - pPath = classHostPath pcls; + pPath = classParentPath pcls; + pArg = classParentArg pcls; producerConfig = if pcls == null then config else if pPath == null then - hostCfg + ownerCfg else if consumerNested && pcls == class && (v.__producerName or null) == consumerName then config else - lib.attrByPath (pPath v.__producerName) { } hostCfg; + lib.attrByPath (pPath v.__producerName) { } ownerCfg; result = v.__fn ( ctxArgs // { config = producerConfig; - osConfig = hostCfg; + } + // lib.optionalAttrs (pArg != null) { ${pArg} = ownerCfg; } + // { inherit lib; } ); @@ -239,12 +253,19 @@ let wrapper = moduleArgs: let + # A nested consumer fetches the owner config from the module system + # via its registered parentArg (e.g. home-manager's osConfig). + ownerCfg = + if consumerNested && consumerParentArg != null then + (moduleArgs.${consumerParentArg} or { }) + else + (moduleArgs.config or { }); resolvedDen = if hasConfigThunks then lib.mapAttrs ( k: v: if builtins.elem k denArgsWithThunks && builtins.isList v then - resolveMarkers (moduleArgs.config or { }) (moduleArgs.osConfig or { }) v + resolveMarkers (moduleArgs.config or { }) ownerCfg v else v ) denWinsDen