diff --git a/nix/lib/aspects/fx/assemble-pipes.nix b/nix/lib/aspects/fx/assemble-pipes.nix index 3d1fa245..108f03ef 100644 --- a/nix/lib/aspects/fx/assemble-pipes.nix +++ b/nix/lib/aspects/fx/assemble-pipes.nix @@ -34,30 +34,23 @@ let ); 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. + # Detect config-dependent thunks: functions taking arguments not provided by the + # current evaluation scope context (e.g., config, pkgs, inputs, system). These + # are deferred to the NixOS/Home-Manager module system via __configThunk. isConfigDependent = - val: + scopeCtx: val: builtins.isFunction val && ( let a = builtins.functionArgs val; + allowedKeys = [ "lib" ] ++ builtins.attrNames scopeCtx; in - a ? config || readsParentArg a + builtins.any (k: !(builtins.elem k allowedKeys)) (builtins.attrNames a) ); # Pipeline-parametric values require pipeline context args (host, user, etc.) # 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) - ); + isPipelineParametric = scopeCtx: val: builtins.isFunction val && !(isConfigDependent scopeCtx val); # Resolve a local pipeline-parametric value eagerly using scope context. # These are quirk values like `{ host, ... }: { addr = host.addr; }` that @@ -68,7 +61,7 @@ let # providing the missing args (e.g., perSystem CRD build pipeline provides pkgs). resolveLocalParametric = scopeCtx: val: - if isPipelineParametric val then + if isPipelineParametric scopeCtx val then let thunkArgs = builtins.functionArgs val; requiredArgs = builtins.filter (k: !(thunkArgs.${k} or false) && k != "lib") ( @@ -94,20 +87,20 @@ let # 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; - __producerClass = producer.class or null; - __producerName = producer.name or null; - } - else - v; - - # Mark all config-dependent entries in a value list with their producer. - markConfigThunks = producer: map (markConfigThunk producer); + markConfigThunks = + scopeCtx: producer: values: + builtins.map ( + v: + if isConfigDependent scopeCtx v then + { + __configThunk = true; + __fn = v; + __producerClass = producer.class or null; + __producerName = producer.name or null; + } + else + v + ) values; # Producer tag (class + name) for a scope, read from pipeline state. The class # selects the producer's config-resolution route via den.classes..parentPath. @@ -188,7 +181,10 @@ let # config under the class's parentArg. Returns a list (auto-flattens lists). resolveEntry = hostConfigs: producerConfigFor: scopeContexts: sourceScopeId: entry: - if isConfigDependent entry then + let + scopeCtx = scopeContexts.${sourceScopeId} or { }; + in + if isConfigDependent scopeCtx entry then if hostConfigs == null then # No host configs on this crossing path: defer the config-dependent emit. # The local evalModules fixpoint resolves it (via __configThunk). Collected @@ -214,7 +210,7 @@ let ); in if builtins.isList result then result else [ result ] - else if isPipelineParametric entry then + else if isPipelineParametric scopeCtx entry then let thunkArgs = builtins.functionArgs entry; scopeCtx = scopeContexts.${sourceScopeId} or { }; @@ -748,7 +744,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 scopeEntityClass scopeContexts scopeId) ( + resolvedBase = markConfigThunks scopeCtx (producerOf scopeEntityClass scopeContexts scopeId) ( builtins.concatMap (resolveLocalParametric scopeCtx) baseValues ); # Child-exposed data is already concrete — each child resolved its @@ -867,14 +863,32 @@ let scopeEntityClass ? { }, hostConfigs ? null, }: + let + # Inherit parent scope contexts recursively + enrichedScopeContexts = lib.genAttrs (builtins.attrNames scopeContexts) ( + scopeId: + let + ownCtx = scopeContexts.${scopeId} or { }; + pid = scopeParent.${scopeId} or null; + in + if pid == null || pid == scopeId then + ownCtx + else + let + parentCtx = enrichedScopeContexts.${pid} or { }; + inherited = lib.filterAttrs (k: _: !(ownCtx ? ${k})) parentCtx; + in + ownCtx // inherited + ); + in if pipeNames == [ ] then - scopeContexts + enrichedScopeContexts else let # Pass 1: Collect all exposed data bottom-up. allExposed = collectAllExposed { + scopeContexts = enrichedScopeContexts; inherit - scopeContexts scopedClassImports scopedPipeEffects scopeParent @@ -884,8 +898,8 @@ let # Pass 1b: Distribute broadcast data laterally (push, fleet-wide). allBroadcast = collectAllBroadcast { + scopeContexts = enrichedScopeContexts; inherit - scopeContexts scopedClassImports scopedPipeEffects scopeParent @@ -940,10 +954,10 @@ 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 scopeEntityClass scopeContexts scopeId; - markedBase = markConfigThunks producer resolvedBase; + producer = producerOf scopeEntityClass enrichedScopeContexts scopeId; + markedBase = markConfigThunks scopeCtx producer resolvedBase; exposedValues = exposedForScope.${pn} or [ ]; - markedExposed = markConfigThunks producer exposedValues; + markedExposed = markConfigThunks scopeCtx producer exposedValues; in markedBase ++ markedExposed; @@ -1135,7 +1149,7 @@ let // pipeData // lib.optionalAttrs hasTargeted { __pipeTargeted = pipeTargeted; } // lib.optionalAttrs hasConfigThunks { __pipeConfigThunks = pipeConfigThunks; } - ) scopeContexts; + ) enrichedScopeContexts; in assembled; in diff --git a/nix/lib/aspects/fx/class-module.nix b/nix/lib/aspects/fx/class-module.nix index edd86a08..a7a0d71f 100644 --- a/nix/lib/aspects/fx/class-module.nix +++ b/nix/lib/aspects/fx/class-module.nix @@ -181,9 +181,26 @@ let || (pa != null && (builtins.functionArgs (m.__fn or (_: { }))) ? ${pa}); needsOwner = consumerNested && hasConfigThunks && builtins.any markerNeedsOwner allMarkers; + # Extract arguments requested by config thunks that cannot be fulfilled from context. + thunkArgs = lib.foldl' ( + acc: m: + let + args = builtins.functionArgs (m.__fn or (_: { })); + pcls = m.__producerClass or null; + pArg = classParentArg pcls; + filteredArgs = removeAttrs args ( + [ "config" ] + ++ lib.optional (pArg != null) pArg + ++ builtins.filter (k: ctx ? ${k}) (builtins.attrNames args) + ); + in + acc // filteredArgs + ) { } 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. + # We also need any specialArgs requested by the thunks themselves. effectiveRemainingArgs = if hasConfigThunks then remainingArgs @@ -191,6 +208,7 @@ let config = true; } // lib.optionalAttrs (needsOwner && consumerParentArg != null) { ${consumerParentArg} = true; } + // thunkArgs else remainingArgs; @@ -199,7 +217,7 @@ let # (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: owner: values: + moduleArgs: config: owner: values: let ownerCfg = if consumerNested then owner else config; in @@ -223,18 +241,37 @@ let config else lib.attrByPath (pPath v.__producerName) { } ownerCfg; - result = v.__fn ( - ctxArgs - // { - config = producerConfig; - } - // lib.optionalAttrs (pArg != null) { ${pArg} = ownerCfg; } - // { - inherit lib; - } - ); + # Defer evaluation if cross-host config is required but not yet available. + # Only defer if the consuming module system exposes its own identity + # (`config.identity`) AND it differs from the producer — i.e. the producer + # config lives on another host and cannot be resolved here. Otherwise + # (single-host, e.g. Nixidy) evaluate eagerly. + # + # NOTE: no consumer wires `config.identity` yet, so this guard is currently + # INERT (the eager branch always runs). It is the forward hook for the + # cross-host case; wiring + a covering fixture is a follow-up. See PR #625. in - if builtins.isList result then result else [ result ] + if + v.__producerName != null + && (ownerCfg.identity or null) != null + && v.__producerName != ownerCfg.identity + then + [ v ] + else + let + result = v.__fn ( + ctxArgs + // moduleArgs + // { + config = producerConfig; + } + // lib.optionalAttrs (pArg != null) { ${pArg} = ownerCfg; } + // { + inherit lib; + } + ); + in + if builtins.isList result then result else [ result ] else [ v ] ) values; @@ -265,7 +302,7 @@ let lib.mapAttrs ( k: v: if builtins.elem k denArgsWithThunks && builtins.isList v then - resolveMarkers (moduleArgs.config or { }) ownerCfg v + resolveMarkers moduleArgs (moduleArgs.config or { }) ownerCfg v else v ) denWinsDen @@ -277,7 +314,7 @@ let config = true; }; validator = mkCollisionValidator policy denArgNames; - advertisedArgs = effectiveRemainingArgs // lib.genAttrs denArgNames (_: true); + advertisedArgs = effectiveRemainingArgs // lib.genAttrs classWinsNames (_: true); in { module = lib.setFunctionArgs wrapper advertisedArgs; diff --git a/nix/lib/aspects/fx/handlers/class-collector.nix b/nix/lib/aspects/fx/handlers/class-collector.nix index 20106d1e..e98b4b30 100644 --- a/nix/lib/aspects/fx/handlers/class-collector.nix +++ b/nix/lib/aspects/fx/handlers/class-collector.nix @@ -44,18 +44,26 @@ let ${param.class} = (scopeImportData.${param.class} or [ ]) ++ [ mod ]; }; }; + updatedEmittedLocs = emittedLocs // { + ${scope} = scopeLocs // { + ${loc} = true; + }; + }; + # These two maps are threaded as `_: value` closures so the effect loop's state + # deepSeq cannot reach the module bodies inside them (den's lazy-state discipline). + # The shield is total, so each emit-class layered a fresh lazy `prev // { ... }`; + # forcing the final map then chained through every prior closure — depth ∝ emit + # count, a C-stack overflow on large fleets. Force just the TOP-LEVEL spine (the + # scope key set — bounded by fleet size, never the module lists) at each step so + # the closure captures an already-evaluated head: the chain collapses to O(1) depth + # per force while the bodies stay unforced. `seq (attrNames m)` is O(scopes), so the + # accumulation stays linear. + forceHead = m: builtins.seq (builtins.attrNames m) m; in state // { - scopedClassImports = _: updatedImports; - scopedEmittedLocs = - _: - emittedLocs - // { - ${scope} = scopeLocs // { - ${loc} = true; - }; - }; + scopedClassImports = builtins.seq (forceHead updatedImports) (_: updatedImports); + scopedEmittedLocs = builtins.seq (forceHead updatedEmittedLocs) (_: updatedEmittedLocs); }; }; }; diff --git a/nix/lib/aspects/fx/handlers/push-scope.nix b/nix/lib/aspects/fx/handlers/push-scope.nix index d0572a0d..16b7a3cb 100644 --- a/nix/lib/aspects/fx/handlers/push-scope.nix +++ b/nix/lib/aspects/fx/handlers/push-scope.nix @@ -36,7 +36,7 @@ let prevIsolated = (state.scopeIsolated or (_: { })) null; prevScopeByEntity = (state.scopeByEntity or (_: { })) null; updatedContexts = prevContexts // { - ${newScopeId} = scopedCtx; + ${newScopeId} = prevContexts.${newScopeId} or scopedCtx; }; # Spec→scope link: record the entity scope this push created, keyed by # (parentScope, id_hash). The instantiate spec — registered at the same diff --git a/nix/lib/aspects/fx/handlers/scope-widen.nix b/nix/lib/aspects/fx/handlers/scope-widen.nix index f02f52e3..f2e2df75 100644 --- a/nix/lib/aspects/fx/handlers/scope-widen.nix +++ b/nix/lib/aspects/fx/handlers/scope-widen.nix @@ -14,6 +14,9 @@ in { param, state }: let ctx = param.ctx; + updated = (state.scopeContexts null) // { + ${state.currentScope} = (state.scopeContexts null).${state.currentScope} or { } // ctx; + }; in { resume = fx.bind (fx.send "drain" ctx) ( @@ -31,7 +34,9 @@ in ) ) (fx.pure null) satisfiable ); - inherit state; + state = state // { + scopeContexts = _: updated; + }; }; }; } diff --git a/nix/lib/aspects/fx/resolve.nix b/nix/lib/aspects/fx/resolve.nix index d6e1801d..0d5a4621 100644 --- a/nix/lib/aspects/fx/resolve.nix +++ b/nix/lib/aspects/fx/resolve.nix @@ -19,7 +19,7 @@ let inherit (import ./edges/materialize.nix { inherit lib den; }) assembleSubtree; inherit (import ./edges/pi.nix { inherit lib; }) mkStaticPi; inherit (import ./edges/instantiate-edges.nix { inherit lib den; }) mkInstantiateEdges; - inherit (import ./edges/edge.nix { inherit lib; }) scopeName; + inherit (import ./edges/edge.nix { inherit lib; }) scopeName edgeSortKey; inherit (import ./edges/provides.nix { inherit lib den; }) applyProvidesEdges dedupProvides @@ -387,10 +387,18 @@ let # (phase4 + the B′ hostConfigs build) use the link. scopeByEntity = (result.state.scopeByEntity or (_: { })) null; - # Scan raw pipe values for config-dependent thunks (functions taking - # { config, ... }). If none exist, hostConfigs stays null and - # assemblePipes skips cross-host instantiation entirely. - isConfigDependent = val: builtins.isFunction val && (builtins.functionArgs val) ? config; + # Scan raw pipe values for config-dependent thunks. If none exist, hostConfigs + # stays null and assemblePipes skips cross-host instantiation entirely. + isConfigDependent = + scopeCtx: val: + builtins.isFunction val + && ( + let + a = builtins.functionArgs val; + allowedKeys = [ "lib" ] ++ builtins.attrNames scopeCtx; + in + builtins.any (k: !(builtins.elem k allowedKeys)) (builtins.attrNames a) + ); hasAnyConfigThunk = let # Values may be lists of entries, raw functions, or pipe entry @@ -400,9 +408,12 @@ let if builtins.isList v then builtins.any checkVal v else if builtins.isAttrs v && v ? module then - isConfigDependent v.module + isConfigDependent (v.ctx or { }) v.module else - isConfigDependent v; + # Bare-function fallback (no ctx): treats any non-`lib` arg as + # config-dependent. Only gates whether hostConfigs is built (a + # conservative over-trigger is perf-only, never a correctness change). + isConfigDependent { } v; in builtins.any (scopeImports: builtins.any checkVal (lib.attrValues scopeImports)) ( lib.attrValues scopedClassImportsRaw @@ -473,14 +484,17 @@ let # 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; + tempAugmentedNoCfg = assemblePipes { + inherit scopeContexts scopeEntityKind; scopeEntityClass = scopeEntityClassMap; - scopedClassImports = scopedClassImportsRaw; + hostConfigs = null; + scopedClassImports = importsForPipes; scopedPipeEffects = result.state.scopedPipeEffects null; inherit scopeParent; }; + drainedForHostConfigs = (mkDrained tempAugmentedNoCfg).classImports; + # §A #8/#2/#7 B′ raw-context ACCIDENT fix (option b: augmented-context build). # # hostConfigs (re-entry B′) builds each peer host's full config for cross- @@ -509,14 +523,29 @@ let inherit scopeContexts scopeEntityKind; scopeEntityClass = scopeEntityClassMap; hostConfigs = null; - scopedClassImports = scopedClassImportsRaw; + scopedClassImports = drainedForHostConfigs; + scopedPipeEffects = result.state.scopedPipeEffects null; + inherit scopeParent; + }; + + tempAugmented = assemblePipes { + inherit scopeContexts hostConfigs scopeEntityKind; + scopeEntityClass = scopeEntityClassMap; + scopedClassImports = importsForPipes; + scopedPipeEffects = result.state.scopedPipeEffects null; + inherit scopeParent; + }; + + drained = mkDrained tempAugmented; + drainedClassImportsRaw = drained.classImports; + + augmentedScopeContexts = assemblePipes { + inherit scopeContexts hostConfigs scopeEntityKind; + scopeEntityClass = scopeEntityClassMap; + scopedClassImports = drainedClassImportsRaw; scopedPipeEffects = result.state.scopedPipeEffects null; inherit scopeParent; }; - # B′ peer-config drain: only its class-imports map is consumed (the cross- - # host config build); its spawn edges are NOT collected — B′'s delivery is - # covered by the per-host mkInstantiateEdges in the unifiedEdges union. - drainedForHostConfigs = (mkDrained augmentedScopeContextsNoCfg).classImports; # Parent-state bundle for node spawns. Uses the RAW scopeContexts and # scopedClassImports (not the augmented/drained maps): the spawned node @@ -551,6 +580,95 @@ let selfRef = spawnNode; } mkPipeline parentState; + # Materialize the deferred node spawns (policy.spawn) ONCE over RAW parent + # state — shared by both the pre-assembly quirk surfacing (importsForPipes, + # below) and mkDrained's class-content fold. `spawnNode` reads only + # `parentState` (raw contexts/imports/parent/kind/effects/routes) and runs its + # internal assembly with hostConfigs=null, so this binding never touches the + # augmented/hostConfigs maps and is invariant across mkDrained's + # `augmentedContexts` param. Per requesting scope it carries the resolved + # `classes` and the per-class spawn return ({ imports; edges; quirkEmits }). + allHomeNodes = (result.state.scopedSpawns or (_: { })) null; + homeNodeSpawns = builtins.foldl' ( + acc: scopeId: + let + sctx = scopeContexts.${scopeId} or { }; + ownKind = scopeEntityKind.${scopeId} or null; + ownRecord = if ownKind == null then null else sctx.${ownKind} or null; + from = scopeParent.${scopeId} or null; + parentKind = if from == null then null else scopeEntityKind.${from} or null; + parentRecord = + if parentKind == null then null else (scopeContexts.${from} or { }).${parentKind} or null; + specs = allHomeNodes.${scopeId}; + defaultClasses = if ownRecord == null then [ ] else ownRecord.classes or [ ]; + classes = lib.unique ( + lib.concatMap (s: if s.classes != null then s.classes else defaultClasses) specs + ); + in + if parentRecord == null || ownRecord == null then + acc + else + acc + // { + ${scopeId} = { + inherit classes; + spawned = lib.genAttrs classes ( + cls: + spawnNode { + inherit from; + class = cls; + aspect = parentRecord.aspect; + bindings = { + ${ownKind} = ownRecord; + }; + } + ); + }; + } + ) { } (builtins.attrNames allHomeNodes); + + # THE FIX: a projected aspect is processed in BOTH the requesting scope and + # its spawned node, so its non-host-bound quirk emits must also materialize at + # the requesting scope — else a pipe policy there (broadcast/collect/expose/ + # local) reads `[]`, because every pipe reader takes the source straight from + # the imports map and pipe assembly (assemblePipes) runs PRE-drain while the + # spawn materializes post-drain. Surface the spawn roots' `quirkEmits` into a + # SEPARATE map layered over the raw imports. It must NOT mutate + # `scopedClassImportsRaw` itself: `parentState` reads that raw map, so folding + # the quirk there would make the spawn's own internal assembly re-read it (a + # cycle + internal double-count). The spawn root is absent from the pre-drain + # scope universe, so the quirk lands EXACTLY ONCE at the requesting scope — + # as if that scope had included the aspect directly. + importsForPipes = builtins.foldl' ( + acc: scopeId: + let + inherit (homeNodeSpawns.${scopeId}) classes spawned; + quirkNames = lib.unique ( + lib.concatMap (cls: lib.attrNames (spawned.${cls}.quirkEmits or { })) classes + ); + base = acc.${scopeId} or { }; + # A quirk key is classified class-agnostically, so EVERY class's spawn + # walk yields the identical emit set — surface it from the FIRST class + # that carries it, NOT concatMap'd across classes (which would land the + # same emit once per spawned class, a multi-class double-count). + quirkEmitFor = + qn: + let + firstCls = lib.findFirst ( + cls: ((spawned.${cls}.quirkEmits or { }).${qn} or [ ]) != [ ] + ) null classes; + in + lib.optionals (firstCls != null) ((spawned.${firstCls}.quirkEmits or { }).${qn} or [ ]); + in + if quirkNames == [ ] then + acc + else + acc + // { + ${scopeId} = base // lib.genAttrs quirkNames (qn: (base.${qn} or [ ]) ++ quirkEmitFor qn); + } + ) scopedClassImportsRaw (builtins.attrNames homeNodeSpawns); + # Post-assembly drain: resolve deferred includes. Parameterized by the # augmented contexts the deferred-include resolution reads, so the SAME # drain logic produces two maps with different cycle constraints: @@ -621,18 +739,23 @@ let k: let modules = unwrapContentValuesList child.${k}; + isPipe = den.quirks ? ${k}; in - map (module: { - __rawEntry = true; - class = k; - inherit module; - ctx = scopeCtx; - identity = child.name or ""; - aspectPolicy = child.meta.collisionPolicy or null; - globalPolicy = den.config.classModuleCollisionPolicy or "error"; - isContextDependent = false; - }) modules - ) classified.classKeys + map ( + module: + { + __rawEntry = true; + class = k; + inherit module; + ctx = scopeCtx; + identity = child.name or ""; + aspectPolicy = child.meta.collisionPolicy or null; + globalPolicy = den.config.classModuleCollisionPolicy or "error"; + isContextDependent = false; + } + // lib.optionalAttrs isPipe { __isPipeEntry = true; } + ) modules + ) (classified.classKeys ++ classified.pipeKeys) ) drainable; in builtins.foldl' ( @@ -644,7 +767,7 @@ let }; } ) accImports newEntries - ) scopedClassImportsRaw (builtins.attrNames allDeferred); + ) importsForPipes (builtins.attrNames allDeferred); # Materialize deferred node spawn markers (policy.spawn) over the # parent scope-tree state, kind-generically. Each marker lives at some @@ -661,72 +784,41 @@ let # scope's ancestor-bound `host`. Default classes fall back to the own # record's `classes` (e.g. user type defaults `["homeManager"]`); in # practice batteries pass `spec.classes` explicitly so this is unused. - allHomeNodes = (result.state.scopedSpawns or (_: { })) null; in - # Accumulate BOTH the class-imports map AND the spawn nodes' SURFACED edge - # sets. Each spawnNode {…} returns { imports; edges; }: `.imports` folds - # into the class buckets as before; `.edges` is the spawn's real delivered - # edge set (its default fold + provides + re-applied routes), collected so - # the host-own invocation can feed unifiedEdges (the oracle's rewalk arm - # undercounts these). The B′ invocation discards spawnEdges (its delivery - # is covered by the per-host mkInstantiateEdges, see call sites below). + # Fold the (hoisted) `homeNodeSpawns` into the drain: each spawn's + # `.imports` adds to the requesting scope's class buckets (so BOTH phase1 + # and the phase4 per-host re-walk deliver the projected class content) and + # `.edges` is collected for unifiedEdges (the host-own invocation feeds it; + # the B′ invocation discards it). The materialization is computed ONCE in + # `homeNodeSpawns` over raw state (invariant of `augmentedContexts`); the + # non-host-bound quirk emits it also carries are surfaced at the requesting + # scope by `importsForPipes` (pre-assembly), not here. lib.foldl' ( acc: scopeId: let - sctx = scopeContexts.${scopeId} or { }; - ownKind = scopeEntityKind.${scopeId} or null; - ownRecord = if ownKind == null then null else sctx.${ownKind} or null; - from = scopeParent.${scopeId} or null; - parentKind = if from == null then null else scopeEntityKind.${from} or null; - parentRecord = - if parentKind == null then null else (scopeContexts.${from} or { }).${parentKind} or null; - specs = allHomeNodes.${scopeId}; - defaultClasses = if ownRecord == null then [ ] else ownRecord.classes or [ ]; - classes = lib.unique ( - lib.concatMap (s: if s.classes != null then s.classes else defaultClasses) specs - ); + inherit (homeNodeSpawns.${scopeId}) classes spawned; in - if parentRecord == null || ownRecord == null then - acc - else - let - # Materialize each class once; capture the FULL spawn return so both - # `.imports` (class fold) and `.edges` (surfaced set) are available. - spawned = lib.genAttrs classes ( - cls: - spawnNode { - inherit from; - class = cls; - aspect = parentRecord.aspect; - bindings = { - ${ownKind} = ownRecord; - }; - } - ); - in - { - classImports = acc.classImports // { - ${scopeId} = - (acc.classImports.${scopeId} or { }) - // lib.genAttrs classes ( - cls: ((acc.classImports.${scopeId} or { }).${cls} or [ ]) ++ spawned.${cls}.imports - ); - }; - spawnEdges = acc.spawnEdges ++ lib.concatMap (cls: spawned.${cls}.edges) classes; - } + { + classImports = acc.classImports // { + ${scopeId} = + (acc.classImports.${scopeId} or { }) + // lib.genAttrs classes ( + cls: ((acc.classImports.${scopeId} or { }).${cls} or [ ]) ++ spawned.${cls}.imports + ); + }; + spawnEdges = acc.spawnEdges ++ lib.concatMap (cls: spawned.${cls}.edges) classes; + } ) { classImports = baseDrain; spawnEdges = [ ]; } - (builtins.attrNames allHomeNodes); - - # The host's OWN phase1–4 drain, over the hostConfigs-augmented contexts. - # Surfaces drained.classImports for phases + drained.spawnEdges for unifiedEdges. - drained = mkDrained augmentedScopeContexts; - drainedClassImportsRaw = drained.classImports; + (builtins.attrNames homeNodeSpawns); + # Phase 1 of the host's OWN drain: wrap the drained class imports per scope. + # `drained`/`drainedClassImportsRaw` are computed above (lines ~538-539), ahead + # of `augmentedScopeContexts`, to keep that build cycle-free. phase1 = wrapPerScope ctx augmentedScopeContexts drainedClassImportsRaw; # Production delivery (Task 17): one ordered-dispatch fold over the unified # provides+routes edge set, replacing the phase2 (provides) ∘ phase3 (routes) @@ -901,8 +993,16 @@ let scopeEntityClass = result.state.scopeEntityClass or (_: { }); spawnNodeFn = spawnNode; }; + # The B′ pass re-runs the FULL per-host projection, so when its scopes overlap + # the host-own pass it re-emits identical edges. `sortEdges` only sorts (it does + # NOT dedup), so that overlap would double the host-own folds. Keep only the B′ + # edges the host-own pass did NOT already produce (its cross-host delta); the + # overlap is inert, as intended. + perHostEdgeKeys = lib.genAttrs (map edgeSortKey perHostEdges) (_: true); bprimeEdges = lib.optionals (hostConfigs != null) ( - lib.concatMap (perHostEdgesFor bprimeArgBundle) allInstantiateSpecs + lib.filter (e: !(perHostEdgeKeys ? ${edgeSortKey e})) ( + lib.concatMap (perHostEdgesFor bprimeArgBundle) allInstantiateSpecs + ) ); # The PRODUCTION delivery-edge object (Task 18.2). The fold-ordered diff --git a/nix/lib/aspects/fx/spawn-node.nix b/nix/lib/aspects/fx/spawn-node.nix index be79702f..4c532fc0 100644 --- a/nix/lib/aspects/fx/spawn-node.nix +++ b/nix/lib/aspects/fx/spawn-node.nix @@ -192,5 +192,21 @@ in scopeEntityKind = parentState.scopeEntityKind // ((result.state.scopeEntityKind or (_: { })) null); ownProvides = result.state.scopedProvides null; allScopeIds = spawnAllScopeIds; + } + // { + # The aspect is processed in BOTH the requesting (user) scope and this + # spawned home node, so its quirks must materialize in both. Surface the + # NON-host-bound ones (host-bound quirks were stripped above and inherited + # from the host instead) across ALL scopes in the spawned subtree, so the + # caller (resolve.nix) can also fold them into the requesting scope's quirk + # buckets — letting a user-scope broadcast/collect/expose of a + # host-aspects-projected quirk behave as if the user included the aspect. + quirkEmits = + let + allEmits = lib.mapAttrsToList ( + sid: scopeClasses: lib.filterAttrs (k: v: (pipeNamesSet ? ${k}) && v != [ ]) scopeClasses + ) spawnedClassImports; + in + lib.zipAttrsWith (name: values: lib.concatLists values) allEmits; }; } diff --git a/templates/ci/modules/public-api/pipe-broadcast.nix b/templates/ci/modules/public-api/pipe-broadcast.nix index e6fd3c5e..ebb42189 100644 --- a/templates/ci/modules/public-api/pipe-broadcast.nix +++ b/templates/ci/modules/public-api/pipe-broadcast.nix @@ -496,5 +496,68 @@ expected = "alice"; } ); + + # REPRO (nix-config replicateHome → hub shortfall): a HOME-POOL quirk — + # emitted by a named aspect that also carries homeManager content and is + # consumed in homeManager — broadcast from the USER scope to a remote host. + # Identical in shape to test-broadcast-to-remote-host (which passes), except + # the quirk is home-pool. The remote host should receive the broadcast. + test-broadcast-home-pool-to-host = denTest ( + { + den, + igloo, + lib, + ... + }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.hosts.x86_64-linux.iceberg.users.alice = { }; + + den.quirks.replicateHome.description = "home dirs to replicate"; + + # claude-like: a HOST aspect that emits replicateHome AND consumes it in + # homeManager. nix-config projects such host aspects onto the user's home + # via the host-aspects battery (a deferred node SPAWN), so replicateHome + # lands in the spawned home node — NOT the user-entity scope. + den.aspects.claude = { + replicateHome = [ { directories = [ ".claude/memory" ]; } ]; + homeManager = + { replicateHome, ... }: + { + home.sessionVariables.DIRS = lib.concatStringsSep "," ( + lib.concatMap (e: e.directories or [ ]) replicateHome + ); + }; + }; + # iceberg (host) carries claude; alice projects it onto her home via the + # host-aspects spawn — exactly nix-config's sini.includes = [host-aspects]. + den.aspects.iceberg.includes = [ den.aspects.claude ]; + den.aspects.alice.includes = [ den.batteries.host-aspects ]; + + # USER scope: broadcast replicateHome to all hosts. + den.policies.broadcast-rh = + { user, ... }: + let + inherit (den.lib.policy) pipe; + in + [ (pipe.from "replicateHome" [ (pipe.broadcast ({ host, ... }: true)) ]) ]; + den.schema.user.includes = [ den.policies.broadcast-rh ]; + + # igloo (remote relative to alice) consumes the broadcast at host scope. + den.aspects.igloo.includes = [ den.aspects.rh-consumer ]; + den.aspects.rh-consumer = { + nixos = + { replicateHome, lib, ... }: + { + networking.domain = lib.concatStringsSep "," ( + lib.concatMap (e: e.directories or [ ]) replicateHome + ); + }; + }; + + expr = igloo.networking.domain; + expected = ".claude/memory"; + } + ); }; } diff --git a/templates/ci/modules/public-api/pipe-projection.nix b/templates/ci/modules/public-api/pipe-projection.nix new file mode 100644 index 00000000..c801430b --- /dev/null +++ b/templates/ci/modules/public-api/pipe-projection.nix @@ -0,0 +1,337 @@ +# Guard tests for spawn-projected quirk surfacing (fix fd2c2a78). +# +# A deferred `spawn` policy (the host-aspects battery) projects a quirk-bearing +# host aspect onto a user's home. The aspect's static quirk emit is surfaced at +# the REQUESTING (user) scope so a pipe policy there behaves "as if the user +# included the aspect directly". These tests pin the surfacing's exactly-once +# semantics across every pipe reader (collect / expose / local), the genuine +# double-inclusion count, the multi-class union, and the host-bound boundary. +# +# host-emit interaction: because the projection projects the HOST's aspect tree, +# a host that carries the quirk-bearing aspect ALSO emits the quirk at its own +# (host) scope — separate from the user projection. The `({ user, ... }: true)` +# collects below select user scopes only (the entity-kind depth filter rejects +# host scopes), isolating the user-scope surfacing from the host emit. +{ denTest, lib, ... }: +let + # "|" — count makes a duplicate surfacing observable + # (a single ".claude/memory" surfaced twice reads "2|.claude/memory,.claude/memory"). + dirsStr = + rh: + "${toString (builtins.length rh)}|${ + lib.concatStringsSep "," (lib.sort (a: b: a < b) (builtins.concatMap (e: e.directories or [ ]) rh)) + }"; +in +{ + flake.tests.pipe-projection = { + + # 1. A user-scope collectAll of the projected quirk sees it EXACTLY ONCE — + # the spawn root is absent from the pre-drain scope universe, so there is no + # spawn-root duplicate. The pull dual of the broadcast repro. + test-collect-projected-quirk-once = denTest ( + { + den, + tuxHm, + ... + }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.hosts.x86_64-linux.iceberg.users.alice = { }; + + den.quirks.replicateHome.description = "home dirs to replicate"; + + den.aspects.claude.replicateHome = [ { directories = [ ".claude/memory" ]; } ]; + den.aspects.iceberg.includes = [ den.aspects.claude ]; + den.aspects.alice.includes = [ den.batteries.host-aspects ]; + + # USER scope: collect replicateHome from every OTHER user scope. + den.policies.collect-rh = + { user, ... }: + let + inherit (den.lib.policy) pipe; + in + [ (pipe.from "replicateHome" [ (pipe.collectAll ({ user, ... }: true)) ]) ]; + den.schema.user.includes = [ den.policies.collect-rh ]; + + # tux: pure collector (no own projection) — sees alice's projected quirk once. + den.aspects.tux.homeManager = + { replicateHome, ... }: + { + home.sessionVariables.DIRS = dirsStr replicateHome; + }; + + expr = tuxHm.home.sessionVariables.DIRS; + expected = "1|.claude/memory"; + } + ); + + # 2. Genuine double inclusion: iceberg HOST-includes claude (one host-scope + # emit) AND alice PROJECTS it (one user-scope emit). The host emit is seen + # once at the host; the user projection is collected once at a peer user — + # the host emit does NOT leak into the user collect (kind filter) and the + # surfacing is not doubled. Exactly twice in the system, one per inclusion. + test-host-include-plus-projection-counted-once-each = denTest ( + { + den, + tuxHm, + iceberg, + ... + }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.hosts.x86_64-linux.iceberg.users.alice = { }; + + den.quirks.replicateHome.description = "home dirs to replicate"; + + den.aspects.claude.replicateHome = [ { directories = [ ".claude/memory" ]; } ]; + den.aspects.rh-host-consumer.nixos = + { replicateHome, ... }: + { + networking.domain = dirsStr replicateHome; + }; + # iceberg host-includes claude (host emit) AND a host consumer. + den.aspects.iceberg.includes = [ + den.aspects.claude + den.aspects.rh-host-consumer + ]; + # alice projects claude onto her home (user emit). + den.aspects.alice.includes = [ den.batteries.host-aspects ]; + + den.policies.collect-rh = + { user, ... }: + let + inherit (den.lib.policy) pipe; + in + [ (pipe.from "replicateHome" [ (pipe.collectAll ({ user, ... }: true)) ]) ]; + den.schema.user.includes = [ den.policies.collect-rh ]; + + den.aspects.tux.homeManager = + { replicateHome, ... }: + { + home.sessionVariables.DIRS = dirsStr replicateHome; + }; + + expr = { + # tux collects alice's single user-scope projection — host emit excluded. + userCollected = tuxHm.home.sessionVariables.DIRS; + # iceberg's host scope carries the host inclusion once (no projection leak). + hostSeen = iceberg.networking.domain; + }; + expected = { + userCollected = "1|.claude/memory"; + hostSeen = "1|.claude/memory"; + }; + } + ); + + # 3. pipe.expose of the projected quirk routes it UP to the parent (host) + # scope. The host already emits its own copy (it includes claude), so it + # reads own(1) + exposed(1) = 2. Without the surfacing alice has nothing to + # expose and the host reads only its own (1). + test-expose-projected-quirk-reaches-parent = denTest ( + { + den, + iceberg, + ... + }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.hosts.x86_64-linux.iceberg.users.alice = { }; + + den.quirks.replicateHome.description = "home dirs to replicate"; + + den.aspects.claude.replicateHome = [ { directories = [ ".claude/memory" ]; } ]; + den.aspects.rh-host-consumer.nixos = + { replicateHome, ... }: + { + networking.domain = dirsStr replicateHome; + }; + den.aspects.iceberg.includes = [ + den.aspects.claude + den.aspects.rh-host-consumer + ]; + den.aspects.alice.includes = [ den.batteries.host-aspects ]; + + # USER scope: expose replicateHome up to the parent (host) scope. + den.policies.expose-rh = + { user, ... }: + let + inherit (den.lib.policy) pipe; + in + [ (pipe.from "replicateHome" [ pipe.expose ]) ]; + den.schema.user.includes = [ den.policies.expose-rh ]; + + expr = iceberg.networking.domain; + expected = "2|.claude/memory,.claude/memory"; + } + ); + + # 4. A consumer AT the requesting (user) scope reads the projected quirk + # locally — no collect/broadcast/expose — exercising the mkCombinedBase + # reader over the surfaced emit. alice binds replicateHome locally (surfaced), + # so she reads her own value, not the host's. + test-local-consume-projected-quirk = denTest ( + { + den, + iceberg, + ... + }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.hosts.x86_64-linux.iceberg.users.alice = { }; + + den.quirks.replicateHome.description = "home dirs to replicate"; + + # claude is emit-only here; alice carries the local consumer. + den.aspects.claude.replicateHome = [ { directories = [ ".claude/memory" ]; } ]; + den.aspects.iceberg.includes = [ den.aspects.claude ]; + den.aspects.alice = { + includes = [ den.batteries.host-aspects ]; + homeManager = + { replicateHome, ... }: + { + home.sessionVariables.DIRS = dirsStr replicateHome; + }; + }; + + expr = iceberg.home-manager.users.alice.home.sessionVariables.DIRS; + expected = "1|.claude/memory"; + } + ); + + # 5. Multi-class spawn: alice's classes drive the projection's spawn classes + # (host-aspects spawns `user.classes`). With >1 class, each class's walk picks + # up the (class-agnostic) quirk emit, but the surfacing must land it ONCE at + # the user scope — not once per class. Observed via a peer collect so the + # extra class's output is never forced. + test-multi-class-spawn-surfaces-once = denTest ( + { + den, + tuxHm, + ... + }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.hosts.x86_64-linux.iceberg.users.alice.classes = [ + "homeManager" + "extra" + ]; + + den.classes.extra.description = "second projected spawn class"; + den.quirks.replicateHome.description = "home dirs to replicate"; + + den.aspects.claude.replicateHome = [ { directories = [ ".claude/memory" ]; } ]; + den.aspects.iceberg.includes = [ den.aspects.claude ]; + den.aspects.alice.includes = [ den.batteries.host-aspects ]; + + den.policies.collect-rh = + { user, ... }: + let + inherit (den.lib.policy) pipe; + in + [ (pipe.from "replicateHome" [ (pipe.collectAll ({ user, ... }: true)) ]) ]; + den.schema.user.includes = [ den.policies.collect-rh ]; + + den.aspects.tux.homeManager = + { replicateHome, ... }: + { + home.sessionVariables.DIRS = dirsStr replicateHome; + }; + + expr = tuxHm.home.sessionVariables.DIRS; + expected = "1|.claude/memory"; + } + ); + + # 6. Host-bound boundary: when the projected quirk is ALSO bound by a + # host-level pipe policy, the spawn strips it (strippableNames) and it is NOT + # surfaced at the user scope — the user inherits the host's assembled value + # instead. The host collects a DISTINCT extra entry ("host-extra"); alice + # reading it proves inheritance, not the (suppressed) projected surfacing + # (which would yield just ".claude/memory"). + test-host-bound-projected-quirk-not-surfaced = denTest ( + { + den, + iceberg, + ... + }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.hosts.x86_64-linux.iceberg.users.alice = { }; + + den.quirks.replicateHome.description = "home dirs to replicate"; + + den.aspects.claude.replicateHome = [ { directories = [ ".claude/memory" ]; } ]; + den.aspects.host-extra.replicateHome = [ { directories = [ "host-extra" ]; } ]; + # host-level policy BINDS replicateHome at the host scope. + den.policies.host-collect = + { host, ... }: + let + inherit (den.lib.policy) pipe; + in + [ (pipe.from "replicateHome" [ (pipe.collectAll ({ host, ... }: true)) ]) ]; + den.aspects.iceberg.includes = [ + den.aspects.claude + den.aspects.host-extra + den.policies.host-collect + ]; + # alice projects claude — but replicateHome is host-bound, so the spawn + # strips it and it is not surfaced; alice inherits the host's value. + den.aspects.alice = { + includes = [ den.batteries.host-aspects ]; + homeManager = + { replicateHome, ... }: + { + home.sessionVariables.DIRS = dirsStr replicateHome; + }; + }; + + expr = iceberg.home-manager.users.alice.home.sessionVariables.DIRS; + expected = "2|.claude/memory,host-extra"; + } + ); + + # 7. Parametric per-user expansion: two users on one host each project the + # SAME parametric quirk aspect. The emit must resolve at EACH user's scope + # with THAT user (no collapse, no cross-bleed) — so a peer's collectAll sees + # both users' DISTINCT values, one per projecting user. + test-per-user-parametric-projection = denTest ( + { + den, + tuxHm, + ... + }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.hosts.x86_64-linux.iceberg.users.alice = { }; + den.hosts.x86_64-linux.iceberg.users.bob = { }; + + den.quirks.replicateHome.description = "home dirs to replicate"; + + # Parametric: the dir is keyed by the projecting user. + den.aspects.claude.replicateHome = { user, ... }: [ { directories = [ ".claude/${user.name}" ]; } ]; + den.aspects.iceberg.includes = [ den.aspects.claude ]; + den.aspects.alice.includes = [ den.batteries.host-aspects ]; + den.aspects.bob.includes = [ den.batteries.host-aspects ]; + + den.policies.collect-rh = + { user, ... }: + let + inherit (den.lib.policy) pipe; + in + [ (pipe.from "replicateHome" [ (pipe.collectAll ({ user, ... }: true)) ]) ]; + den.schema.user.includes = [ den.policies.collect-rh ]; + + # tux collects every other user's projected emit: alice's + bob's, distinct. + den.aspects.tux.homeManager = + { replicateHome, ... }: + { + home.sessionVariables.DIRS = dirsStr replicateHome; + }; + + expr = tuxHm.home.sessionVariables.DIRS; + expected = "2|.claude/alice,.claude/bob"; + } + ); + }; +} diff --git a/templates/ci/modules/public-api/pipes.nix b/templates/ci/modules/public-api/pipes.nix index 87fe5c4b..bf044977 100644 --- a/templates/ci/modules/public-api/pipes.nix +++ b/templates/ci/modules/public-api/pipes.nix @@ -223,10 +223,9 @@ } ); - # Parametric pipe values with unsatisfied required args pass through - # unresolved instead of crashing (e.g. quirk needing pkgs at a scope - # without pkgs). - test-pipe-unsatisfied-parametric-passthrough = denTest ( + # Parametric pipe values with unsatisfied required args are deferred as + # config thunks and correctly resolved when passed to a module system consumer. + test-pipe-parametric-resolved-in-consumer = denTest ( { den, igloo, ... }: { den.hosts.x86_64-linux.igloo.users.tux = { }; @@ -252,15 +251,18 @@ { build-info, ... }: { networking.hostName = - if builtins.length build-info == 1 && builtins.isFunction (builtins.head build-info) then - "passthrough" + let + head = builtins.head build-info; + in + if builtins.length build-info == 1 && builtins.isAttrs head && head ? name then + "resolved" else - "resolved"; + "failed"; }; }; expr = igloo.networking.hostName; - expected = "passthrough"; + expected = "resolved"; } ); };