diff --git a/modules/aspects/batteries/home-manager.nix b/modules/aspects/batteries/home-manager.nix index 5de0806b3..023ee9e2c 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,7 @@ in den.schema.user.includes = [ result.userDetect ]; den.classes.homeManager.description = "Home Manager user environment"; + # 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 599e7e344..ea8a1f77f 100644 --- a/modules/options.nix +++ b/modules/options.nix @@ -32,6 +32,28 @@ let type = lib.types.nullOr lib.types.raw; default = null; }; + options.parentPath = lib.mkOption { + description = '' + 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 0de6b8dcc..3d1fa245f 100644 --- a/nix/lib/aspects/fx/assemble-pipes.nix +++ b/nix/lib/aspects/fx/assemble-pipes.nix @@ -26,14 +26,38 @@ 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; + # 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 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 + && ( + let + a = builtins.functionArgs val; + in + a ? config || readsParentArg a + ); # 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 a parent-config arg. Resolved eagerly via scope context. + isPipelineParametric = + val: + builtins.isFunction val + && ( + let + a = builtins.functionArgs val; + in + !(a ? config) && !(readsParentArg a) + ); # Resolve a local pipeline-parametric value eagerly using scope context. # These are quirk values like `{ host, ... }: { addr = host.addr; }` that @@ -66,27 +90,104 @@ 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 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; + __producerClass = producer.class 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 + name) for a scope, read from pipeline state. The class + # selects the producer's config-resolution route via den.classes..parentPath. + producerOf = + scopeEntityClass: scopeContexts: sid: + let + ctx = scopeContexts.${sid} or { }; + in + { + class = scopeEntityClass.${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), 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, + scopeContexts, + scopeParent ? { }, + 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 = { }; + owner = { }; + inherit parentArg; + } + else if hostConfigs ? ${scopeId} then + { + config = hostConfigs.${scopeId}; + owner = hostConfigs.${scopeId}; + inherit parentArg; + } + else + let + # Nearest enclosing scope that owns a config. + findOwner = + sid: + if sid == null then + null + else if hostConfigs ? ${sid} then + sid + else + 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) { } 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, for a nested producer, the owner + # config under the class's parentArg. Returns a list (auto-flattens lists). 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 +201,14 @@ 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 { }; + config = pc.config; + } + // lib.optionalAttrs (pc.parentArg != null) { ${pc.parentArg} = pc.owner; } + // { inherit lib; } ); @@ -126,8 +231,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; }). @@ -292,6 +397,8 @@ let scopeParent, scopeEntityKind ? { }, scopedClassImports, + allExposed ? { }, + scopeEntityClass ? { }, currentScopeId, pipeName, hostConfigs ? null, @@ -315,6 +422,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 + scopeEntityClass + ; + }; # Resolve a list of matching scopes into collected values, each tagged with # its SOURCE scope id (not currentScopeId). collectTagged = @@ -324,9 +439,14 @@ 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 + # 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 +531,8 @@ let scopeParent, scopeEntityKind ? { }, scopedClassImports, + allExposed ? { }, + scopeEntityClass ? { }, currentScopeId, pipeName, hostConfigs ? null, @@ -432,6 +554,8 @@ let scopeParent scopeEntityKind scopedClassImports + allExposed + scopeEntityClass currentScopeId pipeName hostConfigs @@ -448,6 +572,8 @@ let scopeParent, scopeEntityKind ? { }, scopedClassImports, + allExposed ? { }, + scopeEntityClass ? { }, hostConfigs ? null, }: pipeName: scopeId: baseValues: effects: @@ -470,6 +596,8 @@ let scopeParent scopeEntityKind scopedClassImports + allExposed + scopeEntityClass hostConfigs ; currentScopeId = scopeId; @@ -492,6 +620,8 @@ let scopeParent, scopeEntityKind ? { }, scopedClassImports, + allExposed ? { }, + scopeEntityClass ? { }, currentScopeId, hostConfigs ? null, }: @@ -507,6 +637,8 @@ let scopeContexts scopeParent scopedClassImports + allExposed + scopeEntityClass currentScopeId hostConfigs ; @@ -533,6 +665,37 @@ 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; + + # 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 = @@ -541,6 +704,7 @@ let scopedClassImports, scopedPipeEffects, scopeParent, + scopeEntityClass ? { }, }: let allScopeIds = builtins.attrNames scopeContexts; @@ -562,23 +726,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 @@ -600,7 +748,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 scopeEntityClass 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 [ ]; @@ -631,6 +781,82 @@ 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 — 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 ? { }, + scopeEntityClass ? { }, + hostConfigs ? null, + }: + let + allScopeIds = builtins.attrNames scopeContexts; + producerConfigFor = producerConfigs { + inherit + hostConfigs + scopeContexts + scopeParent + scopeEntityClass + ; + }; + perBroadcaster = + sourceId: + let + scopeEffects = scopedPipeEffects.${sourceId} or [ ]; + broadcastEffects = dedupEffectsByPolicy (builtins.filter hasBroadcastStage scopeEffects); + 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'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; + 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, @@ -638,6 +864,7 @@ let scopedPipeEffects ? { }, scopeParent ? { }, scopeEntityKind ? { }, + scopeEntityClass ? { }, hostConfigs ? null, }: if pipeNames == [ ] then @@ -651,17 +878,34 @@ let scopedClassImports scopedPipeEffects scopeParent + scopeEntityClass + ; + }; + + # Pass 1b: Distribute broadcast data laterally (push, fleet-wide). + allBroadcast = collectAllBroadcast { + inherit + scopeContexts + scopedClassImports + scopedPipeEffects + scopeParent + scopeEntityKind + scopeEntityClass + 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`. @@ -694,9 +938,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 scopeEntityClass scopeContexts scopeId; + markedBase = markConfigThunks producer resolvedBase; exposedValues = exposedForScope.${pn} or [ ]; - markedExposed = markConfigThunks exposedValues; + markedExposed = markConfigThunks producer exposedValues; in markedBase ++ markedExposed; @@ -741,6 +988,8 @@ let scopeParent scopeEntityKind scopedClassImports + allExposed + scopeEntityClass hostConfigs ; currentScopeId = scopeId; @@ -761,11 +1010,18 @@ let scopeParent scopeEntityKind scopedClassImports + allExposed + scopeEntityClass 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 +1072,8 @@ let scopeParent scopeEntityKind scopedClassImports + allExposed + scopeEntityClass hostConfigs ; currentScopeId = scopeId; @@ -832,6 +1090,8 @@ let scopeParent 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 a3cba1043..edd86a084 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,91 @@ 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 for the + # same member resolves against `config` directly (standalone-safe). + consumerName = ctx.user.name or ctx.home.name or 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, + # 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 + ); + markerNeedsOwner = + m: + let + pa = classParentArg (m.__producerClass or null); + in + (classParentPath (m.__producerClass or null)) == null + || (m.__producerName or null) != consumerName + || (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 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; } else remainingArgs; + if hasConfigThunks then + remainingArgs + // { + config = true; + } + // lib.optionalAttrs (needsOwner && consumerParentArg != null) { ${consumerParentArg} = 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 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: values: + config: owner: values: + let + ownerCfg = if consumerNested then owner 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; }); + pcls = v.__producerClass or null; + pPath = classParentPath pcls; + pArg = classParentArg pcls; + producerConfig = + if pcls == null then + config + else if pPath == null then + ownerCfg + else if consumerNested && pcls == class && (v.__producerName or null) == consumerName then + config + else + lib.attrByPath (pPath v.__producerName) { } ownerCfg; + result = v.__fn ( + ctxArgs + // { + config = producerConfig; + } + // lib.optionalAttrs (pArg != null) { ${pArg} = ownerCfg; } + // { + inherit lib; + } + ); in if builtins.isList result then result else [ result ] else @@ -186,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 { }) v + resolveMarkers (moduleArgs.config or { }) ownerCfg v else v ) denWinsDen @@ -218,9 +292,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/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; 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/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/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-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..e6fd3c5ee --- /dev/null +++ b/templates/ci/modules/public-api/pipe-broadcast.nix @@ -0,0 +1,500 @@ +# 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"; + }; + } + ); + + # 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"; + } + ); + }; +} 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"; + } + ); }; }