From ff84f9e6ff9c932ec24f019dff916d1216151932 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Wed, 20 May 2026 16:02:15 -0700 Subject: [PATCH 001/101] feat: add gen-schema flake input to CI template --- templates/ci/flake.lock | 21 +++++++++++++++++++++ templates/ci/flake.nix | 3 +++ 2 files changed, 24 insertions(+) diff --git a/templates/ci/flake.lock b/templates/ci/flake.lock index 92708f12c..594e27ce9 100644 --- a/templates/ci/flake.lock +++ b/templates/ci/flake.lock @@ -35,6 +35,26 @@ "type": "github" } }, + "den-schema": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1779316549, + "narHash": "sha256-Ez1W0qTzMzyC+x2mjis5oh5L3q7cOMe/xJVdhSP6sJo=", + "owner": "sini", + "repo": "den-schema", + "rev": "1028d249a82d1184752c51168a0b397ea0bafa12", + "type": "github" + }, + "original": { + "owner": "sini", + "repo": "den-schema", + "type": "github" + } + }, "flake-parts": { "inputs": { "nixpkgs-lib": [ @@ -198,6 +218,7 @@ "inputs": { "darwin": "darwin", "den": "den", + "den-schema": "den-schema", "home-manager": "home-manager", "import-tree": "import-tree", "nix-effects": "nix-effects", diff --git a/templates/ci/flake.nix b/templates/ci/flake.nix index 8df911244..93becb746 100644 --- a/templates/ci/flake.nix +++ b/templates/ci/flake.nix @@ -29,5 +29,8 @@ nix-effects.url = "github:denful/nix-effects/den"; nix-effects.inputs.nixpkgs.follows = "nixpkgs"; nix-effects.inputs.nix-unit.follows = "nix-unit"; + + den-schema.url = "github:sini/den-schema"; + den-schema.inputs.nixpkgs.follows = "nixpkgs"; }; } From 3a51e30b5bebf8984882a1ae4b22ca8f2f1da329 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Wed, 20 May 2026 17:04:33 -0700 Subject: [PATCH 002/101] feat: port den.schema to gen-schema mkSchemaOption Replaces hand-rolled schemaEntryType with gen-schema mkSchemaOption. Sidecars: includes, excludes. Computed: isEntity (structural content only). Extracts resolvedCtxModule (id_hash, resolved, collisionPolicy) to _types.nix for entity type reuse. collisionPolicy flows through deferred module merge to entity instances (not a sidecar) preserving existing ctx.host.collisionPolicy resolution path. --- modules/options.nix | 212 ++++---------------------------- nix/lib/entities/_types.nix | 85 +++++++++++++ nix/lib/entities/home.nix | 8 +- nix/lib/entities/host.nix | 13 +- templates/ci/provider/flake.nix | 2 + 5 files changed, 130 insertions(+), 190 deletions(-) diff --git a/modules/options.nix b/modules/options.nix index 94974c557..cdd791403 100644 --- a/modules/options.nix +++ b/modules/options.nix @@ -14,181 +14,7 @@ let config ; }; - - # Context args are derived from the entity's _module.args, filtered to - # known entity kinds so framework args don't leak through. - schemaKinds = builtins.filter (n: n != "conf" && !(lib.hasPrefix "_" n)) ( - builtins.attrNames (den.schema or { }) - ); - knownKinds = schemaKinds; - - # Option type names whose values are safe for identity hashing. - primitiveTypeNames = [ - "str" - "int" - "bool" - ]; - - schemaEntryType = - let - base = lib.types.deferredModule; - in - base - // { - merge = - loc: defs: - let - kind = lib.last loc; - # Extract includes and excludes from defs, strip before deferred merge - allIncludes = lib.concatMap ( - d: - if builtins.isAttrs d.value && d.value ? includes && builtins.isList d.value.includes then - d.value.includes - else - [ ] - ) defs; - allExcludes = lib.concatMap ( - d: - if builtins.isAttrs d.value && d.value ? excludes && builtins.isList d.value.excludes then - d.value.excludes - else - [ ] - ) defs; - strippedDefs = map ( - d: - if builtins.isAttrs d.value then - d - // { - value = builtins.removeAttrs d.value [ - "includes" - "excludes" - ]; - } - else - d - ) defs; - merged = base.merge loc strippedDefs; - - resolvedCtx = - { config, options, ... }: - { - # Stable identity hash for entity comparison. - # - # Nix's `==` does deep structural comparison which diverges or - # infinitely recurses when the same entity is accessed via - # different module system thunks. This hash reflects on all - # non-internal primitive options (str, int, bool), prefixed by - # schema kind, to produce a cheap string identity. - # - # Automatically includes any primitive option declared on the - # entity — custom entity types get this for free. - # - # Usage: builtins.filter (h: h.id_hash != host.id_hash) allHosts - options.id_hash = lib.mkOption { - description = '' - Auto-computed identity hash for entity comparison. - - Derived by reflecting on all non-internal, primitive-typed - options (str, int, bool) declared on this entity. The schema - kind is included to prevent cross-kind collisions. - - Use `a.id_hash != b.id_hash` instead of `a != b` for entity - comparison — Nix's `==` does deep structural comparison which - is fragile across module system boundaries. - ''; - readOnly = true; - internal = true; - type = lib.types.str; - default = - let - isPrimitive = - name: opt: - !(lib.hasPrefix "_" name) - && (opt ? type) - && builtins.elem (opt.type.name or "") primitiveTypeNames - && !(opt.internal or false); - identityKeys = lib.sort (a: b: a < b) (builtins.attrNames (lib.filterAttrs isPrimitive options)); - encode = - k: - let - v = config.${k}; - in - "${k}=${toString v}"; - fingerprint = "${kind}|${lib.concatMapStringsSep "|" encode identityKeys}"; - in - builtins.hashString "sha256" fingerprint; - }; - options.resolved = lib.mkOption { - description = "The resolved aspect for this ${kind}."; - readOnly = true; - type = lib.types.raw; - default = - let - # knownKinds already includes schema-derived kinds. - isContextArg = n: builtins.elem n knownKinds; - ctx = lib.filterAttrs (n: v: isContextArg n && v != null) config._module.args // { - ${kind} = config; - }; - in - den.lib.resolveEntity kind ctx; - }; - options.collisionPolicy = lib.mkOption { - description = "How to handle collisions between den context args and module-system args in flat-form class modules."; - type = lib.types.nullOr ( - lib.types.enum [ - "error" - "class-wins" - "den-wins" - ] - ); - default = null; - }; - }; - # Entity gating: kind gets pipeline wiring if it has includes, excludes, or structural module content. - # `conf` is a shared base module, not an entity — always excluded. - hasEntityContent = - kind != "conf" && (allIncludes != [ ] || allExcludes != [ ] || hasStructuralContent); - # A schema entry is "structural" if it has module content beyond just includes/excludes. - # Only structural entries should get self-provide aspect lookup. - hasStructuralContent = builtins.any ( - d: - let - v = d.value; - stripped = - if builtins.isAttrs v then - builtins.removeAttrs v [ - "includes" - "excludes" - ] - else - v; - in - !builtins.isAttrs stripped || stripped != { } - ) defs; - in - if hasEntityContent then - { - __functor = - _: - { ... }: - { - imports = [ - merged - resolvedCtx - ]; - }; - includes = allIncludes; - excludes = allExcludes; - isEntity = hasStructuralContent; - } - else - { - __functor = _: { ... }: merged; - includes = [ ]; - excludes = [ ]; - isEntity = false; - }; - }; + schemaLib = inputs.den-schema.lib; classSchemaType = lib.types.submodule ( { ... }: @@ -214,20 +40,35 @@ let }; } ); - - schemaOption = lib.mkOption { - description = "freeform deferred modules per entity kind"; - defaultText = lib.literalExpression "{ }"; - default = { }; - type = lib.types.submodule { - freeformType = lib.types.lazyAttrsOf schemaEntryType; - }; - }; in { options.den.hosts = types.hostsOption; options.den.homes = types.homesOption; - options.den.schema = schemaOption; + options.den.schema = schemaLib.mkSchemaOption { + sidecars = { + includes = { + default = [ ]; + }; + excludes = { + default = [ ]; + }; + }; + computed = _kind: _sidecars: defs: { + isEntity = builtins.any ( + d: + let + v = d.value; + sidecarKeys = [ + "includes" + "excludes" + "collisionPolicy" + ]; + stripped = if builtins.isAttrs v then builtins.removeAttrs v sidecarKeys else v; + in + !builtins.isAttrs stripped || stripped != { } + ) defs; + }; + }; options.den.classes = lib.mkOption { description = "Class evaluation domains"; type = lib.types.lazyAttrsOf classSchemaType; @@ -254,6 +95,7 @@ in host.imports = [ den.schema.conf ]; user.imports = [ den.schema.conf ]; home.imports = [ den.schema.conf ]; + _topology.host.children = [ "user" ]; }; config.den.classes = { nixos.description = "NixOS system configuration"; diff --git a/nix/lib/entities/_types.nix b/nix/lib/entities/_types.nix index c9b199728..dfe66bf1d 100644 --- a/nix/lib/entities/_types.nix +++ b/nix/lib/entities/_types.nix @@ -4,6 +4,7 @@ # Extracted from nix/lib/types.nix — no new functionality. { lib, + den, ... }: let @@ -59,6 +60,89 @@ let defaultText = "config.__resolveResult.pathSetByScope"; default = config.__resolveResult.pathSetByScope; }; + + # Entity kinds derived from the schema, excluding non-entity entries. + schemaKinds = builtins.filter (n: n != "conf" && !(lib.hasPrefix "_" n)) ( + builtins.attrNames (den.schema or { }) + ); + + # Option type names whose values are safe for identity hashing. + primitiveTypeNames = [ + "str" + "int" + "bool" + ]; + + # Module injected into entity submodules for resolved aspect, id_hash, + # and collisionPolicy. Extracted here so host.nix, home.nix, and future + # entity types all share it. + resolvedCtxModule = + kind: + { + config, + options, + ... + }: + { + options.id_hash = lib.mkOption { + description = '' + Auto-computed identity hash for entity comparison. + + Derived by reflecting on all non-internal, primitive-typed + options (str, int, bool) declared on this entity. The schema + kind is included to prevent cross-kind collisions. + + Use `a.id_hash != b.id_hash` instead of `a != b` for entity + comparison — Nix's `==` does deep structural comparison which + is fragile across module system boundaries. + ''; + readOnly = true; + internal = true; + type = lib.types.str; + default = + let + isPrimitive = + name: opt: + !(lib.hasPrefix "_" name) + && (opt ? type) + && builtins.elem (opt.type.name or "") primitiveTypeNames + && !(opt.internal or false); + identityKeys = lib.sort (a: b: a < b) (builtins.attrNames (lib.filterAttrs isPrimitive options)); + encode = + k: + let + v = config.${k}; + in + "${k}=${toString v}"; + fingerprint = "${kind}|${lib.concatMapStringsSep "|" encode identityKeys}"; + in + builtins.hashString "sha256" fingerprint; + }; + options.resolved = lib.mkOption { + description = "The resolved aspect for this ${kind}."; + readOnly = true; + type = lib.types.raw; + default = + let + isContextArg = n: builtins.elem n schemaKinds; + ctx = lib.filterAttrs (n: v: isContextArg n && v != null) config._module.args // { + ${kind} = config; + }; + in + den.lib.resolveEntity kind ctx; + }; + options.collisionPolicy = lib.mkOption { + description = "How to handle collisions between den context args and module-system args."; + type = lib.types.nullOr ( + lib.types.enum [ + "error" + "class-wins" + "den-wins" + ] + ); + default = null; + }; + }; in { inherit @@ -67,5 +151,6 @@ in mainModuleOption resolveResultOption pathSetByScopeOption + resolvedCtxModule ; } diff --git a/nix/lib/entities/home.nix b/nix/lib/entities/home.nix index fa8fe91e8..9939c61e6 100644 --- a/nix/lib/entities/home.nix +++ b/nix/lib/entities/home.nix @@ -6,12 +6,13 @@ ... }@top: let - inherit (import ./_types.nix { inherit lib; }) + inherit (import ./_types.nix { inherit lib den; }) strOpt lookupAspect mainModuleOption resolveResultOption pathSetByScopeOption + resolvedCtxModule ; homesOption = lib.mkOption { @@ -74,7 +75,10 @@ let in { freeformType = lib.types.attrsOf lib.types.anything; - imports = [ den.schema.home ]; + imports = [ + den.schema.home + (resolvedCtxModule "home") + ]; config._module.args.home = config; config._module.args.host = hostCtx; config._module.args.user = userByName; diff --git a/nix/lib/entities/host.nix b/nix/lib/entities/host.nix index cca8644cc..132e4b8ce 100644 --- a/nix/lib/entities/host.nix +++ b/nix/lib/entities/host.nix @@ -6,12 +6,13 @@ ... }: let - inherit (import ./_types.nix { inherit lib; }) + inherit (import ./_types.nix { inherit lib den; }) strOpt lookupAspect mainModuleOption resolveResultOption pathSetByScopeOption + resolvedCtxModule ; hostsOption = lib.mkOption { @@ -34,7 +35,10 @@ let { name, config, ... }: { freeformType = lib.types.attrsOf lib.types.anything; - imports = [ den.schema.host ]; + imports = [ + den.schema.host + (resolvedCtxModule "host") + ]; config._module.args.host = config; options = { name = strOpt "host configuration name" name; @@ -124,7 +128,10 @@ let { name, config, ... }: { freeformType = lib.types.attrsOf lib.types.anything; - imports = [ den.schema.user ]; + imports = [ + den.schema.user + (resolvedCtxModule "user") + ]; config._module.args.host = host; config._module.args.user = config; options = { diff --git a/templates/ci/provider/flake.nix b/templates/ci/provider/flake.nix index 2380d7309..c18d4ece5 100644 --- a/templates/ci/provider/flake.nix +++ b/templates/ci/provider/flake.nix @@ -10,5 +10,7 @@ nixpkgs.url = "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz"; import-tree.url = "github:vic/import-tree"; den.url = "github:denful/den"; + den-schema.url = "github:sini/den-schema"; + den-schema.inputs.nixpkgs.follows = "nixpkgs"; }; } From 828fba057f360cea1469aa0bf1cf5aac241b9998 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Wed, 20 May 2026 17:49:16 -0700 Subject: [PATCH 003/101] feat: support flat host declarations alongside legacy two-level form den.hosts now accepts both forms: - Legacy: den.hosts.x86_64-linux.igloo = { ... } - Flat: den.hosts.igloo = { system = "x86_64-linux"; ... } The outer option type uses a permissive submodule with deepMergeAttrs freeformType (lib.recursiveUpdate-based merge that avoids the infinite recursion lib.types.anything causes with cross-option references). The apply function preprocesses flat entries into two-level form and re-evaluates through the original attrsOf systemType, so all 6 consumers see the canonical { system.name = hostConfig } shape. --- nix/lib/entities/_types.nix | 29 +++++++ nix/lib/entities/host.nix | 30 ++++++- .../ci/modules/public-api/flat-hosts.nix | 87 +++++++++++++++++++ 3 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 templates/ci/modules/public-api/flat-hosts.nix diff --git a/nix/lib/entities/_types.nix b/nix/lib/entities/_types.nix index dfe66bf1d..b32d2a983 100644 --- a/nix/lib/entities/_types.nix +++ b/nix/lib/entities/_types.nix @@ -143,6 +143,33 @@ let default = null; }; }; + # System strings recognized as two-level group keys rather than host names. + reservedSystems = lib.genAttrs lib.systems.flakeExposed (_: true); + + # Normalize mixed host declarations into canonical two-level form. + # Two-level entries (key is a system string) pass through. + # Flat entries (key is a host name) are grouped by their `system` attribute. + preprocessHosts = + raw: + let + systemGroups = lib.filterAttrs (k: _: reservedSystems ? ${k}) raw; + directHosts = lib.filterAttrs (k: _: !(reservedSystems ? ${k})) raw; + grouped = lib.foldlAttrs ( + acc: name: cfg: + let + system = + cfg.system + or (throw "den: flat host '${name}' must specify 'system' (e.g. system = \"x86_64-linux\")"); + in + acc + // { + ${system} = (acc.${system} or { }) // { + ${name} = builtins.removeAttrs cfg [ "system" ]; + }; + } + ) { } directHosts; + in + lib.recursiveUpdate systemGroups grouped; in { inherit @@ -152,5 +179,7 @@ in resolveResultOption pathSetByScopeOption resolvedCtxModule + reservedSystems + preprocessHosts ; } diff --git a/nix/lib/entities/host.nix b/nix/lib/entities/host.nix index 132e4b8ce..5d7947b6a 100644 --- a/nix/lib/entities/host.nix +++ b/nix/lib/entities/host.nix @@ -13,13 +13,41 @@ let resolveResultOption pathSetByScopeOption resolvedCtxModule + reservedSystems + preprocessHosts ; + # Recursive merge without forcing leaf values. + # Unlike lib.types.anything, this does not inspect values deeply (no + # mapAttrsRecursiveCond), avoiding infinite recursion when values + # reference other options (e.g. den.aspects). + deepMergeAttrs = lib.mkOptionType { + name = "deepMergeAttrs"; + description = "recursively merged attribute set"; + check = builtins.isAttrs; + merge = _loc: defs: builtins.foldl' (acc: def: lib.recursiveUpdate acc def.value) { } defs; + }; + + innerType = lib.types.attrsOf systemType; + hostsOption = lib.mkOption { description = "den hosts definition"; default = { }; defaultText = lib.literalExpression "{ }"; - type = lib.types.attrsOf systemType; + type = lib.types.attrsOf (lib.types.submodule { freeformType = deepMergeAttrs; }); + apply = + raw: + let + normalized = preprocessHosts raw; + in + innerType.merge + [ "den" "hosts" ] + [ + { + file = ""; + value = normalized; + } + ]; }; systemType = lib.types.submodule ( diff --git a/templates/ci/modules/public-api/flat-hosts.nix b/templates/ci/modules/public-api/flat-hosts.nix new file mode 100644 index 000000000..3df7c456d --- /dev/null +++ b/templates/ci/modules/public-api/flat-hosts.nix @@ -0,0 +1,87 @@ +{ denTest, ... }: +{ + flake.tests.flat-hosts = { + test-flat-host-two-level-shape = denTest ( + { den, ... }: + { + den.hosts.igloo = { + system = "x86_64-linux"; + users.tux = { }; + }; + + expr = builtins.attrNames den.hosts; + expected = [ "x86_64-linux" ]; + } + ); + + test-flat-host-name = denTest ( + { den, ... }: + { + den.hosts.igloo = { + system = "x86_64-linux"; + users.tux = { }; + }; + + expr = den.hosts.x86_64-linux.igloo.name; + expected = "igloo"; + } + ); + + test-flat-host-system = denTest ( + { den, ... }: + { + den.hosts.igloo = { + system = "x86_64-linux"; + users.tux = { }; + }; + + expr = den.hosts.x86_64-linux.igloo.system; + expected = "x86_64-linux"; + } + ); + + test-flat-host-coexists-with-legacy = denTest ( + { den, ... }: + { + den.hosts.x86_64-linux.legacy-host.users.tux = { }; + den.hosts.flat-host = { + system = "x86_64-linux"; + users.tux = { }; + }; + + expr = builtins.sort (a: b: a < b) (builtins.attrNames den.hosts.x86_64-linux); + expected = [ + "flat-host" + "legacy-host" + ]; + } + ); + + test-flat-host-users-with-module-args = denTest ( + { den, ... }: + { + den.hosts.igloo = { + system = "x86_64-linux"; + users.tux = { }; + }; + + expr = den.hosts.x86_64-linux.igloo.users.tux.host.name; + expected = "igloo"; + } + ); + + test-flat-host-nixos-output = denTest ( + { den, igloo, ... }: + { + den.hosts.igloo = { + system = "x86_64-linux"; + users.tux = { }; + }; + den.aspects.igloo.nixos.networking.hostName = "flat-test"; + + expr = igloo.networking.hostName; + expected = "flat-test"; + } + ); + }; +} From a4280bda54283683a4e3b70cc030045f1964fd21 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Wed, 20 May 2026 17:52:18 -0700 Subject: [PATCH 004/101] feat: port den.homes to support flat and legacy two-level forms Same pattern as den.hosts: deepMergeAttrs + preprocessHosts + apply. Cross-entity host lookup and osConfig injection preserved. --- nix/lib/entities/home.nix | 30 +++++- .../ci/modules/public-api/flat-homes.nix | 92 +++++++++++++++++++ 2 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 templates/ci/modules/public-api/flat-homes.nix diff --git a/nix/lib/entities/home.nix b/nix/lib/entities/home.nix index 9939c61e6..b408e1822 100644 --- a/nix/lib/entities/home.nix +++ b/nix/lib/entities/home.nix @@ -13,12 +13,40 @@ let resolveResultOption pathSetByScopeOption resolvedCtxModule + reservedSystems + preprocessHosts ; + # Recursive merge without forcing leaf values. + # Unlike lib.types.anything, this does not inspect values deeply (no + # mapAttrsRecursiveCond), avoiding infinite recursion when values + # reference other options (e.g. den.aspects). + deepMergeAttrs = lib.mkOptionType { + name = "deepMergeAttrs"; + description = "recursively merged attribute set"; + check = builtins.isAttrs; + merge = _loc: defs: builtins.foldl' (acc: def: lib.recursiveUpdate acc def.value) { } defs; + }; + + innerType = lib.types.attrsOf homeSystemType; + homesOption = lib.mkOption { description = "den standalone home-manager configurations"; default = { }; - type = lib.types.attrsOf homeSystemType; + type = lib.types.attrsOf (lib.types.submodule { freeformType = deepMergeAttrs; }); + apply = + raw: + let + normalized = preprocessHosts raw; + in + innerType.merge + [ "den" "homes" ] + [ + { + file = ""; + value = normalized; + } + ]; }; homeSystemType = lib.types.submodule ( diff --git a/templates/ci/modules/public-api/flat-homes.nix b/templates/ci/modules/public-api/flat-homes.nix new file mode 100644 index 000000000..af894e579 --- /dev/null +++ b/templates/ci/modules/public-api/flat-homes.nix @@ -0,0 +1,92 @@ +{ denTest, ... }: +{ + flake.tests.flat-homes = { + test-flat-home-two-level-shape = denTest ( + { den, ... }: + { + den.homes."tux@igloo" = { + system = "x86_64-linux"; + }; + + expr = builtins.attrNames den.homes; + expected = [ "x86_64-linux" ]; + } + ); + + test-flat-home-name-parsing = denTest ( + { den, ... }: + { + den.homes."tux@igloo" = { + system = "x86_64-linux"; + }; + + expr = { + inherit (den.homes.x86_64-linux."tux@igloo") + name + userName + hostName + system + ; + }; + expected = { + name = "tux"; + userName = "tux"; + hostName = "igloo"; + system = "x86_64-linux"; + }; + } + ); + + test-flat-home-coexists-with-legacy = denTest ( + { den, ... }: + { + den.homes.x86_64-linux.legacy-home = { }; + den.homes."flat-home" = { + system = "x86_64-linux"; + }; + + expr = builtins.sort (a: b: a < b) (builtins.attrNames den.homes.x86_64-linux); + expected = [ + "flat-home" + "legacy-home" + ]; + } + ); + + test-flat-home-cross-entity-host-lookup = denTest ( + { den, config, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.homes."tux@igloo" = { + system = "x86_64-linux"; + }; + + den.aspects.igloo.nixos.networking.hostName = "igloo"; + den.aspects.tux.includes = [ den.provides.define-user ]; + den.aspects.tux.homeManager = + { osConfig, ... }: + { + home.keyboard.model = osConfig.networking.hostName; + }; + + expr = config.flake.homeConfigurations."tux@igloo".config.home.keyboard.model; + expected = "igloo"; + } + ); + + test-flat-home-output = denTest ( + { den, config, ... }: + { + den.homes."tux" = { + system = "x86_64-linux"; + }; + den.default.homeManager.home.stateVersion = "25.11"; + den.default.includes = [ den.provides.define-user ]; + den.aspects.tux.homeManager.programs.fish.enable = true; + + expr = config.flake.homeConfigurations.tux.config.programs.fish.enable; + expected = true; + } + ); + }; +} From ba06283b76c9d835f1b47e7920ddefacfe03c3b3 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Wed, 20 May 2026 18:08:28 -0700 Subject: [PATCH 005/101] test: add entity gen-schema port tests Covers: id_hash, freeform, topology, meta introspection, isEntity computed, schema includes sidecar. --- .../internal-api/entity-gen-schema.nix | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 templates/ci/modules/internal-api/entity-gen-schema.nix diff --git a/templates/ci/modules/internal-api/entity-gen-schema.nix b/templates/ci/modules/internal-api/entity-gen-schema.nix new file mode 100644 index 000000000..7cdd55ab7 --- /dev/null +++ b/templates/ci/modules/internal-api/entity-gen-schema.nix @@ -0,0 +1,91 @@ +{ denTest, ... }: +{ + flake.tests.entity-gen-schema = { + # gen-schema id_hash available on hosts + test-entity-host-id-hash = denTest ( + { den, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + expr = { + hasIdHash = den.hosts.x86_64-linux.igloo ? id_hash; + isString = builtins.isString den.hosts.x86_64-linux.igloo.id_hash; + }; + expected = { + hasIdHash = true; + isString = true; + }; + } + ); + + # id_hash differs between different hosts + test-entity-id-hash-differs = denTest ( + { den, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.hosts.x86_64-linux.tundra.users.tux = { }; + + expr = den.hosts.x86_64-linux.igloo.id_hash != den.hosts.x86_64-linux.tundra.id_hash; + expected = true; + } + ); + + # Freeform: arbitrary attributes on hosts not rejected + test-entity-freeform = denTest ( + { den, ... }: + { + den.hosts.x86_64-linux.igloo = { + users.tux = { }; + customAttr = "hello"; + }; + + expr = den.hosts.x86_64-linux.igloo.customAttr; + expected = "hello"; + } + ); + + # gen-schema _topology is available + test-entity-topology = denTest ( + { den, ... }: + { + expr = den.schema._topology.host.children; + expected = [ "user" ]; + } + ); + + # gen-schema _meta is available + test-entity-meta-available = denTest ( + { den, ... }: + { + expr = den.schema ? _meta; + expected = true; + } + ); + + # Schema entry has isEntity computed correctly + test-entity-is-entity = denTest ( + { den, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + expr = { + hostIsEntity = den.schema.host.isEntity; + flakeIsEntity = den.schema.flake.isEntity; + }; + expected = { + hostIsEntity = true; + flakeIsEntity = false; + }; + } + ); + + # Schema entry has includes sidecar + test-entity-schema-includes = denTest ( + { den, ... }: + { + expr = builtins.isList den.schema.host.includes; + expected = true; + } + ); + }; +} From 04af0f4fd97d943990eb59c87b38954a4c91d795 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Thu, 21 May 2026 15:37:40 -0700 Subject: [PATCH 006/101] chore: rename den-schema to gen-schema Update flake inputs and references to match the renamed repo at github:sini/gen-schema. --- modules/options.nix | 2 +- templates/ci/flake.lock | 40 ++++++++++++++++----------------- templates/ci/flake.nix | 4 ++-- templates/ci/provider/flake.nix | 4 ++-- 4 files changed, 25 insertions(+), 25 deletions(-) diff --git a/modules/options.nix b/modules/options.nix index cdd791403..d930481a2 100644 --- a/modules/options.nix +++ b/modules/options.nix @@ -14,7 +14,7 @@ let config ; }; - schemaLib = inputs.den-schema.lib; + schemaLib = inputs.gen-schema.lib; classSchemaType = lib.types.submodule ( { ... }: diff --git a/templates/ci/flake.lock b/templates/ci/flake.lock index 594e27ce9..ff2f85d9a 100644 --- a/templates/ci/flake.lock +++ b/templates/ci/flake.lock @@ -35,44 +35,44 @@ "type": "github" } }, - "den-schema": { + "flake-parts": { "inputs": { - "nixpkgs": [ + "nixpkgs-lib": [ + "nix-unit", "nixpkgs" ] }, "locked": { - "lastModified": 1779316549, - "narHash": "sha256-Ez1W0qTzMzyC+x2mjis5oh5L3q7cOMe/xJVdhSP6sJo=", - "owner": "sini", - "repo": "den-schema", - "rev": "1028d249a82d1184752c51168a0b397ea0bafa12", + "lastModified": 1762440070, + "narHash": "sha256-xxdepIcb39UJ94+YydGP221rjnpkDZUlykKuF54PsqI=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "26d05891e14c88eb4a5d5bee659c0db5afb609d8", "type": "github" }, "original": { - "owner": "sini", - "repo": "den-schema", + "owner": "hercules-ci", + "repo": "flake-parts", "type": "github" } }, - "flake-parts": { + "gen-schema": { "inputs": { - "nixpkgs-lib": [ - "nix-unit", + "nixpkgs": [ "nixpkgs" ] }, "locked": { - "lastModified": 1762440070, - "narHash": "sha256-xxdepIcb39UJ94+YydGP221rjnpkDZUlykKuF54PsqI=", - "owner": "hercules-ci", - "repo": "flake-parts", - "rev": "26d05891e14c88eb4a5d5bee659c0db5afb609d8", + "lastModified": 1779386005, + "narHash": "sha256-KY33X6a6XSwgbtdUDf1TKgwzJxTTIW/IizpHWmEmfHQ=", + "owner": "sini", + "repo": "gen-schema", + "rev": "246ccaee53afe4abdd488f8ec5b66bd8ee90369a", "type": "github" }, "original": { - "owner": "hercules-ci", - "repo": "flake-parts", + "owner": "sini", + "repo": "gen-schema", "type": "github" } }, @@ -218,7 +218,7 @@ "inputs": { "darwin": "darwin", "den": "den", - "den-schema": "den-schema", + "gen-schema": "gen-schema", "home-manager": "home-manager", "import-tree": "import-tree", "nix-effects": "nix-effects", diff --git a/templates/ci/flake.nix b/templates/ci/flake.nix index 93becb746..608d35dff 100644 --- a/templates/ci/flake.nix +++ b/templates/ci/flake.nix @@ -30,7 +30,7 @@ nix-effects.inputs.nixpkgs.follows = "nixpkgs"; nix-effects.inputs.nix-unit.follows = "nix-unit"; - den-schema.url = "github:sini/den-schema"; - den-schema.inputs.nixpkgs.follows = "nixpkgs"; + gen-schema.url = "github:sini/gen-schema"; + gen-schema.inputs.nixpkgs.follows = "nixpkgs"; }; } diff --git a/templates/ci/provider/flake.nix b/templates/ci/provider/flake.nix index c18d4ece5..5c682e574 100644 --- a/templates/ci/provider/flake.nix +++ b/templates/ci/provider/flake.nix @@ -10,7 +10,7 @@ nixpkgs.url = "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz"; import-tree.url = "github:vic/import-tree"; den.url = "github:denful/den"; - den-schema.url = "github:sini/den-schema"; - den-schema.inputs.nixpkgs.follows = "nixpkgs"; + gen-schema.url = "github:sini/gen-schema"; + gen-schema.inputs.nixpkgs.follows = "nixpkgs"; }; } From 06f71f3400bd087043544314da9a07592e151c3a Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Thu, 21 May 2026 23:12:14 -0700 Subject: [PATCH 007/101] feat: isEntity as settable sidecar with computed fallback --- modules/options.nix | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/modules/options.nix b/modules/options.nix index d930481a2..7e6d26f2e 100644 --- a/modules/options.nix +++ b/modules/options.nix @@ -52,21 +52,28 @@ in excludes = { default = [ ]; }; + isEntity = { + default = false; + merge = acc: val: acc || val; + }; }; - computed = _kind: _sidecars: defs: { - isEntity = builtins.any ( - d: - let - v = d.value; - sidecarKeys = [ - "includes" - "excludes" - "collisionPolicy" - ]; - stripped = if builtins.isAttrs v then builtins.removeAttrs v sidecarKeys else v; - in - !builtins.isAttrs stripped || stripped != { } - ) defs; + computed = _kind: sidecars: defs: { + isEntity = + sidecars.isEntity + || builtins.any ( + d: + let + v = d.value; + sidecarKeys = [ + "includes" + "excludes" + "isEntity" + "collisionPolicy" + ]; + stripped = if builtins.isAttrs v then builtins.removeAttrs v sidecarKeys else v; + in + !builtins.isAttrs stripped || stripped != { } + ) defs; }; }; options.den.classes = lib.mkOption { From c6c1090aabab84272ef654e4c080efb6e7fe58ae Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Thu, 21 May 2026 23:22:32 -0700 Subject: [PATCH 008/101] feat: update gen-schema, add parent sidecars for user/home, fix computed signature --- modules/options.nix | 7 ++++++- templates/ci/flake.lock | 13 +++++++------ .../ci/modules/internal-api/entity-gen-schema.nix | 6 +++--- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/modules/options.nix b/modules/options.nix index 7e6d26f2e..2f0cde3e7 100644 --- a/modules/options.nix +++ b/modules/options.nix @@ -57,7 +57,7 @@ in merge = acc: val: acc || val; }; }; - computed = _kind: sidecars: defs: { + computed = sidecars: defs: { isEntity = sidecars.isEntity || builtins.any ( @@ -68,6 +68,7 @@ in "includes" "excludes" "isEntity" + "parent" "collisionPolicy" ]; stripped = if builtins.isAttrs v then builtins.removeAttrs v sidecarKeys else v; @@ -76,6 +77,10 @@ in ) defs; }; }; + # Built-in entity topology: users nest inside hosts, homes nest inside hosts. + config.den.schema.user.parent = "host"; + config.den.schema.home.parent = "host"; + options.den.classes = lib.mkOption { description = "Class evaluation domains"; type = lib.types.lazyAttrsOf classSchemaType; diff --git a/templates/ci/flake.lock b/templates/ci/flake.lock index ff2f85d9a..681b2514b 100644 --- a/templates/ci/flake.lock +++ b/templates/ci/flake.lock @@ -63,12 +63,13 @@ ] }, "locked": { - "lastModified": 1779386005, - "narHash": "sha256-KY33X6a6XSwgbtdUDf1TKgwzJxTTIW/IizpHWmEmfHQ=", - "owner": "sini", - "repo": "gen-schema", - "rev": "246ccaee53afe4abdd488f8ec5b66bd8ee90369a", - "type": "github" + "lastModified": 1779417936, + "narHash": "sha256-3S6PyiGjkTEUPqqqkElshOZZvedXguAkdIIrmvNCCU8=", + "ref": "refs/heads/main", + "rev": "25c0e4b6b91abcd22ac769c0e288de7cee008ec5", + "revCount": 58, + "type": "git", + "url": "file:///home/sini/Documents/repos/gen-schema" }, "original": { "owner": "sini", diff --git a/templates/ci/modules/internal-api/entity-gen-schema.nix b/templates/ci/modules/internal-api/entity-gen-schema.nix index 7cdd55ab7..7ed15d094 100644 --- a/templates/ci/modules/internal-api/entity-gen-schema.nix +++ b/templates/ci/modules/internal-api/entity-gen-schema.nix @@ -44,12 +44,12 @@ } ); - # gen-schema _topology is available + # gen-schema _meta.topology derived from parent sidecars test-entity-topology = denTest ( { den, ... }: { - expr = den.schema._topology.host.children; - expected = [ "user" ]; + expr = den.schema._meta.topology.host.children; + expected = [ "home" "user" ]; } ); From c3e18be7fe5a80df53babd9fc885c0a3ff660f5e Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 22 May 2026 00:14:12 -0700 Subject: [PATCH 009/101] feat: reserve 'settings' as structural key on aspects --- nix/lib/aspects/fx/key-classification.nix | 1 + 1 file changed, 1 insertion(+) diff --git a/nix/lib/aspects/fx/key-classification.nix b/nix/lib/aspects/fx/key-classification.nix index 458812def..a3ceb0e20 100644 --- a/nix/lib/aspects/fx/key-classification.nix +++ b/nix/lib/aspects/fx/key-classification.nix @@ -29,6 +29,7 @@ let "__providesForwarded" "_module" "_" + "settings" ] (_: true); # Schema registry for key classification. From cf913f7b656191c2fe08cbb568f5cf007dea97dc Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 22 May 2026 00:42:38 -0700 Subject: [PATCH 010/101] =?UTF-8?q?feat:=20den.reservedKeys=20=E2=80=94=20?= =?UTF-8?q?user-extensible=20structural=20keys=20for=20aspects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/options.nix | 7 +++++++ nix/lib/aspects/fx/key-classification.nix | 10 +++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/modules/options.nix b/modules/options.nix index 2f0cde3e7..9f5717598 100644 --- a/modules/options.nix +++ b/modules/options.nix @@ -81,6 +81,13 @@ in config.den.schema.user.parent = "host"; config.den.schema.home.parent = "host"; + options.den.reservedKeys = lib.mkOption { + description = "Additional aspect keys reserved from pipeline dispatch. These keys are treated as structural — the pipeline ignores them, letting consumers use them for metadata."; + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ "settings" "tags" ]; + }; + options.den.classes = lib.mkOption { description = "Class evaluation domains"; type = lib.types.lazyAttrsOf classSchemaType; diff --git a/nix/lib/aspects/fx/key-classification.nix b/nix/lib/aspects/fx/key-classification.nix index a3ceb0e20..462fd780a 100644 --- a/nix/lib/aspects/fx/key-classification.nix +++ b/nix/lib/aspects/fx/key-classification.nix @@ -6,7 +6,7 @@ let # Structural keys are always handled by the pipeline itself — not # dispatched as class or nested aspect keys. - structuralKeysSet = lib.genAttrs [ + builtinStructuralKeys = [ "name" "description" "meta" @@ -29,8 +29,12 @@ let "__providesForwarded" "_module" "_" - "settings" - ] (_: true); + ]; + + # User-extensible reserved keys via den.reservedKeys option. + structuralKeysSet = lib.genAttrs + (builtinStructuralKeys ++ (den.reservedKeys or [ ])) + (_: true); # Schema registry for key classification. # Top-level den.classes lives outside den.schema, breaking From a44887746cf1f4944acf4f0859e4598fb5c69627 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Mon, 25 May 2026 01:22:16 -0700 Subject: [PATCH 011/101] fix: update for gen-schema collections rename and nix-effects v0.12 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gen-schema flattened _meta into _-prefixed options and renamed sidecars → collections. nix-effects changed bindAttrs so true is a literal param, not an optionality marker — translate __args values to fx.bind.optionalArg before bind.fn. --- modules/options.nix | 16 ++-- nix/lib/aspects/fx/key-classification.nix | 4 +- templates/ci/flake.lock | 87 +++++++++---------- .../internal-api/entity-gen-schema.nix | 15 ++-- 4 files changed, 62 insertions(+), 60 deletions(-) diff --git a/modules/options.nix b/modules/options.nix index 9f5717598..3a9d44f6a 100644 --- a/modules/options.nix +++ b/modules/options.nix @@ -45,7 +45,7 @@ in options.den.hosts = types.hostsOption; options.den.homes = types.homesOption; options.den.schema = schemaLib.mkSchemaOption { - sidecars = { + collections = { includes = { default = [ ]; }; @@ -57,21 +57,21 @@ in merge = acc: val: acc || val; }; }; - computed = sidecars: defs: { + computed = collections: defs: { isEntity = - sidecars.isEntity + collections.isEntity || builtins.any ( d: let v = d.value; - sidecarKeys = [ + collectionKeys = [ "includes" "excludes" "isEntity" "parent" "collisionPolicy" ]; - stripped = if builtins.isAttrs v then builtins.removeAttrs v sidecarKeys else v; + stripped = if builtins.isAttrs v then builtins.removeAttrs v collectionKeys else v; in !builtins.isAttrs stripped || stripped != { } ) defs; @@ -85,7 +85,10 @@ in description = "Additional aspect keys reserved from pipeline dispatch. These keys are treated as structural — the pipeline ignores them, letting consumers use them for metadata."; type = lib.types.listOf lib.types.str; default = [ ]; - example = [ "settings" "tags" ]; + example = [ + "settings" + "tags" + ]; }; options.den.classes = lib.mkOption { @@ -114,7 +117,6 @@ in host.imports = [ den.schema.conf ]; user.imports = [ den.schema.conf ]; home.imports = [ den.schema.conf ]; - _topology.host.children = [ "user" ]; }; config.den.classes = { nixos.description = "NixOS system configuration"; diff --git a/nix/lib/aspects/fx/key-classification.nix b/nix/lib/aspects/fx/key-classification.nix index 462fd780a..bb51ec886 100644 --- a/nix/lib/aspects/fx/key-classification.nix +++ b/nix/lib/aspects/fx/key-classification.nix @@ -32,9 +32,7 @@ let ]; # User-extensible reserved keys via den.reservedKeys option. - structuralKeysSet = lib.genAttrs - (builtinStructuralKeys ++ (den.reservedKeys or [ ])) - (_: true); + structuralKeysSet = lib.genAttrs (builtinStructuralKeys ++ (den.reservedKeys or [ ])) (_: true); # Schema registry for key classification. # Top-level den.classes lives outside den.schema, breaking diff --git a/templates/ci/flake.lock b/templates/ci/flake.lock index 681b2514b..3669d43a8 100644 --- a/templates/ci/flake.lock +++ b/templates/ci/flake.lock @@ -7,11 +7,11 @@ ] }, "locked": { - "lastModified": 1775037210, - "narHash": "sha256-KM2WYj6EA7M/FVZVCl3rqWY+TFV5QzSyyGE2gQxeODU=", + "lastModified": 1779036909, + "narHash": "sha256-zXcwYQGCT6pzinK+1dBB2ekTVtfxGZAapb3Evdcu4fY=", "owner": "nix-darwin", "repo": "nix-darwin", - "rev": "06648f4902343228ce2de79f291dd5a58ee12146", + "rev": "56c666e108467d87d13508936aade6d567f2a501", "type": "github" }, "original": { @@ -22,11 +22,11 @@ }, "den": { "locked": { - "lastModified": 1776710169, - "narHash": "sha256-q4WXIX2E3w9Ld3MZ1Pl8Lh5SgrEFdEuzvY1Lj/Wo2kY=", + "lastModified": 1779693278, + "narHash": "sha256-Er/DjUx8O/pOmyF9+u8MBLim4ZwMKCgbkEkAOh5oxS4=", "owner": "denful", "repo": "den", - "rev": "0af82e24be89b9fd400bd0b58b0fed5ea0f269ad", + "rev": "0b250e179282832e7ba9b7f95ffb8e6b87a973fe", "type": "github" }, "original": { @@ -35,41 +35,40 @@ "type": "github" } }, - "flake-parts": { + "gen-schema": { "inputs": { - "nixpkgs-lib": [ - "nix-unit", + "nixpkgs": [ "nixpkgs" ] }, "locked": { - "lastModified": 1762440070, - "narHash": "sha256-xxdepIcb39UJ94+YydGP221rjnpkDZUlykKuF54PsqI=", - "owner": "hercules-ci", - "repo": "flake-parts", - "rev": "26d05891e14c88eb4a5d5bee659c0db5afb609d8", + "lastModified": 1779682984, + "narHash": "sha256-kXusOmg6kpwRx9IJr4B0sy1SlUOhinbiqesnKRh44bQ=", + "owner": "sini", + "repo": "gen-schema", + "rev": "ee478e438b59a0af3233b39ddd18f50677d998a9", "type": "github" }, "original": { - "owner": "hercules-ci", - "repo": "flake-parts", + "owner": "sini", + "repo": "gen-schema", "type": "github" } }, - "gen-schema": { + "gen-schema_2": { "inputs": { "nixpkgs": [ + "provider", "nixpkgs" ] }, "locked": { - "lastModified": 1779417936, - "narHash": "sha256-3S6PyiGjkTEUPqqqkElshOZZvedXguAkdIIrmvNCCU8=", - "ref": "refs/heads/main", - "rev": "25c0e4b6b91abcd22ac769c0e288de7cee008ec5", - "revCount": 58, - "type": "git", - "url": "file:///home/sini/Documents/repos/gen-schema" + "lastModified": 1779682984, + "narHash": "sha256-kXusOmg6kpwRx9IJr4B0sy1SlUOhinbiqesnKRh44bQ=", + "owner": "sini", + "repo": "gen-schema", + "rev": "ee478e438b59a0af3233b39ddd18f50677d998a9", + "type": "github" }, "original": { "owner": "sini", @@ -84,11 +83,11 @@ ] }, "locked": { - "lastModified": 1776964438, - "narHash": "sha256-AF0cby9Xuijr5qaFpYKbm1mExV956Hk233bel6QxpFw=", + "lastModified": 1779678629, + "narHash": "sha256-gHcIFg0mm+KFsg7iZQt67kni3+qR5U3PhEC9P7vKlZ4=", "owner": "nix-community", "repo": "home-manager", - "rev": "e09259dd2e147d35ef889784b51e89b0a10ffe15", + "rev": "612bbe3b405ad5f71d7bf9edecc04b678a061652", "type": "github" }, "original": { @@ -99,11 +98,11 @@ }, "import-tree": { "locked": { - "lastModified": 1773693634, - "narHash": "sha256-BtZ2dtkBdSUnFPPFc+n0kcMbgaTxzFNPv2iaO326Ffg=", + "lastModified": 1778781969, + "narHash": "sha256-Jjuz5CmSkur8KvLDoGa+vylEp+RkQtv4mt/qcMznpH0=", "owner": "vic", "repo": "import-tree", - "rev": "c41e7d58045f9057880b0d85e1152d6a4430dbf1", + "rev": "d321337efd0f23a9eb14a42adb7b2c29313ab274", "type": "github" }, "original": { @@ -131,7 +130,6 @@ }, "original": { "owner": "denful", - "ref": "den", "repo": "nix-effects", "type": "github" } @@ -159,7 +157,6 @@ }, "nix-unit": { "inputs": { - "flake-parts": "flake-parts", "nix-github-actions": "nix-github-actions", "nixpkgs": [ "nixpkgs" @@ -167,11 +164,11 @@ "treefmt-nix": "treefmt-nix" }, "locked": { - "lastModified": 1762774186, - "narHash": "sha256-hRADkHjNt41+JUHw2EiSkMaL4owL83g5ZppjYUdF/Dc=", + "lastModified": 1779338171, + "narHash": "sha256-affUbv/bwE8SLGhuWKniDr7SVO+Lo1XEPjCZdyU5kgQ=", "owner": "nix-community", "repo": "nix-unit", - "rev": "1c9ab50554eed0b768f9e5b6f646d63c9673f0f7", + "rev": "6ab1f232562a01d18b40d5ed6a58718c4f3a74bc", "type": "github" }, "original": { @@ -182,11 +179,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1776548001, - "narHash": "sha256-qH3mBrZnNsPdwpAgvG2Olgzsp5kt+Sibpm1tx1pxkcQ=", - "rev": "b12141ef619e0a9c1c84dc8c684040326f27cdcc", + "lastModified": 1779508470, + "narHash": "sha256-OtXX32ZNu00Co+iVgV3ffkJVgVVcc0Sy56DdfJm+UQM=", + "rev": "29916453413845e54a65b8a1cf996842300cd299", "type": "tarball", - "url": "https://releases.nixos.org/nixos/unstable/nixos-26.05pre982522.b12141ef619e/nixexprs.tar.xz?lastModified=1776548001&rev=b12141ef619e0a9c1c84dc8c684040326f27cdcc" + "url": "https://releases.nixos.org/nixos/unstable/nixos-26.05pre1003640.299164534138/nixexprs.tar.xz?lastModified=1779508470&rev=29916453413845e54a65b8a1cf996842300cd299" }, "original": { "type": "tarball", @@ -198,6 +195,7 @@ "den": [ "den" ], + "gen-schema": "gen-schema_2", "import-tree": [ "import-tree" ], @@ -206,14 +204,15 @@ ] }, "locked": { + "lastModified": 1, + "narHash": "sha256-Hqizev6Ij0Q4O7Xpm6TDhblKxoY2et2BxRc67Gklff4=", "path": "./provider", "type": "path" }, "original": { "path": "./provider", "type": "path" - }, - "parent": [] + } }, "root": { "inputs": { @@ -236,11 +235,11 @@ ] }, "locked": { - "lastModified": 1762410071, - "narHash": "sha256-aF5fvoZeoXNPxT0bejFUBXeUjXfHLSL7g+mjR/p5TEg=", + "lastModified": 1775636079, + "narHash": "sha256-pc20NRoMdiar8oPQceQT47UUZMBTiMdUuWrYu2obUP0=", "owner": "numtide", "repo": "treefmt-nix", - "rev": "97a30861b13c3731a84e09405414398fbf3e109f", + "rev": "790751ff7fd3801feeaf96d7dc416a8d581265ba", "type": "github" }, "original": { diff --git a/templates/ci/modules/internal-api/entity-gen-schema.nix b/templates/ci/modules/internal-api/entity-gen-schema.nix index 7ed15d094..935e92c20 100644 --- a/templates/ci/modules/internal-api/entity-gen-schema.nix +++ b/templates/ci/modules/internal-api/entity-gen-schema.nix @@ -44,20 +44,23 @@ } ); - # gen-schema _meta.topology derived from parent sidecars + # gen-schema _meta.topology derived from parent collections test-entity-topology = denTest ( { den, ... }: { - expr = den.schema._meta.topology.host.children; - expected = [ "home" "user" ]; + expr = den.schema._topology.host.children; + expected = [ + "home" + "user" + ]; } ); - # gen-schema _meta is available - test-entity-meta-available = denTest ( + # gen-schema _topology is available + test-entity-topology-available = denTest ( { den, ... }: { - expr = den.schema ? _meta; + expr = den.schema ? _topology; expected = true; } ); From 3db285a9a800b3893c999771a32eafbea65885bd Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Wed, 27 May 2026 08:44:26 -0700 Subject: [PATCH 012/101] chore: update gen-schema --- templates/ci/flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/templates/ci/flake.lock b/templates/ci/flake.lock index 3669d43a8..9b73a7f86 100644 --- a/templates/ci/flake.lock +++ b/templates/ci/flake.lock @@ -42,11 +42,11 @@ ] }, "locked": { - "lastModified": 1779682984, - "narHash": "sha256-kXusOmg6kpwRx9IJr4B0sy1SlUOhinbiqesnKRh44bQ=", + "lastModified": 1779829514, + "narHash": "sha256-66GmT6xMiOBZ2Msb2xZAw/afH+r4n8kzpi90vM4Pfp4=", "owner": "sini", "repo": "gen-schema", - "rev": "ee478e438b59a0af3233b39ddd18f50677d998a9", + "rev": "a3ce41a010414afffd2407dac242ced39e12d5df", "type": "github" }, "original": { From 6bd8a087f2ed750add8aba8bc6c1772c88e60a38 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Thu, 28 May 2026 11:07:51 -0700 Subject: [PATCH 013/101] chore: update flake.lock --- templates/ci/flake.lock | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/templates/ci/flake.lock b/templates/ci/flake.lock index 9b73a7f86..60f193bf0 100644 --- a/templates/ci/flake.lock +++ b/templates/ci/flake.lock @@ -22,11 +22,11 @@ }, "den": { "locked": { - "lastModified": 1779693278, - "narHash": "sha256-Er/DjUx8O/pOmyF9+u8MBLim4ZwMKCgbkEkAOh5oxS4=", + "lastModified": 1779919306, + "narHash": "sha256-eo2Snm2hoYAq8MA7bfjnn8p36riLcpJDA74Fzx9gcsY=", "owner": "denful", "repo": "den", - "rev": "0b250e179282832e7ba9b7f95ffb8e6b87a973fe", + "rev": "fba67817bd16955e10ff158c9758874031af089c", "type": "github" }, "original": { @@ -42,11 +42,11 @@ ] }, "locked": { - "lastModified": 1779829514, - "narHash": "sha256-66GmT6xMiOBZ2Msb2xZAw/afH+r4n8kzpi90vM4Pfp4=", + "lastModified": 1779986641, + "narHash": "sha256-KcZuS+hpaloICFcepNXNLpbehh6XoPjWPBteYpTqMRw=", "owner": "sini", "repo": "gen-schema", - "rev": "a3ce41a010414afffd2407dac242ced39e12d5df", + "rev": "4bd0f6eb1799bf3c38eb3707419157b1f70eb1f5", "type": "github" }, "original": { @@ -63,11 +63,11 @@ ] }, "locked": { - "lastModified": 1779682984, - "narHash": "sha256-kXusOmg6kpwRx9IJr4B0sy1SlUOhinbiqesnKRh44bQ=", + "lastModified": 1779986641, + "narHash": "sha256-KcZuS+hpaloICFcepNXNLpbehh6XoPjWPBteYpTqMRw=", "owner": "sini", "repo": "gen-schema", - "rev": "ee478e438b59a0af3233b39ddd18f50677d998a9", + "rev": "4bd0f6eb1799bf3c38eb3707419157b1f70eb1f5", "type": "github" }, "original": { @@ -83,11 +83,11 @@ ] }, "locked": { - "lastModified": 1779678629, - "narHash": "sha256-gHcIFg0mm+KFsg7iZQt67kni3+qR5U3PhEC9P7vKlZ4=", + "lastModified": 1779969295, + "narHash": "sha256-HwIJ3tOcwSMiV75L7KqJXciXR9UfT+d7rwOZMX7cTnA=", "owner": "nix-community", "repo": "home-manager", - "rev": "612bbe3b405ad5f71d7bf9edecc04b678a061652", + "rev": "61e2c9659324181e0f0ed911958c536333b1d4f6", "type": "github" }, "original": { @@ -179,11 +179,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1779508470, - "narHash": "sha256-OtXX32ZNu00Co+iVgV3ffkJVgVVcc0Sy56DdfJm+UQM=", - "rev": "29916453413845e54a65b8a1cf996842300cd299", + "lastModified": 1779560665, + "narHash": "sha256-NpH8iEQ5JHv/BtUuzTEXUMDxPLetCDzIv4OxL8H7Kps=", + "rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786", "type": "tarball", - "url": "https://releases.nixos.org/nixos/unstable/nixos-26.05pre1003640.299164534138/nixexprs.tar.xz?lastModified=1779508470&rev=29916453413845e54a65b8a1cf996842300cd299" + "url": "https://releases.nixos.org/nixos/unstable/nixos-26.05pre1004030.64c08a7ca051/nixexprs.tar.xz?lastModified=1779560665&rev=64c08a7ca051951c8eae34e3e3cb1e202fe36786" }, "original": { "type": "tarball", From dca996cff951f0a8423aea52c35e037b2dd336e5 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Thu, 28 May 2026 12:46:33 -0700 Subject: [PATCH 014/101] chore: update flake.lock --- templates/ci/provider/flake.lock | 45 ++++++++++++++++++++-------- templates/default/flake.lock | 6 ++-- templates/example/flake.lock | 6 ++-- templates/minimal/flake.lock | 6 ++-- templates/noflake/npins/sources.json | 6 ++-- 5 files changed, 45 insertions(+), 24 deletions(-) diff --git a/templates/ci/provider/flake.lock b/templates/ci/provider/flake.lock index ae33b4cd2..9e7451297 100644 --- a/templates/ci/provider/flake.lock +++ b/templates/ci/provider/flake.lock @@ -2,26 +2,46 @@ "nodes": { "den": { "locked": { - "lastModified": 1774498900, - "narHash": "sha256-THw/ly8KvXGQ0EI+Nhu/Eo9w8w7wtgHhAeWpteNiz/Q=", - "owner": "vic", + "lastModified": 1779919306, + "narHash": "sha256-eo2Snm2hoYAq8MA7bfjnn8p36riLcpJDA74Fzx9gcsY=", + "owner": "denful", "repo": "den", - "rev": "eb92bbfdefd22b76fa5781e8adbeff42c4fe429e", + "rev": "fba67817bd16955e10ff158c9758874031af089c", "type": "github" }, "original": { - "owner": "vic", + "owner": "denful", "repo": "den", "type": "github" } }, + "gen-schema": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1779986641, + "narHash": "sha256-KcZuS+hpaloICFcepNXNLpbehh6XoPjWPBteYpTqMRw=", + "owner": "sini", + "repo": "gen-schema", + "rev": "4bd0f6eb1799bf3c38eb3707419157b1f70eb1f5", + "type": "github" + }, + "original": { + "owner": "sini", + "repo": "gen-schema", + "type": "github" + } + }, "import-tree": { "locked": { - "lastModified": 1773693634, - "narHash": "sha256-BtZ2dtkBdSUnFPPFc+n0kcMbgaTxzFNPv2iaO326Ffg=", + "lastModified": 1778781969, + "narHash": "sha256-Jjuz5CmSkur8KvLDoGa+vylEp+RkQtv4mt/qcMznpH0=", "owner": "vic", "repo": "import-tree", - "rev": "c41e7d58045f9057880b0d85e1152d6a4430dbf1", + "rev": "d321337efd0f23a9eb14a42adb7b2c29313ab274", "type": "github" }, "original": { @@ -32,11 +52,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1775710090, - "narHash": "sha256-WGjBfvXv/mcg5yBg+AtK1Q3FHyXfjAAeJROmg7DLYfM=", - "rev": "4c1018dae018162ec878d42fec712642d214fdfa", + "lastModified": 1779560665, + "narHash": "sha256-NpH8iEQ5JHv/BtUuzTEXUMDxPLetCDzIv4OxL8H7Kps=", + "rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786", "type": "tarball", - "url": "https://releases.nixos.org/nixos/unstable/nixos-26.05pre977467.4c1018dae018/nixexprs.tar.xz" + "url": "https://releases.nixos.org/nixos/unstable/nixos-26.05pre1004030.64c08a7ca051/nixexprs.tar.xz?lastModified=1779560665&rev=64c08a7ca051951c8eae34e3e3cb1e202fe36786" }, "original": { "type": "tarball", @@ -46,6 +66,7 @@ "root": { "inputs": { "den": "den", + "gen-schema": "gen-schema", "import-tree": "import-tree", "nixpkgs": "nixpkgs" } diff --git a/templates/default/flake.lock b/templates/default/flake.lock index 0cb37d10c..f1ec07ebe 100644 --- a/templates/default/flake.lock +++ b/templates/default/flake.lock @@ -2,11 +2,11 @@ "nodes": { "den": { "locked": { - "lastModified": 1776710169, - "narHash": "sha256-q4WXIX2E3w9Ld3MZ1Pl8Lh5SgrEFdEuzvY1Lj/Wo2kY=", + "lastModified": 1779919306, + "narHash": "sha256-eo2Snm2hoYAq8MA7bfjnn8p36riLcpJDA74Fzx9gcsY=", "owner": "denful", "repo": "den", - "rev": "0af82e24be89b9fd400bd0b58b0fed5ea0f269ad", + "rev": "fba67817bd16955e10ff158c9758874031af089c", "type": "github" }, "original": { diff --git a/templates/example/flake.lock b/templates/example/flake.lock index 3dbcb6556..9c52a348f 100644 --- a/templates/example/flake.lock +++ b/templates/example/flake.lock @@ -22,11 +22,11 @@ }, "den": { "locked": { - "lastModified": 1776710169, - "narHash": "sha256-q4WXIX2E3w9Ld3MZ1Pl8Lh5SgrEFdEuzvY1Lj/Wo2kY=", + "lastModified": 1779919306, + "narHash": "sha256-eo2Snm2hoYAq8MA7bfjnn8p36riLcpJDA74Fzx9gcsY=", "owner": "denful", "repo": "den", - "rev": "0af82e24be89b9fd400bd0b58b0fed5ea0f269ad", + "rev": "fba67817bd16955e10ff158c9758874031af089c", "type": "github" }, "original": { diff --git a/templates/minimal/flake.lock b/templates/minimal/flake.lock index f41ba0f42..46a486632 100644 --- a/templates/minimal/flake.lock +++ b/templates/minimal/flake.lock @@ -2,11 +2,11 @@ "nodes": { "den": { "locked": { - "lastModified": 1776710169, - "narHash": "sha256-q4WXIX2E3w9Ld3MZ1Pl8Lh5SgrEFdEuzvY1Lj/Wo2kY=", + "lastModified": 1779919306, + "narHash": "sha256-eo2Snm2hoYAq8MA7bfjnn8p36riLcpJDA74Fzx9gcsY=", "owner": "denful", "repo": "den", - "rev": "0af82e24be89b9fd400bd0b58b0fed5ea0f269ad", + "rev": "fba67817bd16955e10ff158c9758874031af089c", "type": "github" }, "original": { diff --git a/templates/noflake/npins/sources.json b/templates/noflake/npins/sources.json index 5e2ef5668..1bdeb575f 100644 --- a/templates/noflake/npins/sources.json +++ b/templates/noflake/npins/sources.json @@ -9,9 +9,9 @@ }, "branch": "main", "submodules": false, - "revision": "0af82e24be89b9fd400bd0b58b0fed5ea0f269ad", - "url": "https://github.com/vic/den/archive/0af82e24be89b9fd400bd0b58b0fed5ea0f269ad.tar.gz", - "hash": "sha256-q4WXIX2E3w9Ld3MZ1Pl8Lh5SgrEFdEuzvY1Lj/Wo2kY=" + "revision": "fba67817bd16955e10ff158c9758874031af089c", + "url": "https://github.com/vic/den/archive/fba67817bd16955e10ff158c9758874031af089c.tar.gz", + "hash": "sha256-eo2Snm2hoYAq8MA7bfjnn8p36riLcpJDA74Fzx9gcsY=" }, "hjem": { "type": "Git", From c48412c8d5a2732cf11afb20c16e154bc314e3a5 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Wed, 10 Jun 2026 11:19:27 -0700 Subject: [PATCH 015/101] refactor(entities): own id_hash via gen-schema mkInstanceType MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build host/user/home submodules with gen-schema's mkInstanceType, which injects name, strict/freeform, _module.args., and schema-owned id_hash (gen-algebra mkIdentityModule) — replacing the hand-rolled id_hash reflection in resolvedCtxModule. Identity now shares the exact algorithm gen-schema's ref/setOf dedup compares against, removing the drift hazard between den's copy and the library's. Add an entity-gen-schema test pinning _roots: host is a root scope and user/home are not (the buildRoots root-detection contract). --- nix/lib/entities/_types.nix | 54 +--- nix/lib/entities/home.nix | 255 +++++++++--------- nix/lib/entities/host.nix | 238 ++++++++-------- .../internal-api/entity-gen-schema.nix | 18 ++ 4 files changed, 274 insertions(+), 291 deletions(-) diff --git a/nix/lib/entities/_types.nix b/nix/lib/entities/_types.nix index b32d2a983..d0ec3b998 100644 --- a/nix/lib/entities/_types.nix +++ b/nix/lib/entities/_types.nix @@ -66,58 +66,14 @@ let builtins.attrNames (den.schema or { }) ); - # Option type names whose values are safe for identity hashing. - primitiveTypeNames = [ - "str" - "int" - "bool" - ]; - - # Module injected into entity submodules for resolved aspect, id_hash, - # and collisionPolicy. Extracted here so host.nix, home.nix, and future - # entity types all share it. + # Module injected into entity submodules for resolved aspect and + # collisionPolicy. Extracted here so host.nix, home.nix, and future entity + # types all share it. Identity (id_hash) is owned by the schema — supplied + # by gen-schema's mkInstanceType via mkIdentityModule — not duplicated here. resolvedCtxModule = kind: + { config, ... }: { - config, - options, - ... - }: - { - options.id_hash = lib.mkOption { - description = '' - Auto-computed identity hash for entity comparison. - - Derived by reflecting on all non-internal, primitive-typed - options (str, int, bool) declared on this entity. The schema - kind is included to prevent cross-kind collisions. - - Use `a.id_hash != b.id_hash` instead of `a != b` for entity - comparison — Nix's `==` does deep structural comparison which - is fragile across module system boundaries. - ''; - readOnly = true; - internal = true; - type = lib.types.str; - default = - let - isPrimitive = - name: opt: - !(lib.hasPrefix "_" name) - && (opt ? type) - && builtins.elem (opt.type.name or "") primitiveTypeNames - && !(opt.internal or false); - identityKeys = lib.sort (a: b: a < b) (builtins.attrNames (lib.filterAttrs isPrimitive options)); - encode = - k: - let - v = config.${k}; - in - "${k}=${toString v}"; - fingerprint = "${kind}|${lib.concatMapStringsSep "|" encode identityKeys}"; - in - builtins.hashString "sha256" fingerprint; - }; options.resolved = lib.mkOption { description = "The resolved aspect for this ${kind}."; readOnly = true; diff --git a/nix/lib/entities/home.nix b/nix/lib/entities/home.nix index b408e1822..a9a811d6f 100644 --- a/nix/lib/entities/home.nix +++ b/nix/lib/entities/home.nix @@ -17,6 +17,10 @@ let preprocessHosts ; + # Entity instances are gen-schema instances: mkInstanceType injects name, + # strict/freeform, _module.args., and schema-owned id_hash (identity). + schemaLib = inputs.gen-schema.lib; + # Recursive merge without forcing leaf values. # Unlike lib.types.anything, this does not inspect values deeply (no # mapAttrsRecursiveCond), avoiding infinite recursion when values @@ -58,139 +62,142 @@ let homeType = system: - lib.types.submodule ( - { name, config, ... }: - let - parts = builtins.split "@" name; - nameWithHost = builtins.length parts > 1; - userName = lib.head parts; - hostName = if nameWithHost then lib.last parts else null; - hostByName = if hostName != null then den.hosts.${system}.${hostName} or null else null; - userByName = if hostByName != null then hostByName.users.${userName} or null else null; + schemaLib.mkInstanceType den.schema.home { + strict = false; + extraModules = [ + (resolvedCtxModule "home") + ( + { name, config, ... }: + let + parts = builtins.split "@" name; + nameWithHost = builtins.length parts > 1; + userName = lib.head parts; + hostName = if nameWithHost then lib.last parts else null; + hostByName = if hostName != null then den.hosts.${system}.${hostName} or null else null; + userByName = if hostByName != null then hostByName.users.${userName} or null else null; # A home named `user@host` carries a host identity even when that host - # isn't declared in `den.hosts`. Synthesize a minimal `{ name = ...; }` - # so host-keyed provides/policies (which match on `host.name`) resolve - # for an otherwise-standalone home — without instantiating a real host - # entity, which would pull in its platform builder (e.g. nix-darwin). - # A declared host always wins and remains the only thing that wires - # `osConfig`. - # - # Only `host` is synthesized, never `user`: a synthetic host alone fires - # `{ host }`-keyed policies (gated on `host ? class` so OS-class routing - # stays inert for a classless synthetic host), while `{ host, user }`-keyed - # OS batteries (define-user, user-to-host, …) keep their existing - # null-user gating and fall back to their home-scope path. - hostCtx = - if hostByName != null then - hostByName - else if nameWithHost then - { name = hostName; } - else - null; + # isn't declared in `den.hosts`. Synthesize a minimal `{ name = ...; }` + # so host-keyed provides/policies (which match on `host.name`) resolve + # for an otherwise-standalone home — without instantiating a real host + # entity, which would pull in its platform builder (e.g. nix-darwin). + # A declared host always wins and remains the only thing that wires + # `osConfig`. + # + # Only `host` is synthesized, never `user`: a synthetic host alone fires + # `{ host }`-keyed policies (gated on `host ? class` so OS-class routing + # stays inert for a classless synthetic host), while `{ host, user }`-keyed + # OS batteries (define-user, user-to-host, …) keep their existing + # null-user gating and fall back to their home-scope path. + hostCtx = + if hostByName != null then + hostByName + else if nameWithHost then + { name = hostName; } + else + null; homeManagerConfiguration = - if nameWithHost && hostByName != null then - { pkgs, modules }: - inputs.home-manager.lib.homeManagerConfiguration { - inherit pkgs modules; - extraSpecialArgs.osConfig = lib.attrByPath ( - [ "flake" ] ++ hostByName.intoAttr ++ [ "config" ] - ) null top.config; - } - else - inputs.home-manager.lib.homeManagerConfiguration; - in - { - freeformType = lib.types.attrsOf lib.types.anything; - imports = [ - den.schema.home - (resolvedCtxModule "home") - ]; - config._module.args.home = config; - config._module.args.host = hostCtx; - config._module.args.user = userByName; - options = { - name = strOpt "home configuration name" userName; + if nameWithHost && hostByName != null then + { pkgs, modules }: + inputs.home-manager.lib.homeManagerConfiguration { + inherit pkgs modules; + extraSpecialArgs.osConfig = lib.attrByPath ( + [ "flake" ] ++ hostByName.intoAttr ++ [ "config" ] + ) null top.config; + } + else + inputs.home-manager.lib.homeManagerConfiguration; + in + { + # mkInstanceType defaults name to the registry key (e.g. "tux@igloo"); + # den's name is the bare user name, so identity/description stay stable. + config.name = lib.mkForce userName; + config._module.args.host = hostCtx; + config._module.args.user = userByName; + options = { + userName = strOpt "user account name" userName; - hostName = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = hostName; - description = "host name (null for unbound standalone homes)"; - }; - user = lib.mkOption { - default = userByName; - defaultText = lib.literalExpression "user"; - }; - host = lib.mkOption { - default = hostCtx; - defaultText = lib.literalExpression "host"; - }; - system = strOpt "platform system" system; - class = strOpt "home management nix class" "homeManager"; - aspect = lib.mkOption { - description = "Aspect that configures this home."; - type = lib.types.raw; # no merging - defaultText = "den.aspects."; - default = lookupAspect den config; - }; - description = strOpt "home description" "home.${config.name}@${config.system}"; - pkgs = lib.mkOption { - description = '' - nixpkgs instance used to build the home configuration. - ''; - example = lib.literalExpression ''inputs.nixpkgs.legacyPackages.''${home.system}''; - type = lib.types.raw; - defaultText = lib.literalExpression ''inputs.nixpkgs.legacyPackages.''${home.system}''; - default = inputs.nixpkgs.legacyPackages.${config.system}; - }; - instantiate = lib.mkOption { - description = '' - Function used to instantiate the home configuration. + hostName = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = hostName; + description = "host name (null for unbound standalone homes)"; + }; + user = lib.mkOption { + default = userByName; + defaultText = lib.literalExpression "user"; + }; + host = lib.mkOption { + default = hostCtx; + defaultText = lib.literalExpression "host"; + }; + system = strOpt "platform system" system; + class = strOpt "home management nix class" "homeManager"; + aspect = lib.mkOption { + description = "Aspect that configures this home."; + type = lib.types.raw; # no merging + defaultText = "den.aspects."; + default = lookupAspect den config; + }; + description = strOpt "home description" "home.${config.name}@${config.system}"; + pkgs = lib.mkOption { + description = '' + nixpkgs instance used to build the home configuration. + ''; + example = lib.literalExpression ''inputs.nixpkgs.legacyPackages.''${home.system}''; + type = lib.types.raw; + defaultText = lib.literalExpression ''inputs.nixpkgs.legacyPackages.''${home.system}''; + default = inputs.nixpkgs.legacyPackages.${config.system}; + }; + instantiate = lib.mkOption { + description = '' + Function used to instantiate the home configuration. - Depending on class, defaults to: - `homeManager`: inputs.home-manager.lib.homeManagerConfiguration + Depending on class, defaults to: + `homeManager`: inputs.home-manager.lib.homeManagerConfiguration - Set explicitly if you need: + Set explicitly if you need: - - a custom input name, eg, home-manager-unstable. - - adding extraSpecialArgs when absolutely required. - ''; - example = lib.literalExpression "inputs.home-manager.lib.homeManagerConfiguration"; - type = lib.types.raw; - defaultText = lib.literalExpression "inputs.home-manager.lib.homeManagerConfiguration"; - default = - { - homeManager = homeManagerConfiguration; - } - .${config.class}; - }; - intoAttr = lib.mkOption { - description = '' - Flake attr where to add the named result of this configuration. - flake.. + - a custom input name, eg, home-manager-unstable. + - adding extraSpecialArgs when absolutely required. + ''; + example = lib.literalExpression "inputs.home-manager.lib.homeManagerConfiguration"; + type = lib.types.raw; + defaultText = lib.literalExpression "inputs.home-manager.lib.homeManagerConfiguration"; + default = + { + homeManager = homeManagerConfiguration; + } + .${config.class}; + }; + intoAttr = lib.mkOption { + description = '' + Flake attr where to add the named result of this configuration. + flake.. - Depending on class, defaults to: - `homeManager`: homeConfigurations - ''; - example = lib.literalExpression ''[ "homeConfigurations" userName ]''; - type = lib.types.listOf lib.types.str; - defaultText = lib.literalExpression ''[ "homeConfigurations" userName ]''; - default = - { - homeManager = [ - "homeConfigurations" - name - ]; - } - .${config.class}; - }; - mainModule = mainModuleOption den config; - __resolveResult = resolveResultOption den config; - __pathSetByScope = pathSetByScopeOption den config; - }; - } - ); + Depending on class, defaults to: + `homeManager`: homeConfigurations + ''; + example = lib.literalExpression ''[ "homeConfigurations" userName ]''; + type = lib.types.listOf lib.types.str; + defaultText = lib.literalExpression ''[ "homeConfigurations" userName ]''; + default = + { + homeManager = [ + "homeConfigurations" + name + ]; + } + .${config.class}; + }; + mainModule = mainModuleOption den config; + __resolveResult = resolveResultOption den config; + __pathSetByScope = pathSetByScopeOption den config; + }; + } + ) + ]; + }; in { inherit homesOption; diff --git a/nix/lib/entities/host.nix b/nix/lib/entities/host.nix index 5d7947b6a..0c790d8ba 100644 --- a/nix/lib/entities/host.nix +++ b/nix/lib/entities/host.nix @@ -17,6 +17,10 @@ let preprocessHosts ; + # Entity instances are gen-schema instances: mkInstanceType injects name, + # strict/freeform, _module.args., and schema-owned id_hash (identity). + schemaLib = inputs.gen-schema.lib; + # Recursive merge without forcing leaf values. # Unlike lib.types.anything, this does not inspect values deeply (no # mapAttrsRecursiveCond), avoiding infinite recursion when values @@ -59,131 +63,129 @@ let hostType = system: - lib.types.submodule ( - { name, config, ... }: - { - freeformType = lib.types.attrsOf lib.types.anything; - imports = [ - den.schema.host - (resolvedCtxModule "host") - ]; - config._module.args.host = config; - options = { - name = strOpt "host configuration name" name; - hostName = strOpt "Network hostname" config.name; - system = strOpt "platform system" system; - class = strOpt "os-configuration nix class for host" ( - if lib.hasSuffix "darwin" config.system then "darwin" else "nixos" - ); - aspect = lib.mkOption { - description = "Aspect that configures this host."; - type = lib.types.raw; # no merging - defaultText = "den.aspects."; - default = lookupAspect den config; - }; - description = strOpt "host description" "${config.class}.${config.hostName}@${config.system}"; - users = lib.mkOption { - description = "user accounts"; - default = { }; - defaultText = lib.literalExpression "{ }"; - type = lib.types.attrsOf (userType config); - }; - instantiate = lib.mkOption { - description = '' - Function used to instantiate the OS configuration. + schemaLib.mkInstanceType den.schema.host { + strict = false; + extraModules = [ + (resolvedCtxModule "host") + ( + { config, ... }: + { + options = { + hostName = strOpt "Network hostname" config.name; + system = strOpt "platform system" system; + class = strOpt "os-configuration nix class for host" ( + if lib.hasSuffix "darwin" config.system then "darwin" else "nixos" + ); + aspect = lib.mkOption { + description = "Aspect that configures this host."; + type = lib.types.raw; # no merging + defaultText = "den.aspects."; + default = lookupAspect den config; + }; + description = strOpt "host description" "${config.class}.${config.hostName}@${config.system}"; + users = lib.mkOption { + description = "user accounts"; + default = { }; + defaultText = lib.literalExpression "{ }"; + type = lib.types.attrsOf (userType config); + }; + instantiate = lib.mkOption { + description = '' + Function used to instantiate the OS configuration. - Depending on class, defaults to: - `darwin`: inputs.darwin.lib.darwinSystem - `nixos`: inputs.nixpkgs.lib.nixosSystem - `systemManager`: inputs.system-manager.lib.makeSystemConfig + Depending on class, defaults to: + `darwin`: inputs.darwin.lib.darwinSystem + `nixos`: inputs.nixpkgs.lib.nixosSystem + `systemManager`: inputs.system-manager.lib.makeSystemConfig - Set explicitly if you need: + Set explicitly if you need: - - a custom input name, eg, nixos-unstable. - - adding specialArgs when absolutely required. - ''; - example = lib.literalExpression "inputs.nixpkgs.lib.nixosSystem"; - type = lib.types.raw; - defaultText = lib.literalExpression "inputs.nixpkgs.lib.nixosSystem"; - default = - { - nixos = inputs.nixpkgs.lib.nixosSystem; - darwin = inputs.darwin.lib.darwinSystem; - systemManager = inputs.system-manager.lib.makeSystemConfig; - } - .${config.class}; - }; - intoAttr = lib.mkOption { - description = '' - Flake attr where to add the named result of this configuration. - flake.. + - a custom input name, eg, nixos-unstable. + - adding specialArgs when absolutely required. + ''; + example = lib.literalExpression "inputs.nixpkgs.lib.nixosSystem"; + type = lib.types.raw; + defaultText = lib.literalExpression "inputs.nixpkgs.lib.nixosSystem"; + default = + { + nixos = inputs.nixpkgs.lib.nixosSystem; + darwin = inputs.darwin.lib.darwinSystem; + systemManager = inputs.system-manager.lib.makeSystemConfig; + } + .${config.class}; + }; + intoAttr = lib.mkOption { + description = '' + Flake attr where to add the named result of this configuration. + flake.. - Depending on class, defaults to: - `darwin`: darwinConfigurations - `nixos`: nixosConfigurations - `systemManager`: systemConfigs - ''; - example = lib.literalExpression ''[ "nixosConfigurations" hostName ]''; - type = lib.types.listOf lib.types.str; - defaultText = lib.literalExpression ''[ "nixosConfigurations" hostName ]''; - default = - { - nixos = [ - "nixosConfigurations" - config.name - ]; - darwin = [ - "darwinConfigurations" - config.name - ]; - systemManager = [ - "systemConfigs" - config.name - ]; - } - .${config.class}; - }; - mainModule = mainModuleOption den config; - __resolveResult = resolveResultOption den config; - __pathSetByScope = pathSetByScopeOption den config; - }; - } - ); + Depending on class, defaults to: + `darwin`: darwinConfigurations + `nixos`: nixosConfigurations + `systemManager`: systemConfigs + ''; + example = lib.literalExpression ''[ "nixosConfigurations" hostName ]''; + type = lib.types.listOf lib.types.str; + defaultText = lib.literalExpression ''[ "nixosConfigurations" hostName ]''; + default = + { + nixos = [ + "nixosConfigurations" + config.name + ]; + darwin = [ + "darwinConfigurations" + config.name + ]; + systemManager = [ + "systemConfigs" + config.name + ]; + } + .${config.class}; + }; + mainModule = mainModuleOption den config; + __resolveResult = resolveResultOption den config; + __pathSetByScope = pathSetByScopeOption den config; + }; + } + ) + ]; + }; userType = host: - lib.types.submodule ( - { name, config, ... }: - { - freeformType = lib.types.attrsOf lib.types.anything; - imports = [ - den.schema.user - (resolvedCtxModule "user") - ]; - config._module.args.host = host; - config._module.args.user = config; - options = { - name = strOpt "user configuration name" name; - userName = strOpt "user account name" name; - classes = lib.mkOption { - type = lib.types.listOf lib.types.str; - description = "home management nix classes"; - defaultText = lib.literalExpression ''[ "user" ]''; - default = [ "user" ]; - }; - aspect = lib.mkOption { - description = "Aspect that configures this user."; - type = lib.types.raw; # no merging - defaultText = "den.aspects."; - default = lookupAspect den config; - }; - host = lib.mkOption { - default = host; - defaultText = lib.literalExpression "host"; - }; - }; - } - ); + schemaLib.mkInstanceType den.schema.user { + strict = false; + extraModules = [ + (resolvedCtxModule "user") + ( + { config, ... }: + { + config._module.args.host = host; + options = { + userName = strOpt "user account name" config.name; + classes = lib.mkOption { + type = lib.types.listOf lib.types.str; + description = "home management nix classes"; + defaultText = lib.literalExpression ''[ "user" ]''; + default = [ "user" ]; + }; + aspect = lib.mkOption { + description = "Aspect that configures this user."; + type = lib.types.raw; # no merging + defaultText = "den.aspects."; + default = lookupAspect den config; + }; + host = lib.mkOption { + default = host; + defaultText = lib.literalExpression "host"; + }; + }; + } + ) + ]; + }; in { inherit hostsOption; diff --git a/templates/ci/modules/internal-api/entity-gen-schema.nix b/templates/ci/modules/internal-api/entity-gen-schema.nix index 935e92c20..711e74523 100644 --- a/templates/ci/modules/internal-api/entity-gen-schema.nix +++ b/templates/ci/modules/internal-api/entity-gen-schema.nix @@ -65,6 +65,24 @@ } ); + # _roots (parentless kinds) is the buildRoots root-detection contract: + # host is a root scope; user/home are spawned under host, not roots. + test-entity-roots = denTest ( + { den, ... }: + { + expr = { + hostIsRoot = builtins.elem "host" den.schema._roots; + userNotRoot = builtins.elem "user" den.schema._roots; + homeNotRoot = builtins.elem "home" den.schema._roots; + }; + expected = { + hostIsRoot = true; + userNotRoot = false; + homeNotRoot = false; + }; + } + ); + # Schema entry has isEntity computed correctly test-entity-is-entity = denTest ( { den, ... }: From dac6c26229ed413b60e8a96d01937943fe010524 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Wed, 10 Jun 2026 11:19:36 -0700 Subject: [PATCH 016/101] fix(aspects): reserved keys pass their values through unwrapped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit den.reservedKeys fed structuralKeysSet, which the pipeline classifier and the synthetic-_ childKeys filter honored — but the aspect submodule's freeform value type (aspectKeyType) wrapped every undeclared key into the __contentValues/__provider provenance shape regardless. A reserved key was excluded from dispatch but its value was still mangled, so consumers could not read it back as the metadata den.reservedKeys promises. Route structural/reserved keys through a passthrough merge (last def wins) in aspectKeyType — the per-key dispatch the type comment already anticipated. den.aspects.. now returns the raw value. --- nix/lib/aspects/types.nix | 11 ++++-- .../ci/modules/public-api/reserved-keys.nix | 36 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 templates/ci/modules/public-api/reserved-keys.nix diff --git a/nix/lib/aspects/types.nix b/nix/lib/aspects/types.nix index 0e3f68434..d2dd86d09 100644 --- a/nix/lib/aspects/types.nix +++ b/nix/lib/aspects/types.nix @@ -599,14 +599,21 @@ let aspectKeyType = typeCfg: let - classReg = den.classes or { }; contentType = aspectContentType typeCfg; + inherit (den.lib.aspects.fx.keyClassification) structuralKeysSet; in lib.types.mkOptionType { name = "aspectKey"; description = "class module or nested aspect (dispatch by registry)"; check = _: true; - merge = loc: defs: contentType.merge loc defs; + # Reserved/structural keys are metadata, not aspect content: pass their + # value through untouched (last def wins) so consumers read it back as + # declared. Without this, the content wrapper mangles the value into a + # __contentValues/__provider shape even though the pipeline ignores the + # key for dispatch. Everything else gets the provenance/content wrapper. + merge = + loc: defs: + if structuralKeysSet ? ${lib.last loc} then (lib.last defs).value else contentType.merge loc defs; }; # Aspect meta submodule type: handleWith, provider, collisionPolicy. diff --git a/templates/ci/modules/public-api/reserved-keys.nix b/templates/ci/modules/public-api/reserved-keys.nix new file mode 100644 index 000000000..7fc3bc484 --- /dev/null +++ b/templates/ci/modules/public-api/reserved-keys.nix @@ -0,0 +1,36 @@ +# den.reservedKeys lets a config mark extra aspect keys as structural. The +# pipeline skips them (no class/nested/pipe dispatch) and the aspect type +# leaves their values untouched, so consumers can use them for free-form +# metadata and read them back exactly as declared. +{ denTest, ... }: +{ + flake.tests.reserved-keys = { + # A reserved key carries metadata: the rest of the aspect resolves + # normally, and the key's value passes through unwrapped. Without + # reservation `settings` would be dispatched and its value content-wrapped. + test-reserved-key-is-metadata = denTest ( + { den, igloo, ... }: + { + den.reservedKeys = [ "settings" ]; + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.aspects.igloo = { + settings = { + theme = "dark"; + }; + nixos.networking.hostName = "reserved-test"; + }; + + expr = { + resolves = igloo.networking.hostName; + metadata = den.aspects.igloo.settings; + }; + expected = { + resolves = "reserved-test"; + metadata = { + theme = "dark"; + }; + }; + } + ); + }; +} From 3aa188d17bc2d4da66ee2832f9289066a35e1b53 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Wed, 10 Jun 2026 11:44:04 -0700 Subject: [PATCH 017/101] fix(policy): restore `self` guard dropped in rebase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `self` dispatch guard (fire-once at the registration scope; always bound in ctx so flake-scope resolution policies can fire) was added on this branch but never made it into origin/main's squash of the cherry-picked fixes — #603 covered spawn subtree routes and multi-def identity, not `self`. Rebasing onto origin/main therefore silently dropped it. Without `self`, `{ self, ... }:` policies never dispatch (flake isn't bound in its own ctx), collapsing any flake-scope resolution cascade — e.g. a consumer's flake -> fleet -> environment -> host walk produces zero host outputs. Re-applies the dispatch.nix `self` injection and the policy/schema.nix late-fan-out exclusion, plus the self-guard regression test. --- nix/lib/aspects/fx/policy/dispatch.nix | 14 +++- nix/lib/aspects/fx/policy/schema.nix | 21 +++--- .../ci/modules/internal-api/self-guard.nix | 74 +++++++++++++++++++ .../modules/internal-api/zz-repro-colmena.nix | 23 ++++++ 4 files changed, 121 insertions(+), 11 deletions(-) create mode 100644 templates/ci/modules/internal-api/self-guard.nix create mode 100644 templates/ci/modules/internal-api/zz-repro-colmena.nix diff --git a/nix/lib/aspects/fx/policy/dispatch.nix b/nix/lib/aspects/fx/policy/dispatch.nix index 3ca0c04fa..79d7fc194 100644 --- a/nix/lib/aspects/fx/policy/dispatch.nix +++ b/nix/lib/aspects/fx/policy/dispatch.nix @@ -39,15 +39,25 @@ let dispatchAspect = aspectPolicies: firedPolicies: resolveCtx: let + # `self` is always bound to the dispatching scope's context, so a policy + # can guard with `{ self, ... }:` to mean "fire at my registration scope" + # — distinct from `{ , ... }:` which fans across entities of that + # kind. The binding must be present (not just allowed) because Nix's + # attrset pattern requires every named arg, used or not. See the + # late-dispatch filter in policy/schema.nix, which keeps `self`-guarded + # policies from re-firing at descendant scopes. + ctx = resolveCtx // { + self = resolveCtx.self or resolveCtx; + }; entries = lib.attrsToList aspectPolicies; matching = builtins.filter ( - e: resolveArgsSatisfied e.value.fn resolveCtx && !(firedPolicies ? ${e.name}) + e: resolveArgsSatisfied e.value.fn ctx && !(firedPolicies ? ${e.name}) ) entries; in map ( entry: let - result = entry.value.fn resolveCtx; + result = entry.value.fn ctx; rawEffects = if builtins.isList result then result else [ result ]; in { diff --git a/nix/lib/aspects/fx/policy/schema.nix b/nix/lib/aspects/fx/policy/schema.nix index d1dcefaa8..b68d9e928 100644 --- a/nix/lib/aspects/fx/policy/schema.nix +++ b/nix/lib/aspects/fx/policy/schema.nix @@ -168,17 +168,20 @@ let entityKinds = den.lib.schemaUtil.schemaEntityKinds; latePolicies = lib.filterAttrs ( name: policy: + let + policyArgs = builtins.functionArgs (policy.fn or policy); + requiredEntityArgs = builtins.filter ( + k: builtins.elem k entityKinds && !(policyArgs.${k} or false) + ) (builtins.attrNames policyArgs); + in !(alreadyFired ? ${name}) && !(parentFiredPolicies ? ${name}) - && ( - let - policyArgs = builtins.functionArgs (policy.fn or policy); - requiredEntityArgs = builtins.filter ( - k: builtins.elem k entityKinds && !(policyArgs.${k} or false) - ) (builtins.attrNames policyArgs); - in - requiredEntityArgs == [ ] || builtins.elem sib.targetKind requiredEntityArgs - ) + # `self`-guarded policies fire only at their own registration scope + # (during the initial dispatch); they must never re-fire at descendant + # scopes via the fan-out. This is what makes `{ self, ... }:` mean + # "fire once here", as opposed to `{ , ... }:` which fans. + && !(policyArgs ? self) + && (requiredEntityArgs == [ ] || builtins.elem sib.targetKind requiredEntityArgs) ) allAspectPolicies; in fx.bind fx.effects.state.get ( diff --git a/templates/ci/modules/internal-api/self-guard.nix b/templates/ci/modules/internal-api/self-guard.nix new file mode 100644 index 000000000..ae0a6ba3d --- /dev/null +++ b/templates/ci/modules/internal-api/self-guard.nix @@ -0,0 +1,74 @@ +# `{ self, ... }:` — a policy guard meaning "fire once at my own registration +# scope; never fan to descendant scopes". Distinct from `{ , ... }:`, +# which fans across every entity of that kind. +# +# `self` is always bound in the dispatch ctx (to the scope's context), so a +# policy can use it even when the scope's own kind isn't a ctx binding — this +# is what lets a flake-scope resolution policy fire, where `{ flake, ... }:` +# could not (flake is not bound in its own ctx). +{ denTest, ... }: +{ + flake.tests.self-guard = { + # A self-guarded policy is dispatchable and fires at its registration scope. + test-self-fires-at-registration-scope = denTest ( + { + den, + igloo, + ... + }: + let + inherit (den.lib.policy) include; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.aspects.igloo.policies.self-fire = + { self, ... }: [ (include { nixos.environment.variables.DEN_SELF = "fired"; }) ]; + den.aspects.igloo.includes = [ den.aspects.igloo.policies.self-fire ]; + expr = igloo.environment.variables.DEN_SELF or "no"; + expected = "fired"; + } + ); + + # Registered at the host, `{ self, ... }:` fires once at the host and does + # NOT fan to the user children — whereas `{ user, ... }:` fans to each user. + test-self-does-not-fan-to-children = denTest ( + { + den, + tuxHm, + pinguHm, + ... + }: + let + inherit (den.lib.policy) include; + in + { + den.hosts.x86_64-linux.igloo.users = { + tux = { }; + pingu = { }; + }; + # fans: fires once per user, configuring each. + den.aspects.igloo.policies.fan = + { user, ... }: [ (include { homeManager.programs.vim.enable = true; }) ]; + # self: fires once at the host scope; its user-targeted bit never lands. + den.aspects.igloo.policies.self-only = + { self, ... }: [ (include { homeManager.programs.emacs.enable = true; }) ]; + den.aspects.igloo.includes = [ + den.aspects.igloo.policies.fan + den.aspects.igloo.policies.self-only + ]; + expr = { + tuxVim = tuxHm.programs.vim.enable; + pinguVim = pinguHm.programs.vim.enable; + tuxEmacs = tuxHm.programs.emacs.enable or false; + pinguEmacs = pinguHm.programs.emacs.enable or false; + }; + expected = { + tuxVim = true; + pinguVim = true; + tuxEmacs = false; + pinguEmacs = false; + }; + } + ); + }; +} diff --git a/templates/ci/modules/internal-api/zz-repro-colmena.nix b/templates/ci/modules/internal-api/zz-repro-colmena.nix new file mode 100644 index 000000000..c7e382014 --- /dev/null +++ b/templates/ci/modules/internal-api/zz-repro-colmena.nix @@ -0,0 +1,23 @@ +{ denTest, ... }: +{ + flake.tests.zz-repro-colmena = { + test-schema-include-instantiate = denTest ( + { den, config, ... }: + { + den.policies.cap = { host, ... }: [ + (den.lib.policy.instantiate { + name = "${host.name}-mod"; + inherit (host) class; + instantiate = { modules, ... }: modules; + intoAttr = [ "capModules" host.name ]; + }) + ]; + den.schema.host.includes = [ den.policies.cap ]; + den.hosts.x86_64-linux.igloo.users.tux = { }; + + expr = (config.flake.capModules or { }) ? igloo; + expected = true; + } + ); + }; +} From a9b5c26aa145f6ba5407443b00e2ee38f9ebb1ea Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Wed, 10 Jun 2026 12:14:52 -0700 Subject: [PATCH 018/101] refactor(schema): derive kind lists from gen-schema _kindNames schema-util and entities/_types each hand-rolled `filter (k != "conf" && !hasPrefix "_")` over `attrNames den.schema`. gen-schema already exposes `_kindNames` (sorted, _-prefixed introspection keys excluded), so consume that as the canonical kind list and drop the duplicated _-prefix filtering. Entity detection still uses the schema's `isEntity` collection. --- nix/lib/entities/_types.nix | 8 +++---- nix/lib/schema-util.nix | 18 +++++++-------- .../modules/internal-api/zz-repro-colmena.nix | 23 ------------------- 3 files changed, 13 insertions(+), 36 deletions(-) delete mode 100644 templates/ci/modules/internal-api/zz-repro-colmena.nix diff --git a/nix/lib/entities/_types.nix b/nix/lib/entities/_types.nix index d0ec3b998..b29702a77 100644 --- a/nix/lib/entities/_types.nix +++ b/nix/lib/entities/_types.nix @@ -61,10 +61,10 @@ let default = config.__resolveResult.pathSetByScope; }; - # Entity kinds derived from the schema, excluding non-entity entries. - schemaKinds = builtins.filter (n: n != "conf" && !(lib.hasPrefix "_" n)) ( - builtins.attrNames (den.schema or { }) - ); + # Entity kinds from the schema's own kind list (gen-schema _kindNames is + # sorted and excludes _-prefixed introspection keys), minus the shared + # `conf` base. + schemaKinds = builtins.filter (n: n != "conf") (den.schema._kindNames or [ ]); # Module injected into entity submodules for resolved aspect and # collisionPolicy. Extracted here so host.nix, home.nix, and future entity diff --git a/nix/lib/schema-util.nix b/nix/lib/schema-util.nix index f6d01d7f5..60c80f6d9 100644 --- a/nix/lib/schema-util.nix +++ b/nix/lib/schema-util.nix @@ -4,20 +4,20 @@ ... }: let - schemaNames = builtins.attrNames (den.schema or { }); + # Canonical kind list from gen-schema introspection: _kindNames is sorted + # and already excludes _-prefixed introspection keys (_topology, _edges, …). + kindNames = den.schema._kindNames or [ ]; - # Canonical entity kind predicate: excludes conf, private keys, - # and non-entity schema entries. + # Canonical entity kind predicate: excludes the shared `conf` base and + # non-entity schema entries (isEntity computed by gen-schema). schemaEntityKinds = builtins.filter ( - k: k != "conf" && !(lib.hasPrefix "_" k) && (den.schema.${k}.isEntity or false) - ) schemaNames; + k: k != "conf" && (den.schema.${k}.isEntity or false) + ) kindNames; # Variant for class-module.nix warnings: all schema-like arg names - # (excludes conf, aspect, private keys) WITHOUT the isEntity check. + # (excludes conf, aspect) WITHOUT the isEntity check. # Used to detect missing den args in class module functions. - schemaArgKinds = builtins.filter ( - k: k != "conf" && k != "aspect" && !(lib.hasPrefix "_" k) - ) schemaNames; + schemaArgKinds = builtins.filter (k: k != "conf" && k != "aspect") kindNames; schemaEntityKindsSet = lib.genAttrs schemaEntityKinds (_: true); in { diff --git a/templates/ci/modules/internal-api/zz-repro-colmena.nix b/templates/ci/modules/internal-api/zz-repro-colmena.nix deleted file mode 100644 index c7e382014..000000000 --- a/templates/ci/modules/internal-api/zz-repro-colmena.nix +++ /dev/null @@ -1,23 +0,0 @@ -{ denTest, ... }: -{ - flake.tests.zz-repro-colmena = { - test-schema-include-instantiate = denTest ( - { den, config, ... }: - { - den.policies.cap = { host, ... }: [ - (den.lib.policy.instantiate { - name = "${host.name}-mod"; - inherit (host) class; - instantiate = { modules, ... }: modules; - intoAttr = [ "capModules" host.name ]; - }) - ]; - den.schema.host.includes = [ den.policies.cap ]; - den.hosts.x86_64-linux.igloo.users.tux = { }; - - expr = (config.flake.capModules or { }) ? igloo; - expected = true; - } - ); - }; -} From 658c13d28f49c0e5ee3bd484161e006b0cc67f6b Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Wed, 10 Jun 2026 21:06:46 -0700 Subject: [PATCH 019/101] feat(delivered-child-host): dedicated guest kind + route delivery (on entity-gen-schema-port base) --- modules/policies/delivered-child-host.nix | 348 +++++++++++++++++ .../internal-api/entity-gen-schema.nix | 3 + .../public-api/delivered-child-host.nix | 358 ++++++++++++++++++ 3 files changed, 709 insertions(+) create mode 100644 modules/policies/delivered-child-host.nix create mode 100644 templates/ci/modules/public-api/delivered-child-host.nix diff --git a/modules/policies/delivered-child-host.nix b/modules/policies/delivered-child-host.nix new file mode 100644 index 000000000..2fa7fe486 --- /dev/null +++ b/modules/policies/delivered-child-host.nix @@ -0,0 +1,348 @@ +# Delivered child host — a reusable primitive for nesting a *guest* host +# inside a *parent* host's instantiated configuration (e.g. a microvm guest +# realized into parent.microvm.vms..config) instead of producing a +# standalone nixosConfigurations. output. +# +# ===================== DESIGN (resolved by two spikes) ===================== +# +# DELIVERY = resolve + class-isolation + route+collectSubtree. NO den-core +# (nix/) change. Three EXISTING mechanisms composed: +# +# 1. resolve.to "" { delivered-guest = guest; host = guest; } +# nests the guest as a child entity scope under the parent host. The +# `host` binding lets curated host-include policies (host-to-users, the +# home batteries, den.default) fire for the guest; the `delivered-guest` +# binding makes resolveEntityClass derive the guest's distinct class. +# +# 2. guest.class = "guest-os" (a DISTINCT class) isolates the child's walked +# content from the parent's own `nixos` partition. Without it the guest's +# modules flatten into the parent's top-level nixos config. +# +# 3. route { fromClass = "guest-os"; intoClass = "nixos"; collectSubtree; +# path = [ "microvm" "vms" "" "config" ]; } collects the +# guest-os content from the ENTIRE parent subtree (incl. the child scope) +# and nests it under the delivery path in the parent's nixos class BEFORE +# the parent is instantiated. The guest carries intoAttr = [] and gets NO +# policy.instantiate, so it produces NO standalone flake output. +# +# (redirect-instantiate — writing into flake.nixosConfigurations.

.config.* +# — is a DEAD END: nixosConfigurations is lazyAttrsOf raw, an already- +# evaluated nixosSystem; writing its .config subpath collides with the +# read-only result instead of injecting a module.) +# +# KIND = a DEDICATED `delivered-guest` kind whose `includes` are a CURATED +# subset of `den.schema.host.includes`, DERIVED (not hand-copied) so it tracks +# host.includes: +# INHERIT participation/identity/collect includes (host-to-users, the home +# batteries, den.default). +# OMIT includes producing a standalone instantiate output (nix-config's +# colmena host-modules-capture) — a child must not instantiate. Named +# via `den.deliveredChild.omitIncludeNames`. No-op in den-only tests +# (colmena is a nix-config host-include, not a den one). +# RETARGET / OVERRIDE agenix-style host-includes whose class lookup or key +# paths assume the host class: the consumer points public_key / +# secret sources at the parent and targets guest-os. Expressed as +# ordinary guest-targeted includes the consumer adds. +# ADD a guest-os home-env instance (supportedOses ∋ guest-os) so +# home-manager synthesis fires for the guest, plus a guest-os +# stateVersion default. +# =========================================================================== +{ + den, + config, + lib, + inputs, + ... +}: +let + inherit (den.lib.policy) resolve route; + + guestClass = "guest-os"; + guestKind = "delivered-guest"; + + cfg = config.den.deliveredChild; + + # An include entry's stable identifier, used for OMIT filtering. Named + # policy includes carry `.name`; bare functions/attrsets have none. + includeName = inc: if builtins.isAttrs inc && inc ? name then inc.name else null; + + # CURATED includes for the guest kind, DERIVED from host.includes so the + # guest tracks the host's participation surface minus the omitted entries. + # (Read host.includes; we never write back to it from here, so no cycle.) + hostIncludes = config.den.schema.host.includes or [ ]; + curatedFromHost = builtins.filter ( + inc: + let + n = includeName inc; + in + n == null || !(builtins.elem n cfg.omitIncludeNames) + ) hostIncludes; + + # The guest class is always nixos-flavored, so the hardcoded `.nixosModules` + # accessor is correct here (vs the class-keyed `"${host.class}Modules"` that + # the normal host battery uses — guest-os has no *Modules input attr of its own). + hmNixosModule = inputs.home-manager.nixosModules.home-manager; + + # ADD: a guest-os home-env instance so home-manager synthesis (gated on + # host.class ∈ supportedOses) fires for a guest-os child. getModule pins the + # parent's nixos home-manager module (the guest-os class has no *Modules + # input attr of its own). + # + # makeHomeEnv returns THREE parts that are ALL wired (mirroring + # modules/aspects/batteries/home-manager.nix for the normal host): + # - guestHome.hostConf → the guest kind's host-submodule imports + # (den.schema.delivered-guest.imports). Defines the `home-manager.enable` + # /`.module` options ON the guest; without them mkDetectHost's + # `isEnabled = (host.home-manager or {}).enable or false` is false → + # detection short-circuits, and the home-manager module pinned for the + # guest's downstream re-instantiation is unavailable. + # - guestHome.battery → the guest kind's includes (host→user detection + + # the host-scope home-manager module import into guest-os). + # - guestHome.userDetect → the USER kind's includes (per-user detection; + # gated on host.class ∈ supportedOses = [guest-os], so a no-op for + # non-guest hosts). + # + # NOTE: the battery forwards each user's homeManager content from a NESTED + # user resolve sub-scope; that forward-route does not survive the parent's + # guest-os collectSubtree delivery route, so the actual per-user delivery is + # done by guestHmUserForward below (a host-scope bridge). + guestHome = den.lib.home-env.makeHomeEnv { + className = "homeManager"; + ctxName = "guest-hm"; + supportedOses = [ guestClass ]; + optionPath = "home-manager"; + getModule = _: hmNixosModule; + forwardPathFn = + { user, ... }: + [ + "home-manager" + "users" + user.userName + ]; + }; + + # ADD: a guest-os stateVersion default. den.default targets nixos/homeManager + # classes, which the guest-os route does not collect, so the guest gets no + # stateVersion otherwise. + guestDefault = lib.optional (cfg.stateVersion != null) { + name = "delivered-guest-default"; + ${guestClass}.system.stateVersion = cfg.stateVersion; + }; + + # Per-user home-manager synthesis for the delivered guest. + # + # The home-env BATTERY (guestHome.battery, wired below) detects the guest's + # homeManager users and forwards each user's homeManager content via a + # homeManager → guest-os forward-route registered in a NESTED user resolve + # sub-scope. That sub-scope forward-route does not survive the parent's + # guest-os → nixos collectSubtree delivery route: forwards register routes + # that are applied per-scope, and the delivery route's per-module freeform + # nesting does not re-run them. So the battery alone yields NO + # home-manager.users content in the delivered config. + # + # This policy bridges that gap at the GUEST HOST scope (one level up, where + # collectSubtree reaches): it RESOLVES each homeManager user's homeManager + # content (den.lib.aspects.resolveImports — the same resolver the forward + # uses) and emits it as guest-os config under home-manager.users. as a + # `{ imports = [...]; }` module. The guest's real home-manager module + # (host.home-manager.module, pinned by the consumer / getModule) evaluates + # those imports when the microvm RE-INSTANTIATES the delivered config as the + # guest's own nixosSystem — exactly the standard home-manager.users. + # submodule contract. + guestHmUserForward = + { host, ... }: + let + hmUsers = lib.filter (u: lib.elem "homeManager" (u.classes or [ ])) ( + lib.attrValues (host.users or { }) + ); + userHmModule = + user: + den.lib.aspects.resolveImports "homeManager" (den.lib.resolveEntity "user" { inherit host user; }); + in + [ + (den.lib.policy.include { + ${guestClass}.home-manager.users = lib.listToAttrs ( + map (user: lib.nameValuePair user.userName (userHmModule user)) hmUsers + ); + }) + ]; + + # The guest kind's includes: curated host participation + the guest home-env + # (host-submodule options + host→user routing) + the per-user home-manager + # synthesis bridge + a guest-os stateVersion default + the expose policy. + curatedIncludes = + curatedFromHost + ++ [ + guestHome.battery + (den.lib.policy.mkPolicy "guest-hm-user-forward" guestHmUserForward) + (den.lib.policy.mkPolicy "expose-child-quirks" exposePolicy) + ] + ++ guestDefault; + + # The guest-os home-env module pinned by getModule. Materialized here because + # a RAW delivered guest bypasses the host submodule (gap G6), so the + # `home-manager.enable`/`.module` option DEFAULTS that guestHome.hostConf + # defines never apply to the `host` binding. We replicate those defaults on + # the raw guest record below (mirroring nix/lib/home-env.nix:hostOptions) so: + # - mkDetectHost sees `host.home-manager.enable` = true (a homeManager user + # exists) and does not short-circuit, and + # - the battery's hostModule can read `host.home-manager.module`. + guestHmModule = hmNixosModule; + guestHasHmUser = + guest: + builtins.any (u: builtins.elem "homeManager" (u.classes or [ ])) ( + builtins.attrValues (guest.users or { }) + ); + + # Per-child delivery: resolve the guest as a nested child entity, isolate it + # in the guest-os class, and route its content into the parent under the + # delivery path. + resolveChild = + _name: guest: + let + # Default the home-manager host option on the raw guest unless the guest + # already declares it (consumer override wins). + hmDefault = lib.optionalAttrs (!(guest ? home-manager)) { + home-manager = { + enable = guestHasHmUser guest; + module = guestHmModule; + }; + }; + withClass = + guest + // hmDefault + // { + class = guestClass; + intoAttr = [ ]; + }; + in + resolve.to guestKind { + ${guestKind} = withClass; + host = withClass; + }; + + routeChild = + name: _guest: + route { + fromClass = guestClass; + intoClass = "nixos"; + collectSubtree = true; + path = cfg.deliveryPathFor name; + }; + + resolvePolicy = { host, ... }: lib.mapAttrsToList resolveChild (host.deliveredChildren or { }); + + routePolicy = { host, ... }: lib.mapAttrsToList routeChild (host.deliveredChildren or { }); + + # EXPOSE policy — runs inside the GUEST scope (it is part of the guest kind's + # curated includes, NOT the parent's host.includes). pipe.expose must fire in + # the scope that EMITS the quirk so the value flows up to the parent. Only + # quirks registered in den.quirks are exposed; the default set is opt-in (a + # consumer declares ollama-endpoints / prometheus-targets in its fleet config), + # and referencing an undeclared quirk is a silent no-op. + exposePolicy = + { ... }: + map (q: den.lib.policy.pipe.from q [ den.lib.policy.pipe.expose ]) ( + builtins.filter (q: den.quirks or { } ? ${q}) cfg.exposeQuirks + ); + + # Parent-host option: explicit, per-parent declaration of delivered children. + hostConf = { + options.deliveredChildren = lib.mkOption { + description = '' + Guest host entities delivered as nested children of this host. Each + guest is resolved in the `${guestClass}` class and routed into this + host's configuration at `den.deliveredChild.deliveryPathFor ` + instead of producing a standalone flake output. + ''; + type = lib.types.attrsOf lib.types.raw; + default = { }; + }; + }; +in +{ + config.den.classes.${guestClass}.description = "Delivered child guest host class"; + + config.den.schema.${guestKind} = { + isEntity = true; + parent = "host"; + includes = curatedIncludes; + # The guest-os home-env's host-submodule options (home-manager.enable / + # .module) must exist ON the guest, or mkDetectHost short-circuits and no + # synthesis fires. Mirrors `den.schema.host.imports = [ result.hostConf ]` + # in the normal home-manager battery, scoped to the guest kind. + imports = [ guestHome.hostConf ]; + }; + + # The guest-os home-env's per-user detection. Gated on + # host.class ∈ [guest-os], so it is a no-op for ordinary nixos/darwin users. + # Mirrors `den.schema.user.includes = [ result.userDetect ]`. + config.den.schema.user.includes = [ guestHome.userDetect ]; + + config.den.schema.host.imports = [ hostConf ]; + + config.den.policies.resolve-child-host = resolvePolicy; + config.den.policies.route-child-host = routePolicy; + + # Wire the PARENT delivery policies into every host scope. Both are GATED on + # host.deliveredChildren so non-parent hosts pay no cost (no-op include → + # byte-identical toplevel). The expose policy is NOT here — it lives in the + # guest kind's curated includes so pipe.expose fires in the guest scope that + # emits the quirk. + config.den.schema.host.includes = [ + den.policies.resolve-child-host + den.policies.route-child-host + ]; + + options.den.deliveredChild = { + deliveryPathFor = lib.mkOption { + description = '' + Function mapping a child name to the parent-config path the child's + content is routed into. Defaults to the microvm guest slot + `microvm.vms..config`; override for other delivery targets. + ''; + type = lib.types.functionTo (lib.types.listOf lib.types.str); + default = name: [ + "microvm" + "vms" + name + "config" + ]; + defaultText = lib.literalExpression ''name: [ "microvm" "vms" name "config" ]''; + }; + omitIncludeNames = lib.mkOption { + description = '' + Names of host-includes to OMIT from the curated guest kind. The default + targets nix-config's colmena `host-modules-capture` host-include, which + runs a `policy.instantiate` producing a standalone OS module list — a + delivered child must not instantiate. + + NOTE: colmena lives in nix-config (modules/den/batteries/colmena.nix), + NOT in den, so `host-modules-capture` is NOT a den host-include. This + omit is therefore a NO-OP in den-only tests (nothing to filter) and is + only exercised by the nix-config consumer. The name was verified against + nix-config: `den.policies.host-modules-capture` → + `den.schema.host.includes`, whose policy `.name` is "host-modules-capture". + ''; + type = lib.types.listOf lib.types.str; + default = [ "host-modules-capture" ]; + }; + exposeQuirks = lib.mkOption { + description = "Fleet quirks exposed from each delivered child up to the parent."; + type = lib.types.listOf lib.types.str; + default = [ + "ollama-endpoints" + "prometheus-targets" + ]; + }; + stateVersion = lib.mkOption { + description = '' + stateVersion default applied in the guest-os class (den.default's + nixos/homeManager defaults are not collected by the guest route). Set + to null to apply none. + ''; + type = lib.types.nullOr lib.types.str; + default = null; + }; + }; +} diff --git a/templates/ci/modules/internal-api/entity-gen-schema.nix b/templates/ci/modules/internal-api/entity-gen-schema.nix index 711e74523..87647c7c3 100644 --- a/templates/ci/modules/internal-api/entity-gen-schema.nix +++ b/templates/ci/modules/internal-api/entity-gen-schema.nix @@ -50,6 +50,9 @@ { expr = den.schema._topology.host.children; expected = [ + # delivered-guest: the delivered-child-host primitive registers a + # dedicated guest kind nested under host (modules/policies). + "delivered-guest" "home" "user" ]; diff --git a/templates/ci/modules/public-api/delivered-child-host.nix b/templates/ci/modules/public-api/delivered-child-host.nix new file mode 100644 index 000000000..596e6c604 --- /dev/null +++ b/templates/ci/modules/public-api/delivered-child-host.nix @@ -0,0 +1,358 @@ +# Acceptance tests for the delivered-child-host PRIMITIVE +# (modules/policies/delivered-child-host.nix). +# +# A delivered child is a guest host that resolves as a nested child scope under +# its parent host and is realized INTO the parent's config +# (microvm.vms..config) instead of producing a standalone +# nixosConfigurations. output. +# +# HARNESS NOTE: denTest only exposes INSTANTIATED hosts +# (config.flake.nixosConfigurations..config). A delivered child has NO +# denTest handle, so EVERY assertion observes the child THROUGH the parent's +# instantiated output (`igloo`), reading a child-sourced value back out of +# `igloo.microvm.vms..config.*`. +# +# The primitive supplies: a parent option (host.deliveredChildren), a dedicated +# `delivered-guest` kind whose includes are a curated subset of +# den.schema.host.includes, the delivery policy (resolve + class-isolation + +# route+collectSubtree), and an expose policy. Tests use the primitive — they +# declare children via den.hosts..igloo.deliveredChildren rather than +# hand-rolling the resolve/route pair. +{ denTest, lib, ... }: +let + # Minimal stand-in for the home-manager nixos module, used only to satisfy + # the guest's host-submodule home-manager.module option (detection). The real + # home-manager module self-references `config.home-manager` and only resolves + # when the guest is re-instantiated as its own nixosSystem; these unit tests + # never re-instantiate (the guest-os content is collected into the freeform + # microvm slot), so the per-user home-manager modules the primitive emits are + # re-evaluated explicitly in the assertion instead. Real consumers (nix-config) + # use the real home-manager module when the microvm genuinely instantiates. + hmStub = { + options.home-manager.users = lib.mkOption { + type = lib.types.lazyAttrsOf ( + lib.types.submodule { freeformType = lib.types.lazyAttrsOf lib.types.anything; } + ); + default = { }; + }; + }; + + # Stub of the microvm.vms..config slot the real microvm.nixos module + # provides on the PARENT. Freeform so delivered child config lands here. + microvmSlot = + { lib, ... }: + { + options.microvm.vms = lib.mkOption { + type = lib.types.attrsOf ( + lib.types.submodule { + options.config = lib.mkOption { + type = lib.types.submoduleWith { + modules = [ + { config._module.freeformType = lib.types.lazyAttrsOf lib.types.anything; } + ]; + }; + default = { }; + }; + } + ); + default = { }; + }; + }; + + # A guest entity. The primitive sets class/intoAttr; the realistic gap-table + # (G6) still applies: a raw delivered child bypasses the host submodule's + # userType, so user records must be FULL ({ name; userName; classes; }). + mkGuest = + den: extra: + { + name = "guest"; + system = "x86_64-linux"; + users = { }; + aspect = den.aspects.guest-aspect; + } + // extra; + + # Common parent wiring: the microvm slot on igloo. Returned as a module so it + # merges (NOT `//`, which would clobber the test body's own `den` attr). + parentBase = den: { + den.aspects.igloo.includes = [ den.aspects.microvm-slot ]; + den.aspects.microvm-slot.nixos.imports = [ microvmSlot ]; + }; +in +{ + flake.tests.delivered-child-host = { + + # DELIVERY (crux): parent reads a child-ONLY value back through its + # instantiated config. Declared purely via the primitive's parent option. + test-delivery = denTest ( + { den, igloo, ... }: + { + imports = [ (parentBase den) ]; + den.hosts.x86_64-linux.igloo.deliveredChildren.guest = mkGuest den { }; + den.aspects.guest-aspect.guest-os.networking.hostName = "guest-vm"; + + expr = igloo.microvm.vms.guest.config.networking.hostName; + expected = "guest-vm"; + } + ); + + # PARTICIPATION: a curated host-include value fires in the CHILD scope and + # arrives in the delivered config. The host-include emits into guest-os. + test-participation = denTest ( + { den, igloo, ... }: + { + imports = [ (parentBase den) ]; + den.hosts.x86_64-linux.igloo.deliveredChildren.guest = mkGuest den { }; + den.schema.host.includes = [ + { guest-os.boot.kernelModules = [ "from-host-include" ]; } + ]; + den.aspects.guest-aspect.guest-os.networking.hostName = "guest-vm"; + + expr = { + hn = igloo.microvm.vms.guest.config.networking.hostName; + km = igloo.microvm.vms.guest.config.boot.kernelModules; + }; + expected = { + hn = "guest-vm"; + km = [ "from-host-include" ]; + }; + } + ); + + # EXPOSE: child emits a fleet quirk; the primitive's expose policy lifts it + # to the parent, which consumes it. The quirk is declared + added to the + # primitive's exposeQuirks set. + test-expose = denTest ( + { den, igloo, ... }: + { + den.quirks.guest-ports.description = "ports the guest needs forwarded upward"; + den.deliveredChild.exposeQuirks = [ "guest-ports" ]; + + den.aspects.igloo.includes = [ + den.aspects.microvm-slot + den.aspects.port-consumer + ]; + den.aspects.microvm-slot.nixos.imports = [ microvmSlot ]; + den.hosts.x86_64-linux.igloo.deliveredChildren.guest = mkGuest den { }; + + den.aspects.guest-aspect = { + guest-os.networking.hostName = "guest-vm"; + guest-ports = [ 2222 ]; + }; + den.aspects.port-consumer.nixos = + { guest-ports, ... }: + { + networking.firewall.allowedTCPPorts = guest-ports; + }; + + expr = igloo.networking.firewall.allowedTCPPorts; + expected = [ 2222 ]; + } + ); + + # NO STANDALONE OUTPUT: the primitive gives the guest intoAttr = [] and no + # policy.instantiate, so nixosConfigurations.guest does NOT exist; only the + # parent is instantiated. + test-no-standalone-output = denTest ( + { den, config, ... }: + { + imports = [ (parentBase den) ]; + den.hosts.x86_64-linux.igloo.deliveredChildren.guest = mkGuest den { }; + den.aspects.guest-aspect.guest-os.networking.hostName = "guest-vm"; + + expr = { + iglooExists = config.flake.nixosConfigurations ? igloo; + guestExists = config.flake.nixosConfigurations ? guest; + }; + expected = { + iglooExists = true; + guestExists = false; + }; + } + ); + + # AGENIX DOESN'T THROW (retarget + parent key): an agenix-like host-include + # reads host.public_key via builtins.readFile. The guest sets public_key to + # the parent's existing key path (clean override), so it resolves and the + # value lands in the delivered config through the parent. + test-agenix-tailored = denTest ( + { den, igloo, ... }: + let + agenixLike = + { host, ... }: + { + guest-os.age.hostPubkey = builtins.readFile host.public_key; + }; + in + { + imports = [ (parentBase den) ]; + den.hosts.x86_64-linux.igloo = { + public_key = ./delivered-child-host.nix; + deliveredChildren.guest = mkGuest den { public_key = ./delivered-child-host.nix; }; + }; + den.schema.host.includes = [ agenixLike ]; + den.aspects.guest-aspect.guest-os.networking.hostName = "guest-vm"; + + expr = + igloo.microvm.vms.guest.config.age.hostPubkey != "" + && igloo.microvm.vms.guest.config.networking.hostName == "guest-vm"; + expected = true; + } + ); + + # NEGATIVE (why tailoring is required): a verbatim guest WITHOUT public_key + # hard-blocks the agenix-like readFile the moment the delivered value is + # forced through the parent. + test-agenix-verbatim-blocks = denTest ( + { den, igloo, ... }: + let + agenixLike = + { host, ... }: + { + guest-os.age.hostPubkey = builtins.readFile host.public_key; + }; + in + { + imports = [ (parentBase den) ]; + den.hosts.x86_64-linux.igloo = { + public_key = ./delivered-child-host.nix; + deliveredChildren.guest = mkGuest den { }; # NO public_key. + }; + den.schema.host.includes = [ agenixLike ]; + den.aspects.guest-aspect.guest-os.networking.hostName = "guest-vm"; + + expr = igloo.microvm.vms.guest.config.age.hostPubkey; + expectedError = { + type = "EvalError"; + msg = "public_key"; + }; + } + ); + + # REALISTIC GUEST: real users + agenix (host pubkey + per-user secret) + + # the guest-os stateVersion default. Exercises the COMPLETE tailoring + # surface through the primitive. A delivered child built as a raw entity + # bypasses userType, so the user is a FULL record (gap G6). + test-realistic-guest = denTest ( + { den, igloo, ... }: + let + agenixBattery = + { host, ... }: + { + guest-os.age.hostPubkey = builtins.readFile host.public_key; + guest-os.age.secrets."tux-password".file = host.public_key; + }; + in + { + imports = [ (parentBase den) ]; + den.deliveredChild.stateVersion = "25.11"; + + den.hosts.x86_64-linux.igloo = { + public_key = ./delivered-child-host.nix; + deliveredChildren.guest = mkGuest den { + public_key = ./delivered-child-host.nix; + # tux is a homeManager user → guest-os HM synthesis now fires; pin + # the stub module (the real module needs guest re-instantiation). + home-manager = { + enable = true; + module = hmStub; + }; + users.tux = { + name = "tux"; + userName = "tux"; + classes = [ "homeManager" ]; + }; + }; + }; + den.schema.host.includes = [ agenixBattery ]; + den.aspects.guest-aspect.guest-os.networking.hostName = "guest-vm"; + + expr = { + pubkeyResolved = igloo.microvm.vms.guest.config.age.hostPubkey != ""; + secretPresent = igloo.microvm.vms.guest.config.age.secrets ? "tux-password"; + hn = igloo.microvm.vms.guest.config.networking.hostName; + stateVersion = igloo.microvm.vms.guest.config.system.stateVersion; + }; + expected = { + pubkeyResolved = true; + secretPresent = true; + hn = "guest-vm"; + stateVersion = "25.11"; + }; + } + ); + + # HOME-MANAGER SYNTHESIS: a guest user with classes = ["homeManager"] and a + # homeManager aspect must produce a home-manager OUTPUT in the delivered + # config — i.e. igloo.microvm.vms.guest.config.home-manager.users.tux.. + # + # This exercises the guest-os home-env wiring: + # - hostConf defines `home-manager.enable`/`.module` ON the guest (else + # mkDetectHost short-circuits and NOTHING synthesizes), + # - the guest-hm-user-forward bridge resolves each homeManager user's + # homeManager content and delivers it under guest-os + # home-manager.users. (the battery's user-sub-scope forward does not + # survive the collectSubtree delivery route — see the primitive). + # WITHOUT that wiring the delivered config has NO home-manager.users.tux + # (hmUsers = []) and this test FAILS. + # + # The delivered content is a `{ imports = [...]; }` home-manager module — the + # exact shape the guest's real home-manager module evaluates when the microvm + # re-instantiates the guest config downstream. These unit tests never + # re-instantiate (the guest-os content lands in the freeform microvm slot), + # so the assertion re-evaluates the delivered imports to observe the actual + # home-manager output. nix-config exercises the real module on instantiation. + test-home-synthesis = denTest ( + { + den, + lib, + igloo, + ... + }: + { + imports = [ (parentBase den) ]; + den.deliveredChild.stateVersion = "25.11"; + + den.hosts.x86_64-linux.igloo.deliveredChildren.guest = mkGuest den { + # Consumer override: pin a stub HM module so the host-submodule + # home-manager.enable option exists (detection) without pulling the + # real home-manager module (which only evaluates on re-instantiation). + home-manager = { + enable = true; + module = hmStub; + }; + users.tux = { + name = "tux"; + userName = "tux"; + classes = [ "homeManager" ]; + # The guest user's home config, attached inline as the aspect. + aspect.homeManager.programs.git.enable = true; + }; + }; + den.aspects.guest-aspect.guest-os.networking.hostName = "guest-vm"; + + # The delivered config carries the per-user home-manager content as a + # `{ imports = [...]; }` module under home-manager.users.tux — the exact + # shape the guest's real home-manager module evaluates when the microvm + # re-instantiates the delivered config. We re-evaluate those imports + # here (with a permissive freeform module set, the way the microvm + # nixosSystem would) and assert the user's program setting lands. + expr = { + hmUsers = builtins.attrNames igloo.microvm.vms.guest.config.home-manager.users; + gitEnabled = + (lib.evalModules { + modules = [ + { config._module.freeformType = lib.types.lazyAttrsOf lib.types.anything; } + ] + ++ igloo.microvm.vms.guest.config.home-manager.users.tux.imports; + }).config.programs.git.enable; + }; + expected = { + hmUsers = [ "tux" ]; + gitEnabled = true; + }; + } + ); + + }; +} From f7403569e62002f95ebce13cd51d183b97a046ac Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Thu, 11 Jun 2026 10:12:45 -0700 Subject: [PATCH 020/101] feat(aspects): restore entity.aspects accessor (dropped in rebase) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-applies df21485e (lost during the entity-gen-schema-port rebase, never cherry-picked to main): host.aspects exposes the flat list of all resolved aspect nodes, each augmented with .identity (base FQN), .identityKey (ctx-qualified), and .isNamed — symmetric with host.hasAspect, sharing the same per-class fxFullResolve. Resolves merge against the projected-hasAspect work landed since: pathSetByScope (gate !isExcluded, entity root included) and resolvedNodes (gate !isExcluded && !isEntityRoot) now coexist in collectPathsHandler, and pathSetByScope keys on the renamed local nodeBaseKey rather than the top-level baseKey helper. Unblocks nix-config's colmena battery, which reads host.aspects for deploy tags. 916/916 CI green, including Group K aspects tests. --- modules/context/has-aspect.nix | 99 +++++++---- nix/lib/aspects/fx/identity.nix | 34 +++- nix/lib/aspects/fx/pipeline.nix | 2 + nix/lib/aspects/has-aspect.nix | 59 ++++++- .../ci/modules/internal-api/has-aspect.nix | 166 ++++++++++++++++++ 5 files changed, 311 insertions(+), 49 deletions(-) diff --git a/modules/context/has-aspect.nix b/modules/context/has-aspect.nix index 401f7285e..5e7d701ea 100644 --- a/modules/context/has-aspect.nix +++ b/modules/context/has-aspect.nix @@ -8,6 +8,45 @@ let entityModule = { config, ... }: + let + # Prefer `classes` (list), fall back to `[class]`, else error. + classes = + config.classes or ( + if config ? class then + [ config.class ] + else + throw "den.schema.conf.hasAspect: entity has no `class` or `classes`" + ); + primaryClass = + if classes == [ ] then + throw "den.schema.conf.hasAspect: entity has empty `classes` list" + else + lib.head classes; + # Lazy thunk throwing at call time (not attribute access), so the + # accessors can be referenced safely by tooling and only fire when + # actually invoked. + err = throw ( + "hasAspect: ${config.name or ""} has no config.resolved " + + "(no matching den.schema. defined)." + ); + # Shared record: `hasAspect` (functor) and `aspects` (node list) are both + # read off `info`, so the per-class fxFullResolve runs once and is reused. + info = + if config ? resolved then + den.lib.aspects.mkEntityHasAspect { + tree = config.resolved; + inherit primaryClass classes; + } + else + { + __functor = _: _: err; + forClass = _: _: err; + forAnyClass = _: err; + aspects = err; + aspectsForClass = _: err; + allAspects = err; + }; + in { options.hasAspect = lib.mkOption { description = '' @@ -30,40 +69,32 @@ let readOnly = true; type = lib.types.raw; defaultText = lib.literalMD "Computed from `config.resolved` and the entity's class/classes."; - default = - let - # Prefer `classes` (list), fall back to `[class]`, else error. - classes = - config.classes or ( - if config ? class then - [ config.class ] - else - throw "den.schema.conf.hasAspect: entity has no `class` or `classes`" - ); - primaryClass = - if classes == [ ] then - throw "den.schema.conf.hasAspect: entity has empty `classes` list" - else - lib.head classes; - # Lazy thunk throwing at call time (not attribute access), so - # entity.hasAspect / .forClass / .forAnyClass can be referenced - # safely by tooling and only fire when actually invoked. - err = throw ( - "hasAspect: ${config.name or ""} has no config.resolved " - + "(no matching den.schema. defined)." - ); - in - if config ? resolved then - den.lib.aspects.mkEntityHasAspect { - tree = config.resolved; - inherit primaryClass classes; - } - else - { - __functor = _: _: err; - forClass = _: _: err; - forAnyClass = _: err; - }; + default = info; + }; + + options.aspects = lib.mkOption { + description = '' + The flat list of all resolved aspect nodes on this entity (every + depth), each the resolved node augmented with: + .identity # base FQN, ctx-stripped — e.g. "roles/workstation" + .identityKey # full unique key incl {ctxId} (distinguishes anons) + .isNamed # false for anonymous aspects + + Each node also retains its `.name`, `.meta`, and `.includes` (its + resolved subtree), so callers can inspect, navigate, and re-include + it. Excludes the entity root and excluded/tombstoned aspects; + anonymous aspects are included. + + Same cyclic caveat as `hasAspect`: do not use to decide an aspect's + own `includes`. Reading it post-resolution (module bodies, batteries) + is safe. + ''; + internal = true; + visible = false; + readOnly = true; + type = lib.types.raw; + defaultText = lib.literalMD "Computed from `config.resolved` and the entity's primary class."; + default = info.aspects; }; }; in diff --git a/nix/lib/aspects/fx/identity.nix b/nix/lib/aspects/fx/identity.nix index 0d433d38f..a19910d6e 100644 --- a/nix/lib/aspects/fx/identity.nix +++ b/nix/lib/aspects/fx/identity.nix @@ -13,6 +13,10 @@ let # Composed: aspectPath → pathKey in one call. key = a: pathKey (aspectPath a); + # Base identity without the {ctxId} instance suffix: provider chain + name. + # The pretty, stable fully-qualified name (e.g. "roles/workstation"). + baseKey = a: pathKey ((a.meta.provider or [ ]) ++ [ (a.name or "") ]); + # True when an identity string refers to an anonymous/unresolved node. isAnonIdentity = id: @@ -47,12 +51,15 @@ let { param, state }: let isExcluded = param.meta.excluded or false; + # The entity root (host/user/home) carries __entityKind. It is indexed + # in pathSet (unchanged) but excluded from resolvedNodes — an entity is + # not one of its own aspects. + isEntityRoot = param ? __entityKind; path = aspectPath param; - key = pathKey path; + nodeKey = pathKey path; # Also store base path (without ctxId) so hasAspect can match # without needing to know the specific context instance. - basePath = (param.meta.provider or [ ]) ++ [ (param.name or "") ]; - baseKey = pathKey basePath; + nodeBaseKey = baseKey param; in { resume = param; @@ -63,10 +70,10 @@ let _: (state.pathSet or (_: { })) null // { - ${key} = true; + ${nodeKey} = true; } - // lib.optionalAttrs (baseKey != key) { - ${baseKey} = true; + // lib.optionalAttrs (nodeBaseKey != nodeKey) { + ${nodeBaseKey} = true; }; pathSetByScope = _: @@ -78,9 +85,21 @@ let prev // { ${scope} = scopeSet // { - ${baseKey} = true; + ${nodeBaseKey} = true; }; }; + } + // lib.optionalAttrs (!isExcluded && !isEntityRoot) { + # Full resolved nodes keyed by unique (ctx-qualified) identity, for + # host.aspects. Stored behind the state thunk so a deepSeq of state + # never forces the node's class-content bodies (the lambda is WHNF); + # only reading host.aspects materializes name/meta/identity. + resolvedNodes = + _: + (state.resolvedNodes or (_: { })) null + // { + ${nodeKey} = param; + }; }; }; }; @@ -100,6 +119,7 @@ in aspectPath pathKey key + baseKey isAnonIdentity stripCtxSuffix toPathSet diff --git a/nix/lib/aspects/fx/pipeline.nix b/nix/lib/aspects/fx/pipeline.nix index 84a1d234a..abd35a724 100644 --- a/nix/lib/aspects/fx/pipeline.nix +++ b/nix/lib/aspects/fx/pipeline.nix @@ -139,6 +139,8 @@ let # structural walk, bucketed by the scope that owns each node. Powers the # projected (in-context) hasAspect. Thunked to survive per-step deepSeq. pathSetByScope = _: { }; + # Full resolved nodes keyed by unique identity, for entity.aspects. + resolvedNodes = _: { }; # --- Scope-partitioned output state (handlers write here) --- scopedClassImports = _: { }; diff --git a/nix/lib/aspects/has-aspect.nix b/nix/lib/aspects/has-aspect.nix index 092f91b70..bc24a37f7 100644 --- a/nix/lib/aspects/has-aspect.nix +++ b/nix/lib/aspects/has-aspect.nix @@ -15,10 +15,12 @@ let else throw "hasAspect: ref must have `name`+`meta` or `__provider` (got ${builtins.typeOf ref})."; - # Resolve tree via fx pipeline and extract pathSet from state. + # Resolve tree via fx pipeline, returning the full result state. One run + # yields both the pathSet (membership, for hasAspect) and resolvedNodes + # (for .aspects), so callers share a single resolution per class. # Inlines the same root normalization as fxResolveTree (default.nix) # to handle raw lambdas and functor attrsets. - collectPathSet = + resolveClassState = { tree, class }: let normalized = den.lib.aspects.normalizeRoot tree; @@ -30,7 +32,10 @@ let self = normalized; }; in - (result.state.pathSet or (_: { })) null; + result.state; + + collectPathSet = + { tree, class }: ((resolveClassState { inherit tree class; }).pathSet or (_: { })) null; hasAspectIn = { @@ -55,6 +60,31 @@ let forAnyClass = check; }; + # Augment a resolved node with its identity accessors for .aspects callers. + # Shallow: every node already appears as its own flat entry, so children + # reached via `.includes` are also present (augmented) at top level. + augment = + node: + let + baseId = identity.baseKey node; + in + node + // { + # Base FQN, ctx-stripped — pretty + stable (e.g. "roles/workstation"). + identity = baseId; + # Full unique key incl {ctxId} — distinguishes anonymous instances. + identityKey = identity.key node; + # Named only if neither the node's own name nor any provider-chain segment + # is an anonymous/synthetic sentinel. isMeaningfulName catches an exact + # ""/""/"[definition …]" name; the infix guards catch + # nested anonymous instances like "roles/dev/:3" (whose name + # ":3" slips past isMeaningfulName), so consumers can filter on it. + isNamed = + den.lib.aspects.isMeaningfulName (node.name or "") + && !(lib.hasInfix "" baseId) + && !(lib.hasInfix "" baseId); + }; + mkEntityHasAspect = { tree, @@ -62,22 +92,35 @@ let classes, }: let - setFor = builtins.listToAttrs ( + # One resolution per unique class, shared between membership (pathSet) + # and the node list (resolvedNodes). + stateFor = builtins.listToAttrs ( map (c: { name = c; - value = collectPathSet { + value = resolveClassState { inherit tree; class = c; }; }) (lib.unique ([ primaryClass ] ++ classes)) ); - check = class: ref: (setFor.${class} or { }) ? ${refKey ref}; - bareFn = check primaryClass; + pathSetFor = c: ((stateFor.${c} or { }).pathSet or (_: { })) null; + nodesFor = + c: map augment (lib.attrValues (((stateFor.${c} or { }).resolvedNodes or (_: { })) null)); + check = class: ref: (pathSetFor class) ? ${refKey ref}; in { - __functor = _: bareFn; + __functor = _: check primaryClass; forClass = check; forAnyClass = ref: lib.any (c: check c ref) classes; + + # Flat list of all resolved aspect nodes (every depth) for the primary + # class, each augmented with .identity / .identityKey / .isNamed. + aspects = nodesFor primaryClass; + aspectsForClass = nodesFor; + # Union across classes, deduped by full identity key. + allAspects = lib.attrValues ( + builtins.listToAttrs (map (n: lib.nameValuePair n.identityKey n) (lib.concatMap nodesFor classes)) + ); }; in diff --git a/templates/ci/modules/internal-api/has-aspect.nix b/templates/ci/modules/internal-api/has-aspect.nix index 5623dcbed..fd58495db 100644 --- a/templates/ci/modules/internal-api/has-aspect.nix +++ b/templates/ci/modules/internal-api/has-aspect.nix @@ -740,5 +740,171 @@ } ); + # ─── Group K: entity.aspects (resolved node list) ───────────────── + + # .aspects returns the flat set of resolved nodes (every depth), each + # augmented with .identity / .identityKey / .isNamed and retaining the + # raw .name. Subset checks (elem) tolerate schema-injected siblings. + test-K-aspects-contains-resolved = denTest ( + { den, ... }: + let + aspects = den.hosts.x86_64-linux.igloo.aspects; + ids = map (a: a.identity) aspects; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.igloo.includes = [ den.aspects.feature ]; + den.aspects.feature.nixos = { }; + + expr = { + hasIgloo = builtins.elem "igloo" ids; + hasFeature = builtins.elem "feature" ids; + allAugmented = builtins.all ( + a: (a ? identity) && (a ? identityKey) && (a ? isNamed) && (a ? name) + ) aspects; + }; + expected = { + hasIgloo = true; + hasFeature = true; + allAugmented = true; + }; + } + ); + + # The entity root (carries __entityKind) is not one of its own aspects. + test-K-aspects-excludes-entity-root = denTest ( + { den, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.aspects.igloo.nixos = { }; + + expr = builtins.any (a: a ? __entityKind) den.hosts.x86_64-linux.igloo.aspects; + expected = false; + } + ); + + # Exclude-aware: tombstoned aspects are absent (same gate as pathSet). + test-K-aspects-respects-tombstone = denTest ( + { den, ... }: + let + ids = map (a: a.identity) den.hosts.x86_64-linux.igloo.aspects; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.igloo.includes = [ + den.aspects.keep + den.aspects.drop + ]; + den.aspects.igloo.meta.handleWith = den.lib.aspects.fx.constraints.exclude den.aspects.drop; + den.aspects.keep.nixos = { }; + den.aspects.drop.nixos = { }; + + expr = { + keep = builtins.elem "keep" ids; + drop = builtins.elem "drop" ids; + }; + expected = { + keep = true; + drop = false; + }; + } + ); + + # Flat across depths: deeply nested aspects appear as their own entries. + test-K-aspects-deep-present = denTest ( + { den, ... }: + let + ids = map (a: a.identity) den.hosts.x86_64-linux.igloo.aspects; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.igloo.includes = [ den.aspects.level1 ]; + den.aspects.level1.includes = [ den.aspects.level2 ]; + den.aspects.level2.includes = [ den.aspects.level3 ]; + den.aspects.level3.nixos = { }; + + expr = { + l1 = builtins.elem "level1" ids; + l2 = builtins.elem "level2" ids; + l3 = builtins.elem "level3" ids; + }; + expected = { + l1 = true; + l2 = true; + l3 = true; + }; + } + ); + + # Provenance: nested freeform refs keep distinct identities (a/sub ≠ b/sub). + test-K-aspects-nested-identity-distinct = denTest ( + { den, ... }: + let + ids = map (a: a.identity) den.hosts.x86_64-linux.igloo.aspects; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.igloo.includes = [ den.aspects.a.sub ]; + den.aspects.a.sub.nixos = { }; + den.aspects.b.sub.nixos = { }; + + expr = { + aSub = builtins.elem "a/sub" ids; + bSub = builtins.elem "b/sub" ids; + }; + expected = { + aSub = true; + bSub = false; + }; + } + ); + + # Anonymous aspects are exposed (not filtered) — an inline-invoked factory + # resolves to an anonymous sibling, present with .isNamed == false. + test-K-aspects-includes-anonymous = denTest ( + { den, ... }: + let + aspects = den.hosts.x86_64-linux.igloo.aspects; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.facter = report: { + nixos.environment.variables.FACTER_REPORT = report; + }; + den.aspects.igloo.includes = [ (den.aspects.facter "report") ]; + + expr = builtins.any (a: !a.isNamed) aspects; + expected = true; + } + ); + + # Regression: anonymous *instances* (loc-named ":N") nest under named + # providers (e.g. "roles/dev/:3"). Their name slips past + # isMeaningfulName, so .isNamed must also inspect the identity. Invariant + # consumers (colmena tags) rely on: every isNamed node has a clean identity. + test-K-aspects-named-have-clean-identity = denTest ( + { den, lib, ... }: + let + aspects = den.hosts.x86_64-linux.igloo.aspects; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.facter = report: { + nixos.environment.variables.FACTER_REPORT = report; + }; + # Inline-invoked factory resolves to an anonymous sibling node. + den.aspects.igloo.includes = [ (den.aspects.facter "report") ]; + + expr = builtins.all (a: !a.isNamed || !(lib.hasInfix "" a.identity)) aspects; + expected = true; + } + ); + }; } From 418c6ea7a3efd99fcb5daa9ea9feaa51ec7b6b5a Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Thu, 11 Jun 2026 10:55:22 -0700 Subject: [PATCH 021/101] feat(schema): kind-level isolated marker, recorded per scope by push-scope A kind declaring isolated = true marks its entity scopes as delivered: their content belongs to their own system and must not be absorbed across the entity boundary. push-scope records a scopeIsolated map (scopeId -> bool) on pipeline state, mirroring scopeEntityKind. --- modules/options.nix | 5 +++++ nix/lib/aspects/fx/handlers/push-scope.nix | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/modules/options.nix b/modules/options.nix index 3a9d44f6a..2d1c0627a 100644 --- a/modules/options.nix +++ b/modules/options.nix @@ -56,6 +56,10 @@ in default = false; merge = acc: val: acc || val; }; + isolated = { + default = false; + merge = acc: val: acc || val; + }; }; computed = collections: defs: { isEntity = @@ -68,6 +72,7 @@ in "includes" "excludes" "isEntity" + "isolated" "parent" "collisionPolicy" ]; diff --git a/nix/lib/aspects/fx/handlers/push-scope.nix b/nix/lib/aspects/fx/handlers/push-scope.nix index a70c41154..726bb0ed4 100644 --- a/nix/lib/aspects/fx/handlers/push-scope.nix +++ b/nix/lib/aspects/fx/handlers/push-scope.nix @@ -32,6 +32,7 @@ let prevEntityClass = (state.scopeEntityClass or (_: { })) null; prevEntityKind = (state.scopeEntityKind or (_: { })) null; prevSourcePolicy = (state.scopeSourcePolicy or (_: { })) null; + prevIsolated = (state.scopeIsolated or (_: { })) null; updatedContexts = prevContexts // { ${newScopeId} = scopedCtx; }; @@ -46,6 +47,8 @@ let updatedSourcePolicy = prevSourcePolicy // lib.optionalAttrs (sourcePolicyName != null) { ${newScopeId} = sourcePolicyName; }; + isolatedKind = entityKind != null && (den.schema.${entityKind}.isolated or false); + updatedIsolated = prevIsolated // lib.optionalAttrs isolatedKind { ${newScopeId} = true; }; in { resume = { @@ -64,6 +67,7 @@ let scopeEntityClass = _: updatedEntityClass; scopeEntityKind = _: updatedEntityKind; scopeSourcePolicy = _: updatedSourcePolicy; + scopeIsolated = _: updatedIsolated; } // lib.optionalAttrs (parentItems != [ ]) { scopedDeferredIncludes = From 35ed459d646709a730ff3b88c2588641f69c27a3 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Thu, 11 Jun 2026 10:55:34 -0700 Subject: [PATCH 022/101] feat(fx): isolation-aware subtree collection + appendToParent route decoupling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both subtree-collection sites (extractSubtreeModules, collectFromSubtree) now skip isolated descendant scopes and everything below them; the collection root stays exempt so an isolated entity's own delivery route still collects itself. Compose entities (user/home) carry no marker and are unaffected. A route's single sourceScopeId doubled as collection root and append target, which cannot express delivery from an isolated child: rooting at the host over-collects, appending at the (extraction-skipped) child drops the content. appendToParent = true decouples them — collection roots at the registering scope, the append lands at scopeParent.${sourceScopeId}. Routes omitting the field are byte-identical to before. mkInstantiateArgs' internal subtree selection stays isolation-blind on purpose: the guest's class imports must remain visible to the delivery route in the per-host re-walk; only final extraction filters. --- nix/lib/aspects/fx/resolve.nix | 46 ++- nix/lib/aspects/fx/route/apply.nix | 41 ++- nix/lib/aspects/fx/spawn-node.nix | 5 +- .../ci/modules/features/entity-isolation.nix | 269 ++++++++++++++++++ 4 files changed, 339 insertions(+), 22 deletions(-) create mode 100644 templates/ci/modules/features/entity-isolation.nix diff --git a/nix/lib/aspects/fx/resolve.nix b/nix/lib/aspects/fx/resolve.nix index b0066d6b7..d557e26be 100644 --- a/nix/lib/aspects/fx/resolve.nix +++ b/nix/lib/aspects/fx/resolve.nix @@ -152,12 +152,13 @@ let # complex-route forward SOURCE with full fleet visibility (replaces the old # isolated fxResolve fallback). applyRoutes = - spawnNode: ctx: scopeContexts: rootScopeId: scopeParent: scopedRoutes: acc: + spawnNode: ctx: scopeContexts: rootScopeId: scopeParent: scopeIsolated: scopedRoutes: acc: route.applyRoutes { inherit scopedRoutes scopeContexts scopeParent + scopeIsolated ctx rootScopeId spawnNode @@ -213,18 +214,23 @@ let # This produces the complete module set for a host: host-scope modules, # user-scope modules, and route-delivered modules — all in one list. extractSubtreeModules = - perScope: scopeParent: rootScopeId: targetClass: + perScope: scopeParent: scopeIsolated: rootScopeId: targetClass: let allScopeIds = builtins.attrNames perScope; - # Collect all descendant scope IDs by walking scopeParent. + # Collect descendant scope IDs by walking scopeParent — skipping isolated + # descendants (and everything below them). The collection root is always + # included: isolation gates crossing INTO an entity, not collecting AT it. isInSubtree = sid: sid == rootScopeId || ( - let - parent = scopeParent.${sid} or null; - in - parent != null && parent != sid && isInSubtree parent + !(scopeIsolated.${sid} or false) + && ( + let + parent = scopeParent.${sid} or null; + in + parent != null && parent != sid && isInSubtree parent + ) ); subtreeScopes = builtins.filter isInSubtree allScopeIds; # Collect modules from all subtree scopes, deduplicating by key. @@ -263,6 +269,7 @@ let scopedRoutes, scopeParent, scopeEntityClass ? (_: { }), + scopeIsolated ? { }, spawnNodeFn, ctx, }: @@ -314,10 +321,10 @@ let subtreePhase1 = wrapPerScope ctx subtreeContexts subtreeClassImports; subtreePhase2 = applyProvides ctx relevantContexts subtreeProvides subtreePhase1; subtreePhase3 = - applyRoutes spawnNodeFn ctx relevantContexts hostScopeId scopeParent subtreeRoutes + applyRoutes spawnNodeFn ctx relevantContexts hostScopeId scopeParent scopeIsolated subtreeRoutes subtreePhase2; in - extractSubtreeModules subtreePhase3.perScope scopeParent hostScopeId hostClass + extractSubtreeModules subtreePhase3.perScope scopeParent scopeIsolated hostScopeId hostClass else null; modules = @@ -360,6 +367,7 @@ let scopedRoutes, scopeParent, scopeEntityClass ? (_: { }), + scopeIsolated ? { }, spawnNodeFn, ctx, }: @@ -373,6 +381,7 @@ let scopedRoutes scopeParent scopeEntityClass + scopeIsolated spawnNodeFn ctx ; @@ -485,6 +494,9 @@ let scopeParent = result.state.scopeParent null; scopedProvides = result.state.scopedProvides null; scopedRoutes = result.state.scopedRoutes null; + # Kind-level isolation marks {scopeId→true}; route collection and subtree + # extraction skip isolated descendants (the collection root is exempt). + scopeIsolated = (result.state.scopeIsolated or (_: { })) null; # Scan raw pipe values for config-dependent thunks (functions taking # { config, ... }). If none exist, hostConfigs stays null and @@ -546,6 +558,7 @@ let scopeParent ; scopeEntityClass = result.state.scopeEntityClass or (_: { }); + inherit scopeIsolated; spawnNodeFn = spawnNode; inherit ctx; }; @@ -574,6 +587,7 @@ let inherit scopeContexts scopeParent + scopeIsolated ctx scopeEntityKind ; @@ -730,11 +744,13 @@ let phase1 = wrapPerScope ctx augmentedScopeContexts drainedClassImportsRaw; phase2 = applyProvides ctx augmentedScopeContexts scopedProvides phase1; phase3 = - applyRoutes spawnNode ctx augmentedScopeContexts result.state.rootScopeId scopeParent scopedRoutes + applyRoutes spawnNode ctx augmentedScopeContexts result.state.rootScopeId scopeParent scopeIsolated + scopedRoutes phase2; phase4 = applyInstantiates { scopedInstantiates = result.state.scopedInstantiates null; scopeEntityClass = result.state.scopeEntityClass or (_: { }); + inherit scopeIsolated; inherit augmentedScopeContexts scopedProvides @@ -777,6 +793,7 @@ let scopeContexts = result.state.scopeContexts null; scopedClassImportsRaw = result.state.scopedClassImports null; scopeParent = result.state.scopeParent null; + scopeIsolated = (result.state.scopeIsolated or (_: { })) null; augmentedScopeContexts = assemblePipes { inherit scopeContexts; @@ -790,7 +807,12 @@ let # threaded spawned node rather than an isolated pipeline. No drain/phase4 # here, so this only matters for nested node resolution. parentState = { - inherit scopeContexts scopeParent ctx; + inherit + scopeContexts + scopeParent + scopeIsolated + ctx + ; scopeEntityKind = (result.state.scopeEntityKind or (_: { })) null; scopedClassImports = scopedClassImportsRaw; scopedPipeEffects = result.state.scopedPipeEffects null; @@ -809,7 +831,7 @@ let phase1 = wrapPerScope ctx augmentedScopeContexts scopedClassImportsRaw; phase2 = applyProvides ctx augmentedScopeContexts (result.state.scopedProvides null) phase1; phase3 = - applyRoutes spawnNode ctx augmentedScopeContexts result.state.rootScopeId scopeParent + applyRoutes spawnNode ctx augmentedScopeContexts result.state.rootScopeId scopeParent scopeIsolated (result.state.scopedRoutes null) phase2; in diff --git a/nix/lib/aspects/fx/route/apply.nix b/nix/lib/aspects/fx/route/apply.nix index c5dac8818..352bc5e9c 100644 --- a/nix/lib/aspects/fx/route/apply.nix +++ b/nix/lib/aspects/fx/route/apply.nix @@ -144,19 +144,24 @@ let }; }; - # Collect class modules from a scope and all its descendants. + # Collect class modules from a scope and all its descendants — skipping + # isolated descendants (and their subtrees). The collection root is always + # included so an isolated entity's own delivery route still collects itself. collectFromSubtree = - wrappedPerScope: scopeParent: rootScopeId: fromClass: + wrappedPerScope: scopeParent: scopeIsolated: rootScopeId: fromClass: let allScopeIds = builtins.attrNames wrappedPerScope; isInSubtree = sid: sid == rootScopeId || ( - let - parent = scopeParent.${sid} or null; - in - parent != null && parent != sid && isInSubtree parent + !(scopeIsolated.${sid} or false) + && ( + let + parent = scopeParent.${sid} or null; + in + parent != null && parent != sid && isInSubtree parent + ) ); subtreeScopes = builtins.filter isInSubtree allScopeIds; in @@ -168,6 +173,7 @@ let route, wrappedPerScope, scopeParent, + scopeIsolated, }: let isFlakeRoute = route.intoClass == "flake"; @@ -178,7 +184,7 @@ let # entity subtrees whose scope args won't be available after adaptArgs. sourceModules = if isFlakeRoute || (route.collectSubtree or false) then - collectFromSubtree wrappedPerScope scopeParent route.sourceScopeId route.fromClass + collectFromSubtree wrappedPerScope scopeParent scopeIsolated route.sourceScopeId route.fromClass else let scopeExists = wrappedPerScope ? ${route.sourceScopeId}; @@ -227,8 +233,17 @@ let guard = route.guard or null; adaptArgs = route.adaptArgs or null; }; + # Delivery routes registered inside an isolated child collect rooted at + # their own scope but must land the result on the PARENT — the child + # scope is skipped by isolation-aware extraction, so appending there + # would drop the content. + appendScopeId = + if route.appendToParent or false then + scopeParent.${route.sourceScopeId} or route.sourceScopeId + else + route.sourceScopeId; in - appendToClass acc route.intoClass route.sourceScopeId wrappedModules; + appendToClass acc route.intoClass appendScopeId wrappedModules; isDenDefaultModule = mod: lib.hasSuffix "@default" (mod.key or mod._file or ""); @@ -318,6 +333,7 @@ let wrappedPerScope, classImports, scopeParent ? { }, + scopeIsolated ? { }, scopeContexts ? { }, ctx ? { }, spawnNode ? null, @@ -346,7 +362,14 @@ let ; } else - applySimpleRoute acc { inherit route wrappedPerScope scopeParent; } + applySimpleRoute acc { + inherit + route + wrappedPerScope + scopeParent + scopeIsolated + ; + } ) { inherit classImports; diff --git a/nix/lib/aspects/fx/spawn-node.nix b/nix/lib/aspects/fx/spawn-node.nix index 7dfbe911d..99c84ed1b 100644 --- a/nix/lib/aspects/fx/spawn-node.nix +++ b/nix/lib/aspects/fx/spawn-node.nix @@ -100,6 +100,8 @@ in // { ${spawnRoot} = from; }; + mergedScopeIsolated = + (parentState.scopeIsolated or { }) // ((result.state.scopeIsolated or (_: { })) null); # 3. Re-derive pipes over merged state. hostConfigs = null: config-dependent # stay deferred (via __configThunk); pipeline-parametric resolve eagerly. @@ -138,7 +140,8 @@ in ) parentSubtreeRoutes; phase3 = - applyRoutes selfRef parentState.ctx augmented spawnRoot mergedScopeParent mergedSpawnRoutes + applyRoutes selfRef parentState.ctx augmented spawnRoot mergedScopeParent mergedScopeIsolated + mergedSpawnRoutes phase2; # Restrict extraction to the spawned subtree (spawnRoot + descendants). diff --git a/templates/ci/modules/features/entity-isolation.nix b/templates/ci/modules/features/entity-isolation.nix new file mode 100644 index 000000000..689b2ce0d --- /dev/null +++ b/templates/ci/modules/features/entity-isolation.nix @@ -0,0 +1,269 @@ +# Entity-isolation marker + isolation-aware extraction (spec 2026-06-11). +{ denTest, ... }: +{ + flake.tests.entity-isolation = { + + # Kind-level marker: declared via gen-schema collection, default false. + test-isolated-marker = denTest ( + { den, config, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.schema.iso-kind = { + isEntity = true; + parent = "host"; + isolated = true; + }; + + expr = { + iso = config.den.schema.iso-kind.isolated; + host = config.den.schema.host.isolated; + }; + expected = { + iso = true; + host = false; + }; + } + ); + + # The marker alone must not flip the computed isEntity heuristic. + test-isolated-alone-not-entity = denTest ( + { den, config, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.schema.marker-only.isolated = true; + + expr = config.den.schema.marker-only.isEntity; + expected = false; + } + ); + + # An isolated child's nixos-authored content must NOT be absorbed into + # the parent's own nixos config (the cortex microvm.guest leak). + test-isolated-content-not-absorbed = denTest ( + { + den, + lib, + igloo, + ... + }: + let + guestEntity = { + name = "guest"; + system = "x86_64-linux"; + class = "nixos"; + intoAttr = [ ]; + users = { }; + aspect = den.aspects.guest-aspect; + }; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.schema.iso-kind = { + isEntity = true; + parent = "host"; + isolated = true; + }; + # Gate on the parent's name so the policy does not re-fire inside the + # guest scope. Bind only the iso-kind record — rebinding `host` to the + # guest would trigger the host home-env synthesis (which the bare guest + # record cannot satisfy) and is unnecessary: the iso-kind scope inherits + # the parent `host` (igloo) from the enriched ctx, and isolation gates + # whether its nixos content leaks back into igloo. + den.policies.resolve-iso-child = + { host, ... }: + lib.optionals (host.name == "igloo") [ + (den.lib.policy.resolve.to "iso-kind" { + iso-kind = guestEntity; + }) + ]; + den.schema.host.includes = [ den.policies.resolve-iso-child ]; + den.aspects.guest-aspect.nixos.boot.kernelModules = [ "guest-only-module" ]; + + # igloo carries nixpkgs hardware defaults (atkbd/loop), so assert the + # guest's module specifically does NOT leak in rather than equality to []. + expr = lib.elem "guest-only-module" igloo.boot.kernelModules; + expected = false; + } + ); + + # SPIKE falsifier: a route registered inside the isolated guest scope with + # appendToParent delivers the guest subtree's nixos exactly once at the + # parent path — and the parent's own toplevel stays clean. + test-isolated-delivery-exactly-once = denTest ( + { + den, + lib, + igloo, + ... + }: + let + guestEntity = { + name = "guest"; + system = "x86_64-linux"; + class = "nixos"; + intoAttr = [ ]; + users = { }; + aspect = den.aspects.guest-aspect; + }; + # Fires inside the guest scope (resolve includes): sourceScopeId + # defaults to the guest scope = collection root. Gated against + # re-fire in nested sub-scopes where it would self-deliver. + deliverPolicy = den.lib.policy.mkPolicy "deliver-iso" ( + { ... }@args: + lib.optionals (!(args ? user) && !(args ? home)) [ + (den.lib.policy.route { + fromClass = "nixos"; + intoClass = "nixos"; + collectSubtree = true; + appendToParent = true; + path = [ + "microvm" + "vms" + "guest" + "config" + ]; + }) + ] + ); + # Freeform slot on the parent so delivered content has a landing path. + microvmSlot = + { lib, ... }: + { + options.microvm.vms = lib.mkOption { + type = lib.types.attrsOf ( + lib.types.submodule { + options.config = lib.mkOption { + type = lib.types.submoduleWith { + modules = [ + { config._module.freeformType = lib.types.lazyAttrsOf lib.types.anything; } + ]; + }; + default = { }; + }; + } + ); + default = { }; + }; + }; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.schema.iso-kind = { + isEntity = true; + parent = "host"; + isolated = true; + }; + den.policies.resolve-iso-child = + { host, ... }: + lib.optionals (host.name == "igloo") [ + (den.lib.policy.resolve.to.withIncludes "iso-kind" [ deliverPolicy ] { + iso-kind = guestEntity; + }) + ]; + den.schema.host.includes = [ den.policies.resolve-iso-child ]; + den.aspects.igloo.nixos.imports = [ microvmSlot ]; + den.aspects.guest-aspect.nixos.boot.kernelModules = [ "guest-only-module" ]; + + expr = { + delivered = lib.elem "guest-only-module" igloo.microvm.vms.guest.config.boot.kernelModules; + leaked = lib.elem "guest-only-module" igloo.boot.kernelModules; + }; + expected = { + delivered = true; + leaked = false; + }; + } + ); + + # A host-rooted collectSubtree route must NOT pull content from an isolated + # descendant: parent and guest both author a shared `side-chan` class; only + # the parent's own side-chan content may reach the route target path. + test-host-route-skips-isolated = denTest ( + { + den, + lib, + igloo, + ... + }: + let + guestEntity = { + name = "guest"; + system = "x86_64-linux"; + class = "nixos"; + intoAttr = [ ]; + users = { }; + aspect = den.aspects.guest-aspect; + }; + microvmSlot = + { lib, ... }: + { + options.microvm.vms = lib.mkOption { + type = lib.types.attrsOf ( + lib.types.submodule { + options.config = lib.mkOption { + type = lib.types.submoduleWith { + modules = [ + { config._module.freeformType = lib.types.lazyAttrsOf lib.types.anything; } + ]; + }; + default = { }; + }; + } + ); + default = { }; + }; + }; + # Host-scope route moving side-chan content into a parent path. Gated on + # the parent name so it does not re-fire (and self-deliver) in the guest. + collectPolicy = + { host, ... }: + lib.optionals (host.name == "igloo") [ + (den.lib.policy.route { + fromClass = "side-chan"; + intoClass = "nixos"; + collectSubtree = true; + path = [ + "microvm" + "vms" + "side" + "config" + ]; + }) + ]; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.classes.side-chan.description = "side channel test class"; + den.schema.iso-kind = { + isEntity = true; + parent = "host"; + isolated = true; + }; + den.policies.resolve-iso-child = + { host, ... }: + lib.optionals (host.name == "igloo") [ + (den.lib.policy.resolve.to "iso-kind" { + iso-kind = guestEntity; + }) + ]; + den.policies.collect-side = collectPolicy; + den.schema.host.includes = [ + den.policies.resolve-iso-child + den.policies.collect-side + ]; + den.aspects.igloo.nixos.imports = [ microvmSlot ]; + den.aspects.igloo.side-chan.boot.kernelModules = [ "host-side" ]; + den.aspects.guest-aspect.side-chan.boot.kernelModules = [ "guest-side" ]; + + expr = { + hostSide = lib.elem "host-side" igloo.microvm.vms.side.config.boot.kernelModules; + guestSide = lib.elem "guest-side" igloo.microvm.vms.side.config.boot.kernelModules; + }; + expected = { + hostSide = true; + guestSide = false; + }; + } + ); + }; +} From a621056d2bf08c640c66454d2b15eaff264a9e52 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Thu, 11 Jun 2026 10:55:45 -0700 Subject: [PATCH 023/101] feat(delivered-child-host): isolated nixos-class guest, drop guest-os remap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guest-os class remap kept the guest's walked content out of the parent's nixos partition, but only for content AUTHORED as guest-os — reused fleet aspects authored as nixos.* leaked into the parent (option 'microvm.guest' does not exist on the parent toplevel). Entity isolation closes that leak at the source, so the guest keeps its honest nixos identity: isolated = true on the kind, class = nixos, and a guest-scope delivery route (fromClass nixos, appendToParent) carrying the guest subtree to microvm.vms..config exactly once. With class = nixos the standard home-manager battery fires for the guest, replacing the parallel guest-os home-env; the per-user bridge stays (simple routes collect pre-route per-scope state, so the battery's user-scope forward is invisible to the delivery route). --- modules/policies/delivered-child-host.nix | 242 ++++++++---------- .../public-api/delivered-child-host.nix | 216 ++++++++++++---- 2 files changed, 268 insertions(+), 190 deletions(-) diff --git a/modules/policies/delivered-child-host.nix b/modules/policies/delivered-child-host.nix index 2fa7fe486..1c864cb74 100644 --- a/modules/policies/delivered-child-host.nix +++ b/modules/policies/delivered-child-host.nix @@ -3,26 +3,31 @@ # realized into parent.microvm.vms..config) instead of producing a # standalone nixosConfigurations. output. # -# ===================== DESIGN (resolved by two spikes) ===================== +# ===================== DESIGN (entity-isolation) =========================== # -# DELIVERY = resolve + class-isolation + route+collectSubtree. NO den-core -# (nix/) change. Three EXISTING mechanisms composed: +# DELIVERY = entity-isolation + a collect/append-decoupled route. The guest +# authors honest `nixos` content; a dedicated `delivered-guest` kind marked +# `isolated = true` keeps that content out of the parent's own toplevel nixos +# partition (isolation-aware subtree extraction skips isolated descendants and +# everything below them — see spec +# 2026-06-11-entity-isolation-aware-extraction-design.md). # -# 1. resolve.to "" { delivered-guest = guest; host = guest; } -# nests the guest as a child entity scope under the parent host. The -# `host` binding lets curated host-include policies (host-to-users, the -# home batteries, den.default) fire for the guest; the `delivered-guest` -# binding makes resolveEntityClass derive the guest's distinct class. +# Two EXISTING mechanisms composed, NO further den-core change here: # -# 2. guest.class = "guest-os" (a DISTINCT class) isolates the child's walked -# content from the parent's own `nixos` partition. Without it the guest's -# modules flatten into the parent's top-level nixos config. +# 1. resolve.to.withIncludes "" [ deliverPolicy ] +# { delivered-guest = guest; host = guest; } +# nests the guest as an isolated child entity scope under the parent host. +# The `host` binding rebinds host to the guest INSIDE the guest scope so +# curated host-include policies (host-to-users, the standard home-manager +# battery, den.default) fire for the guest as if it were a host. The +# `delivered-guest` binding makes resolveEntityClass derive the guest's +# class (always nixos here). # -# 3. route { fromClass = "guest-os"; intoClass = "nixos"; collectSubtree; -# path = [ "microvm" "vms" "" "config" ]; } collects the -# guest-os content from the ENTIRE parent subtree (incl. the child scope) -# and nests it under the delivery path in the parent's nixos class BEFORE -# the parent is instantiated. The guest carries intoAttr = [] and gets NO +# 2. The delivery route is registered INSIDE the guest scope (a resolve +# include). It collects `fromClass = "nixos"` rooted at the guest scope +# (the collection root is exempt from isolation) and, via +# `appendToParent = true`, lands the wrapped result at the PARENT scope +# under the delivery path. The guest carries intoAttr = [] and gets NO # policy.instantiate, so it produces NO standalone flake output. # # (redirect-instantiate — writing into flake.nixosConfigurations.

.config.* @@ -33,19 +38,16 @@ # KIND = a DEDICATED `delivered-guest` kind whose `includes` are a CURATED # subset of `den.schema.host.includes`, DERIVED (not hand-copied) so it tracks # host.includes: -# INHERIT participation/identity/collect includes (host-to-users, the home -# batteries, den.default). +# INHERIT participation/identity/collect includes (host-to-users, the +# standard home-manager battery, den.default). # OMIT includes producing a standalone instantiate output (nix-config's # colmena host-modules-capture) — a child must not instantiate. Named # via `den.deliveredChild.omitIncludeNames`. No-op in den-only tests # (colmena is a nix-config host-include, not a den one). # RETARGET / OVERRIDE agenix-style host-includes whose class lookup or key # paths assume the host class: the consumer points public_key / -# secret sources at the parent and targets guest-os. Expressed as +# secret sources at the parent and targets nixos. Expressed as # ordinary guest-targeted includes the consumer adds. -# ADD a guest-os home-env instance (supportedOses ∋ guest-os) so -# home-manager synthesis fires for the guest, plus a guest-os -# stateVersion default. # =========================================================================== { den, @@ -55,9 +57,8 @@ ... }: let - inherit (den.lib.policy) resolve route; + inherit (den.lib.policy) resolve route mkPolicy; - guestClass = "guest-os"; guestKind = "delivered-guest"; cfg = config.den.deliveredChild; @@ -78,77 +79,41 @@ let n == null || !(builtins.elem n cfg.omitIncludeNames) ) hostIncludes; - # The guest class is always nixos-flavored, so the hardcoded `.nixosModules` - # accessor is correct here (vs the class-keyed `"${host.class}Modules"` that - # the normal host battery uses — guest-os has no *Modules input attr of its own). + # The guest class is always nixos, so the hardcoded `.nixosModules` accessor + # is correct here. Used as the home-manager module default pinned on a raw + # guest record (which bypasses the host submodule's option defaults — gap G6). hmNixosModule = inputs.home-manager.nixosModules.home-manager; - # ADD: a guest-os home-env instance so home-manager synthesis (gated on - # host.class ∈ supportedOses) fires for a guest-os child. getModule pins the - # parent's nixos home-manager module (the guest-os class has no *Modules - # input attr of its own). - # - # makeHomeEnv returns THREE parts that are ALL wired (mirroring - # modules/aspects/batteries/home-manager.nix for the normal host): - # - guestHome.hostConf → the guest kind's host-submodule imports - # (den.schema.delivered-guest.imports). Defines the `home-manager.enable` - # /`.module` options ON the guest; without them mkDetectHost's - # `isEnabled = (host.home-manager or {}).enable or false` is false → - # detection short-circuits, and the home-manager module pinned for the - # guest's downstream re-instantiation is unavailable. - # - guestHome.battery → the guest kind's includes (host→user detection + - # the host-scope home-manager module import into guest-os). - # - guestHome.userDetect → the USER kind's includes (per-user detection; - # gated on host.class ∈ supportedOses = [guest-os], so a no-op for - # non-guest hosts). - # - # NOTE: the battery forwards each user's homeManager content from a NESTED - # user resolve sub-scope; that forward-route does not survive the parent's - # guest-os collectSubtree delivery route, so the actual per-user delivery is - # done by guestHmUserForward below (a host-scope bridge). - guestHome = den.lib.home-env.makeHomeEnv { - className = "homeManager"; - ctxName = "guest-hm"; - supportedOses = [ guestClass ]; - optionPath = "home-manager"; - getModule = _: hmNixosModule; - forwardPathFn = - { user, ... }: - [ - "home-manager" - "users" - user.userName - ]; - }; - - # ADD: a guest-os stateVersion default. den.default targets nixos/homeManager - # classes, which the guest-os route does not collect, so the guest gets no - # stateVersion otherwise. + # A nixos stateVersion default for the delivered guest, kept as an option + # contract for consumers. den.default's nixos content now DOES emit at the + # guest scope (the guest authors honest nixos), but a guest may still want an + # explicit stateVersion independent of the fleet default. Plain assignment + # matches the historical behavior; set stateVersion to null to apply none. guestDefault = lib.optional (cfg.stateVersion != null) { name = "delivered-guest-default"; - ${guestClass}.system.stateVersion = cfg.stateVersion; + nixos.system.stateVersion = cfg.stateVersion; }; - # Per-user home-manager synthesis for the delivered guest. + # Per-user home-manager synthesis bridge for the delivered guest. # - # The home-env BATTERY (guestHome.battery, wired below) detects the guest's - # homeManager users and forwards each user's homeManager content via a - # homeManager → guest-os forward-route registered in a NESTED user resolve - # sub-scope. That sub-scope forward-route does not survive the parent's - # guest-os → nixos collectSubtree delivery route: forwards register routes - # that are applied per-scope, and the delivery route's per-module freeform - # nesting does not re-run them. So the battery alone yields NO - # home-manager.users content in the delivered config. + # The STANDARD home-manager battery fires for the guest naturally (guest + # class = nixos, host.home-manager.enable defaulted true below), but it + # forwards each user's homeManager content from a NESTED user-under-guest + # resolve sub-scope via a per-scope forward-route. Simple routes (the delivery + # route) collect from the ORIGINAL pre-route per-scope state, so the battery's + # user-scope append is invisible to the delivery route's collection. Worse, + # the user-under-guest scope sits BELOW the isolated guest, so isolation drops + # that battery copy from the parent entirely. # - # This policy bridges that gap at the GUEST HOST scope (one level up, where - # collectSubtree reaches): it RESOLVES each homeManager user's homeManager - # content (den.lib.aspects.resolveImports — the same resolver the forward - # uses) and emits it as guest-os config under home-manager.users. as a - # `{ imports = [...]; }` module. The guest's real home-manager module - # (host.home-manager.module, pinned by the consumer / getModule) evaluates - # those imports when the microvm RE-INSTANTIATES the delivered config as the - # guest's own nixosSystem — exactly the standard home-manager.users. - # submodule contract. + # This policy bridges the gap at the GUEST HOST scope (which IS the delivery + # route's collection root): it RESOLVES each homeManager user's homeManager + # content (den.lib.aspects.resolveImports — the same resolver the battery + # forward uses) and emits it as nixos config under home-manager.users. + # as a `{ imports = [...]; }` module. The guest's real home-manager module + # (host.home-manager.module) evaluates those imports when the microvm + # RE-INSTANTIATES the delivered config as the guest's own nixosSystem — + # exactly the standard home-manager.users. submodule contract. There is no + # double-delivery: the battery-forward copy is dropped by isolation. guestHmUserForward = { host, ... }: let @@ -161,29 +126,28 @@ let in [ (den.lib.policy.include { - ${guestClass}.home-manager.users = lib.listToAttrs ( + nixos.home-manager.users = lib.listToAttrs ( map (user: lib.nameValuePair user.userName (userHmModule user)) hmUsers ); }) ]; - # The guest kind's includes: curated host participation + the guest home-env - # (host-submodule options + host→user routing) + the per-user home-manager - # synthesis bridge + a guest-os stateVersion default + the expose policy. + # The guest kind's includes: curated host participation + the per-user + # home-manager synthesis bridge + the expose policy + a stateVersion default. curatedIncludes = curatedFromHost ++ [ - guestHome.battery - (den.lib.policy.mkPolicy "guest-hm-user-forward" guestHmUserForward) - (den.lib.policy.mkPolicy "expose-child-quirks" exposePolicy) + (mkPolicy "guest-hm-user-forward" guestHmUserForward) + (mkPolicy "expose-child-quirks" exposePolicy) ] ++ guestDefault; - # The guest-os home-env module pinned by getModule. Materialized here because - # a RAW delivered guest bypasses the host submodule (gap G6), so the - # `home-manager.enable`/`.module` option DEFAULTS that guestHome.hostConf - # defines never apply to the `host` binding. We replicate those defaults on - # the raw guest record below (mirroring nix/lib/home-env.nix:hostOptions) so: + # The home-manager module pinned by the raw guest record. Materialized here + # because a RAW delivered guest bypasses the host submodule (gap G6), so the + # `home-manager.enable`/`.module` option DEFAULTS the standard battery's + # hostConf defines never apply to the `host` binding. We replicate those + # defaults on the raw guest record below (mirroring nix/lib/home-env.nix: + # hostOptions) so: # - mkDetectHost sees `host.home-manager.enable` = true (a homeManager user # exists) and does not short-circuit, and # - the battery's hostModule can read `host.home-manager.module`. @@ -194,11 +158,31 @@ let builtins.attrValues (guest.users or { }) ); - # Per-child delivery: resolve the guest as a nested child entity, isolate it - # in the guest-os class, and route its content into the parent under the - # delivery path. + # Delivery route, registered INSIDE the guest scope (sourceScopeId = guest = + # collection root); appendToParent lands the wrapped result at the parent. + # Gated against re-fire in nested sub-scopes (user/home), where it would + # self-deliver the guest's content into its own nixos. + deliverPolicyFor = + name: + mkPolicy "deliver-child-${name}" ( + { ... }@args: + lib.optionals (!(args ? user) && !(args ? home)) [ + (route { + fromClass = "nixos"; + intoClass = "nixos"; + collectSubtree = true; + appendToParent = true; + path = cfg.deliveryPathFor name; + }) + ] + ); + + # Per-child delivery: resolve the guest as a nested isolated child entity + # authoring honest nixos, carrying its own delivery route into the guest + # scope. Rebinding `host` to the guest makes curated host-includes and the + # standard home-manager battery see the guest as `host`. resolveChild = - _name: guest: + name: guest: let # Default the home-manager host option on the raw guest unless the guest # already declares it (consumer override wins). @@ -212,28 +196,17 @@ let guest // hmDefault // { - class = guestClass; + class = "nixos"; intoAttr = [ ]; }; in - resolve.to guestKind { + resolve.to.withIncludes guestKind [ (deliverPolicyFor name) ] { ${guestKind} = withClass; host = withClass; }; - routeChild = - name: _guest: - route { - fromClass = guestClass; - intoClass = "nixos"; - collectSubtree = true; - path = cfg.deliveryPathFor name; - }; - resolvePolicy = { host, ... }: lib.mapAttrsToList resolveChild (host.deliveredChildren or { }); - routePolicy = { host, ... }: lib.mapAttrsToList routeChild (host.deliveredChildren or { }); - # EXPOSE policy — runs inside the GUEST scope (it is part of the guest kind's # curated includes, NOT the parent's host.includes). pipe.expose must fire in # the scope that EMITS the quirk so the value flows up to the parent. Only @@ -251,9 +224,10 @@ let options.deliveredChildren = lib.mkOption { description = '' Guest host entities delivered as nested children of this host. Each - guest is resolved in the `${guestClass}` class and routed into this - host's configuration at `den.deliveredChild.deliveryPathFor ` - instead of producing a standalone flake output. + guest is resolved as an isolated `${guestKind}` child authoring honest + `nixos`, and its content is routed into this host's configuration at + `den.deliveredChild.deliveryPathFor ` instead of producing a + standalone flake output. ''; type = lib.types.attrsOf lib.types.raw; default = { }; @@ -261,37 +235,24 @@ let }; in { - config.den.classes.${guestClass}.description = "Delivered child guest host class"; - config.den.schema.${guestKind} = { isEntity = true; + isolated = true; parent = "host"; includes = curatedIncludes; - # The guest-os home-env's host-submodule options (home-manager.enable / - # .module) must exist ON the guest, or mkDetectHost short-circuits and no - # synthesis fires. Mirrors `den.schema.host.imports = [ result.hostConf ]` - # in the normal home-manager battery, scoped to the guest kind. - imports = [ guestHome.hostConf ]; }; - # The guest-os home-env's per-user detection. Gated on - # host.class ∈ [guest-os], so it is a no-op for ordinary nixos/darwin users. - # Mirrors `den.schema.user.includes = [ result.userDetect ]`. - config.den.schema.user.includes = [ guestHome.userDetect ]; - config.den.schema.host.imports = [ hostConf ]; config.den.policies.resolve-child-host = resolvePolicy; - config.den.policies.route-child-host = routePolicy; - # Wire the PARENT delivery policies into every host scope. Both are GATED on + # Wire the PARENT resolve policy into every host scope. GATED on # host.deliveredChildren so non-parent hosts pay no cost (no-op include → - # byte-identical toplevel). The expose policy is NOT here — it lives in the - # guest kind's curated includes so pipe.expose fires in the guest scope that - # emits the quirk. + # byte-identical toplevel). The delivery route is NOT here — it lives in the + # guest kind's curated includes so it fires in the guest scope (its own + # collection root). The expose policy likewise lives in the guest includes. config.den.schema.host.includes = [ den.policies.resolve-child-host - den.policies.route-child-host ]; options.den.deliveredChild = { @@ -337,9 +298,10 @@ in }; stateVersion = lib.mkOption { description = '' - stateVersion default applied in the guest-os class (den.default's - nixos/homeManager defaults are not collected by the guest route). Set - to null to apply none. + stateVersion default applied in the guest's nixos class. The guest now + authors honest nixos and den.default's nixos content emits at the guest + scope, but this provides an explicit per-guest stateVersion contract. + Set to null to apply none. ''; type = lib.types.nullOr lib.types.str; default = null; diff --git a/templates/ci/modules/public-api/delivered-child-host.nix b/templates/ci/modules/public-api/delivered-child-host.nix index 596e6c604..52d8e62f8 100644 --- a/templates/ci/modules/public-api/delivered-child-host.nix +++ b/templates/ci/modules/public-api/delivered-child-host.nix @@ -1,10 +1,12 @@ # Acceptance tests for the delivered-child-host PRIMITIVE # (modules/policies/delivered-child-host.nix). # -# A delivered child is a guest host that resolves as a nested child scope under -# its parent host and is realized INTO the parent's config +# A delivered child is a guest host that resolves as an ISOLATED nested child +# scope under its parent host and is realized INTO the parent's config # (microvm.vms..config) instead of producing a standalone -# nixosConfigurations. output. +# nixosConfigurations. output. The guest authors honest `nixos`; an +# entity-isolation marker keeps that content out of the parent's own toplevel, +# and a collect/append-decoupled delivery route lands it at the delivery path. # # HARNESS NOTE: denTest only exposes INSTANTIATED hosts # (config.flake.nixosConfigurations..config). A delivered child has NO @@ -13,21 +15,22 @@ # `igloo.microvm.vms..config.*`. # # The primitive supplies: a parent option (host.deliveredChildren), a dedicated -# `delivered-guest` kind whose includes are a curated subset of -# den.schema.host.includes, the delivery policy (resolve + class-isolation + -# route+collectSubtree), and an expose policy. Tests use the primitive — they -# declare children via den.hosts..igloo.deliveredChildren rather than -# hand-rolling the resolve/route pair. +# isolated `delivered-guest` kind whose includes are a curated subset of +# den.schema.host.includes, the delivery policy (resolve + isolation + +# route+collectSubtree+appendToParent), and an expose policy. Tests use the +# primitive — they declare children via den.hosts..igloo.deliveredChildren +# rather than hand-rolling the resolve/route pair. { denTest, lib, ... }: let # Minimal stand-in for the home-manager nixos module, used only to satisfy # the guest's host-submodule home-manager.module option (detection). The real # home-manager module self-references `config.home-manager` and only resolves # when the guest is re-instantiated as its own nixosSystem; these unit tests - # never re-instantiate (the guest-os content is collected into the freeform - # microvm slot), so the per-user home-manager modules the primitive emits are - # re-evaluated explicitly in the assertion instead. Real consumers (nix-config) - # use the real home-manager module when the microvm genuinely instantiates. + # never re-instantiate (the guest's nixos content is collected into the + # freeform microvm slot), so the per-user home-manager modules the primitive + # emits are re-evaluated explicitly in the assertion instead. Real consumers + # (nix-config) use the real home-manager module when the microvm genuinely + # instantiates. hmStub = { options.home-manager.users = lib.mkOption { type = lib.types.lazyAttrsOf ( @@ -59,6 +62,19 @@ let }; }; + # Stub of the agenix `age.*` options. Host-includes authored `nixos.age.*` + # now fire at the PARENT scope as well as the guest scope (the guest authors + # honest nixos), where a real nixosSystem has no age options. This absorbs + # those option writes on the parent. + ageStub = + { lib, ... }: + { + options.age = lib.mkOption { + type = lib.types.lazyAttrsOf lib.types.anything; + default = { }; + }; + }; + # A guest entity. The primitive sets class/intoAttr; the realistic gap-table # (G6) still applies: a raw delivered child bypasses the host submodule's # userType, so user records must be FULL ({ name; userName; classes; }). @@ -72,11 +88,15 @@ let } // extra; - # Common parent wiring: the microvm slot on igloo. Returned as a module so it - # merges (NOT `//`, which would clobber the test body's own `den` attr). + # Common parent wiring: the microvm slot + age stub on igloo. Returned as a + # module so it merges (NOT `//`, which would clobber the test body's own + # `den` attr). parentBase = den: { den.aspects.igloo.includes = [ den.aspects.microvm-slot ]; - den.aspects.microvm-slot.nixos.imports = [ microvmSlot ]; + den.aspects.microvm-slot.nixos.imports = [ + microvmSlot + ageStub + ]; }; in { @@ -89,7 +109,7 @@ in { imports = [ (parentBase den) ]; den.hosts.x86_64-linux.igloo.deliveredChildren.guest = mkGuest den { }; - den.aspects.guest-aspect.guest-os.networking.hostName = "guest-vm"; + den.aspects.guest-aspect.nixos.networking.hostName = "guest-vm"; expr = igloo.microvm.vms.guest.config.networking.hostName; expected = "guest-vm"; @@ -97,24 +117,28 @@ in ); # PARTICIPATION: a curated host-include value fires in the CHILD scope and - # arrives in the delivered config. The host-include emits into guest-os. + # arrives in the delivered config. The host-include emits into nixos, so it + # now fires at the PARENT scope too — assert membership in both, not + # list-equality (the parent carries nixpkgs defaults like atkbd/loop). test-participation = denTest ( { den, igloo, ... }: { imports = [ (parentBase den) ]; den.hosts.x86_64-linux.igloo.deliveredChildren.guest = mkGuest den { }; den.schema.host.includes = [ - { guest-os.boot.kernelModules = [ "from-host-include" ]; } + { nixos.boot.kernelModules = [ "from-host-include" ]; } ]; - den.aspects.guest-aspect.guest-os.networking.hostName = "guest-vm"; + den.aspects.guest-aspect.nixos.networking.hostName = "guest-vm"; expr = { hn = igloo.microvm.vms.guest.config.networking.hostName; - km = igloo.microvm.vms.guest.config.boot.kernelModules; + deliveredHasInclude = lib.elem "from-host-include" igloo.microvm.vms.guest.config.boot.kernelModules; + parentHasInclude = lib.elem "from-host-include" igloo.boot.kernelModules; }; expected = { hn = "guest-vm"; - km = [ "from-host-include" ]; + deliveredHasInclude = true; + parentHasInclude = true; }; } ); @@ -136,7 +160,7 @@ in den.hosts.x86_64-linux.igloo.deliveredChildren.guest = mkGuest den { }; den.aspects.guest-aspect = { - guest-os.networking.hostName = "guest-vm"; + nixos.networking.hostName = "guest-vm"; guest-ports = [ 2222 ]; }; den.aspects.port-consumer.nixos = @@ -158,7 +182,7 @@ in { imports = [ (parentBase den) ]; den.hosts.x86_64-linux.igloo.deliveredChildren.guest = mkGuest den { }; - den.aspects.guest-aspect.guest-os.networking.hostName = "guest-vm"; + den.aspects.guest-aspect.nixos.networking.hostName = "guest-vm"; expr = { iglooExists = config.flake.nixosConfigurations ? igloo; @@ -174,14 +198,16 @@ in # AGENIX DOESN'T THROW (retarget + parent key): an agenix-like host-include # reads host.public_key via builtins.readFile. The guest sets public_key to # the parent's existing key path (clean override), so it resolves and the - # value lands in the delivered config through the parent. + # value lands in the delivered config through the parent. The parent + # (igloo) also sets public_key, so the parent-scope evaluation of the + # include resolves too (ageStub absorbs the option). test-agenix-tailored = denTest ( { den, igloo, ... }: let agenixLike = { host, ... }: { - guest-os.age.hostPubkey = builtins.readFile host.public_key; + nixos.age.hostPubkey = builtins.readFile host.public_key; }; in { @@ -191,7 +217,7 @@ in deliveredChildren.guest = mkGuest den { public_key = ./delivered-child-host.nix; }; }; den.schema.host.includes = [ agenixLike ]; - den.aspects.guest-aspect.guest-os.networking.hostName = "guest-vm"; + den.aspects.guest-aspect.nixos.networking.hostName = "guest-vm"; expr = igloo.microvm.vms.guest.config.age.hostPubkey != "" @@ -202,14 +228,15 @@ in # NEGATIVE (why tailoring is required): a verbatim guest WITHOUT public_key # hard-blocks the agenix-like readFile the moment the delivered value is - # forced through the parent. + # forced through the parent. (igloo itself HAS public_key, so the parent + # scope is fine; the guest binding lacks it.) test-agenix-verbatim-blocks = denTest ( { den, igloo, ... }: let agenixLike = { host, ... }: { - guest-os.age.hostPubkey = builtins.readFile host.public_key; + nixos.age.hostPubkey = builtins.readFile host.public_key; }; in { @@ -219,7 +246,7 @@ in deliveredChildren.guest = mkGuest den { }; # NO public_key. }; den.schema.host.includes = [ agenixLike ]; - den.aspects.guest-aspect.guest-os.networking.hostName = "guest-vm"; + den.aspects.guest-aspect.nixos.networking.hostName = "guest-vm"; expr = igloo.microvm.vms.guest.config.age.hostPubkey; expectedError = { @@ -230,17 +257,17 @@ in ); # REALISTIC GUEST: real users + agenix (host pubkey + per-user secret) + - # the guest-os stateVersion default. Exercises the COMPLETE tailoring - # surface through the primitive. A delivered child built as a raw entity - # bypasses userType, so the user is a FULL record (gap G6). + # the stateVersion default. Exercises the COMPLETE tailoring surface + # through the primitive. A delivered child built as a raw entity bypasses + # userType, so the user is a FULL record (gap G6). test-realistic-guest = denTest ( { den, igloo, ... }: let agenixBattery = { host, ... }: { - guest-os.age.hostPubkey = builtins.readFile host.public_key; - guest-os.age.secrets."tux-password".file = host.public_key; + nixos.age.hostPubkey = builtins.readFile host.public_key; + nixos.age.secrets."tux-password".file = host.public_key; }; in { @@ -251,8 +278,8 @@ in public_key = ./delivered-child-host.nix; deliveredChildren.guest = mkGuest den { public_key = ./delivered-child-host.nix; - # tux is a homeManager user → guest-os HM synthesis now fires; pin - # the stub module (the real module needs guest re-instantiation). + # tux is a homeManager user → HM synthesis now fires; pin the stub + # module (the real module needs guest re-instantiation). home-manager = { enable = true; module = hmStub; @@ -265,7 +292,7 @@ in }; }; den.schema.host.includes = [ agenixBattery ]; - den.aspects.guest-aspect.guest-os.networking.hostName = "guest-vm"; + den.aspects.guest-aspect.nixos.networking.hostName = "guest-vm"; expr = { pubkeyResolved = igloo.microvm.vms.guest.config.age.hostPubkey != ""; @@ -286,22 +313,21 @@ in # homeManager aspect must produce a home-manager OUTPUT in the delivered # config — i.e. igloo.microvm.vms.guest.config.home-manager.users.tux.. # - # This exercises the guest-os home-env wiring: - # - hostConf defines `home-manager.enable`/`.module` ON the guest (else - # mkDetectHost short-circuits and NOTHING synthesizes), - # - the guest-hm-user-forward bridge resolves each homeManager user's - # homeManager content and delivers it under guest-os - # home-manager.users. (the battery's user-sub-scope forward does not - # survive the collectSubtree delivery route — see the primitive). - # WITHOUT that wiring the delivered config has NO home-manager.users.tux - # (hmUsers = []) and this test FAILS. + # This exercises the guest-hm-user-forward bridge: the standard battery's + # per-user forward appends at the user-under-guest scope (below the isolated + # guest), which isolation drops from the parent. The bridge resolves each + # homeManager user's homeManager content at the GUEST scope (the delivery + # route's collection root) and delivers it under nixos + # home-manager.users.. WITHOUT it the delivered config has NO + # home-manager.users.tux and this test FAILS. # # The delivered content is a `{ imports = [...]; }` home-manager module — the # exact shape the guest's real home-manager module evaluates when the microvm # re-instantiates the guest config downstream. These unit tests never - # re-instantiate (the guest-os content lands in the freeform microvm slot), - # so the assertion re-evaluates the delivered imports to observe the actual - # home-manager output. nix-config exercises the real module on instantiation. + # re-instantiate (the guest's nixos content lands in the freeform microvm + # slot), so the assertion re-evaluates the delivered imports to observe the + # actual home-manager output. nix-config exercises the real module on + # instantiation. test-home-synthesis = denTest ( { den, @@ -329,7 +355,7 @@ in aspect.homeManager.programs.git.enable = true; }; }; - den.aspects.guest-aspect.guest-os.networking.hostName = "guest-vm"; + den.aspects.guest-aspect.nixos.networking.hostName = "guest-vm"; # The delivered config carries the per-user home-manager content as a # `{ imports = [...]; }` module under home-manager.users.tux — the exact @@ -354,5 +380,95 @@ in } ); + # THE cortex repro: a guest-only option must not leak onto the parent's + # toplevel (microvm.guest does not exist there) and must arrive at the + # delivery path. + test-no-guest-option-leak = denTest ( + { den, igloo, ... }: + { + imports = [ (parentBase den) ]; + den.hosts.x86_64-linux.igloo.deliveredChildren.guest = mkGuest den { }; + den.aspects.guest-aspect.nixos = { + networking.hostName = "guest-vm"; + microvm.guest.enable = true; + }; + + # Forcing the parent's toplevel would throw 'option microvm.guest does + # not exist' if the guest's nixos leaked. networking.hostName is unset + # on the parent, so it falls back to the nixos default. + expr = { + parentEvals = igloo.networking.hostName; + delivered = igloo.microvm.vms.guest.config.microvm.guest.enable; + }; + expected = { + parentEvals = "nixos"; + delivered = true; + }; + } + ); + + # THE load-bearing regression: compose entities are NOT isolated — a + # home-manager user on the PARENT still lands in the parent's nixos even + # while a delivered child coexists. We use the real home-manager battery + # (parent igloo is a genuine nixosSystem) and observe the parent user's HM + # content via the tuxHm fixture (igloo.home-manager.users.tux). + test-parent-home-manager-intact = denTest ( + { + den, + tuxHm, + igloo, + ... + }: + { + imports = [ (parentBase den) ]; + den.hosts.x86_64-linux.igloo = { + users.tux = { }; + deliveredChildren.guest = mkGuest den { }; + }; + # Parent user's home-manager content (standard battery + real module), + # authored on the user's own aspect (named after the user). + den.aspects.tux.homeManager.programs.git.enable = true; + den.aspects.guest-aspect.nixos.networking.hostName = "guest-vm"; + + expr = { + parentHm = tuxHm.programs.git.enable; + delivered = igloo.microvm.vms.guest.config.networking.hostName; + }; + expected = { + parentHm = true; + delivered = "guest-vm"; + }; + } + ); + + # Reused fleet aspect: a SHARED nixos-authored aspect composed into the + # guest lands at the delivery path and does NOT leak to the parent. + test-reused-nixos-aspect-delivered = denTest ( + { + den, + lib, + igloo, + ... + }: + { + imports = [ (parentBase den) ]; + den.hosts.x86_64-linux.igloo.deliveredChildren.guest = mkGuest den { }; + den.aspects.shared-role.nixos.boot.kernelModules = [ "shared-role-module" ]; + den.aspects.guest-aspect = { + includes = [ den.aspects.shared-role ]; + nixos.networking.hostName = "guest-vm"; + }; + + expr = { + delivered = lib.elem "shared-role-module" igloo.microvm.vms.guest.config.boot.kernelModules; + parent = lib.elem "shared-role-module" igloo.boot.kernelModules; + }; + expected = { + delivered = true; + parent = false; + }; + } + ); + }; } From a5cf9c96aed57506194f3552b0769c24b4f30e2e Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Thu, 11 Jun 2026 12:27:41 -0700 Subject: [PATCH 024/101] docs: spawn-node isolation invariant + guest-os removal breaking note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the entity-isolation work (04658982..e11081b1): spawn-node's final extraction walk is isolation-blind by design — isolated entities resolve via resolve.to in the host pipeline, never through spawnNode. Name the invariant at the walk so a future change doesn't silently reintroduce the cross-boundary leak. BREAKING CHANGE (e11081b1): the guest-os class no longer exists. Delivered-guest content must be authored as nixos.* (was guest-os.*). Parents whose host-includes now also fire at the parent scope (e.g. agenix age.*, microvm options) need the corresponding option stubs or modules on the parent. --- nix/lib/aspects/fx/spawn-node.nix | 3 +++ 1 file changed, 3 insertions(+) diff --git a/nix/lib/aspects/fx/spawn-node.nix b/nix/lib/aspects/fx/spawn-node.nix index 99c84ed1b..b81e57021 100644 --- a/nix/lib/aspects/fx/spawn-node.nix +++ b/nix/lib/aspects/fx/spawn-node.nix @@ -151,6 +151,9 @@ in # homeManager content into this node. The fleet pipe values still resolve # correctly because assemblePipes ran over the full merged state; only the # final per-scope class buckets are subtree-restricted here. + # Isolation-blind by design: isolated entities resolve via resolve.to in + # the host pipeline, never through spawnNode, so no isolated descendant + # can appear under spawnRoot. Revisit if that invariant ever changes. isInSubtree = sid: sid == spawnRoot From d278135b30798092cf83e5fc4dd8d38d5288844a Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Thu, 11 Jun 2026 14:38:06 -0700 Subject: [PATCH 025/101] fix(route): deliver re-instantiating routes verbatim and keyed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delivered-child delivery route lands collected nixos at microvm.vms..config, whose option type RE-INSTANTIATES it as a full NixOS system (eval-config + base module-list). nestPlain pre-evaluated the payload in an isolated freeform evalModules and delivered the *resolved* config (no base-module defaults), after unwrapping mod.imports — discarding the per-module key/_file that wrap-classes assigns (@). At a re-instantiating target this poisoned every namespace aggregate (boot/system/networking become valueless) and double-declared the now keyless modules collected across {host,user} scopes (e.g. lix.enable). Add an opt-in `reinstantiate` route flag. When set, deliver each collected keyed wrapper verbatim ({ imports = [mod]; }): the target's own eval-config applies base defaults and dedups identical re-declarations by key — exactly as spawn-node's instantiation walk does. The delivered-child primitive sets it on its delivery route. Existing routes default reinstantiate=false and are byte-identical (verified: axon-01 toplevel drvPath unchanged across the bump; den CI 924/924). --- modules/policies/delivered-child-host.nix | 6 +++++ nix/lib/aspects/fx/route/apply.nix | 1 + nix/lib/aspects/fx/route/wrap.nix | 32 ++++++++++++++++++++--- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/modules/policies/delivered-child-host.nix b/modules/policies/delivered-child-host.nix index 1c864cb74..1ea117d96 100644 --- a/modules/policies/delivered-child-host.nix +++ b/modules/policies/delivered-child-host.nix @@ -173,6 +173,12 @@ let collectSubtree = true; appendToParent = true; path = cfg.deliveryPathFor name; + # The delivery target (microvm.vms..config) RE-INSTANTIATES the + # collected nixos as its own NixOS system, so deliver the keyed module + # wrappers verbatim (base-module defaults apply at the target, keys + # dedup {host,user}-scope re-declarations) instead of pre-evaluating + # them into resolved config (which strips defaults + keys). + reinstantiate = true; }) ] ); diff --git a/nix/lib/aspects/fx/route/apply.nix b/nix/lib/aspects/fx/route/apply.nix index 352bc5e9c..178b5e1ae 100644 --- a/nix/lib/aspects/fx/route/apply.nix +++ b/nix/lib/aspects/fx/route/apply.nix @@ -232,6 +232,7 @@ let inherit (route) path; guard = route.guard or null; adaptArgs = route.adaptArgs or null; + reinstantiate = route.reinstantiate or false; }; # Delivery routes registered inside an isolated child collect rooted at # their own scope but must land the result on the PARENT — the child diff --git a/nix/lib/aspects/fx/route/wrap.nix b/nix/lib/aspects/fx/route/wrap.nix index a57bed952..874959913 100644 --- a/nix/lib/aspects/fx/route/wrap.nix +++ b/nix/lib/aspects/fx/route/wrap.nix @@ -90,11 +90,31 @@ let ); }; - # Nest a module at a target path (dispatch between adapt and plain strategies). + # Nest a module at a path by REFERENCE, keeping the collected module wrapper + # INTACT. Unlike nestPlain (which unwraps `mod.imports` and pre-evaluates the + # content in an isolated freeform evalModules, freezing it to resolved config), + # this preserves the wrapper's `key`/`_file` (assigned by wrap-classes.nix as + # `@`) and delivers the module unevaluated. Required when the + # target RE-INSTANTIATES the delivered content as its own NixOS system (e.g. + # microvm `microvm.vms..config`, whose option type re-runs eval-config with + # the full base module-list): the target then applies base-module defaults AND + # dedups identical re-declarations across {host,user} scopes by `key` — exactly + # as spawn-node's instantiation walk does. Pre-evaluating (nestPlain) instead + # strips base defaults and drops the keys, poisoning every namespace aggregate + # and double-declaring keyless modules at the target. + nestVerbatim = path: mod: { + config = lib.setAttrByPath path { imports = [ mod ]; }; + }; + + # Nest a module at a target path (dispatch between verbatim, adapt, and plain + # strategies). `reinstantiate` selects verbatim delivery for targets that + # re-evaluate the payload as their own module set. nestModule = - path: adaptArgs: mod: + path: adaptArgs: reinstantiate: mod: if path == [ ] then mod + else if reinstantiate then + nestVerbatim path mod else if adaptArgs != null then nestWithAdaptArgs path adaptArgs mod else @@ -144,16 +164,20 @@ let path, guard ? null, adaptArgs ? null, + reinstantiate ? false, }: let adapted = map (adaptModule adaptArgs path) modules; in if adapted == [ ] then [ ] - else if adaptArgs != null && path != [ ] then + # reinstantiate keeps each collected wrapper keyed and separate so the + # target's own evalModules dedups them; it must not be combined into one + # adaptArgs evalModules. + else if adaptArgs != null && path != [ ] && !reinstantiate then [ (guardModule guard (nestWithAdaptArgs path adaptArgs { imports = adapted; })) ] else - map (mod: guardModule guard (nestModule path adaptArgs mod)) adapted; + map (mod: guardModule guard (nestModule path adaptArgs reinstantiate mod)) adapted; # Collect class modules from a forward aspect (recursing into includes). collectClassMods = From 5777b4e98f8efe3f58ae3826a5e6156d621b631f Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Thu, 11 Jun 2026 14:53:11 -0700 Subject: [PATCH 026/101] test(delivered-child-host): re-instantiation regression guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing acceptance tests use a freeform `microvmSlot` stub that stores the delivered config but never re-evaluates it, so they structurally cannot catch a delivery that strips base-module context (the resolved-config bug the route `reinstantiate` flag fixes). Add a faithful `reinstantiatingSlot` whose option type re-runs `evalModules` over the delivered defs together with a base module-list (mirroring microvm's eval-config), and `test-reinstantiation-applies-base-context`: a guest module that READS a base-module default must see it through re-instantiation. This FAILS on the pre-fix route (nestPlain pre-evaluates each module in an isolated freeform evalModules with no base — the read throws) and PASSES with verbatim keyed delivery. --- .../public-api/delivered-child-host.nix | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/templates/ci/modules/public-api/delivered-child-host.nix b/templates/ci/modules/public-api/delivered-child-host.nix index 52d8e62f8..3e47689e4 100644 --- a/templates/ci/modules/public-api/delivered-child-host.nix +++ b/templates/ci/modules/public-api/delivered-child-host.nix @@ -98,6 +98,55 @@ let ageStub ]; }; + + # ---- Faithful RE-INSTANTIATING slot ------------------------------------- + # `microvmSlot` is a freeform stub: it stores the delivered config but never + # re-evaluates it, so it CANNOT catch a delivery that strips base-module + # context or drops module keys. This slot mirrors microvm.nix's real + # `microvm.vms..config`: a custom option type whose `merge` re-runs + # `evalModules` over the delivered defs together with a base module-list. The + # base declares `fromBase` WITH A DEFAULT (analogue of NixOS boot.* defaults) + # and is freeform so authored content lands. + reinstantiatingBase = + { lib, ... }: + { + options.fromBase = lib.mkOption { + type = lib.types.str; + default = "BASE-DEFAULT"; + }; + config._module.freeformType = lib.types.lazyAttrsOf lib.types.anything; + }; + reinstantiatingSlot = + { lib, ... }: + { + options.microvm.vms = lib.mkOption { + default = { }; + type = lib.types.attrsOf ( + lib.types.submodule { + options.config = lib.mkOption { + default = null; + type = lib.types.nullOr ( + lib.mkOptionType { + name = "reinstantiated NixOS config"; + # Re-instantiate the delivered modules WITH base modules — + # exactly as microvm's eval-config does. Returns the full + # evalModules result, so the consumer reads `.config.config.*`. + merge = + _loc: defs: + lib.evalModules { + modules = [ reinstantiatingBase ] ++ map (d: d.value) defs; + }; + } + ); + }; + } + ); + }; + }; + parentReinstantiating = den: { + den.aspects.igloo.includes = [ den.aspects.microvm-reslot ]; + den.aspects.microvm-reslot.nixos.imports = [ reinstantiatingSlot ]; + }; in { flake.tests.delivered-child-host = { @@ -470,5 +519,47 @@ in } ); + # RE-INSTANTIATION: base-module context is preserved (route `reinstantiate`). + # The delivery target re-evaluates the guest's collected nixos as its own + # module set together with BASE modules. A guest module that READS a + # base-module default (`fromBase`) must see it — proving the route delivered + # live MODULES (re-evaluated WITH the base) and not a pre-frozen resolved + # attrset. Pre-fix (nestPlain pre-evaluates each module in an isolated + # freeform evalModules WITHOUT the base) this read has no `fromBase` and the + # delivery THROWS. Only a re-instantiating slot exercises this — the freeform + # `microvmSlot` cannot. + test-reinstantiation-applies-base-context = denTest ( + { den, igloo, ... }: + { + imports = [ (parentReinstantiating den) ]; + den.hosts.x86_64-linux.igloo.deliveredChildren.guest = mkGuest den { }; + den.aspects.guest-aspect.nixos = + { config, ... }: + { + networking.hostName = "guest-vm"; + # Reads a default declared by a BASE module of the target system. + echoed = config.fromBase; + }; + + # `.config.config` — first `.config` is the slot option, second is the + # re-instantiated evalModules result's config. + expr = { + hn = igloo.microvm.vms.guest.config.config.networking.hostName; + echoed = igloo.microvm.vms.guest.config.config.echoed; + }; + expected = { + hn = "guest-vm"; + echoed = "BASE-DEFAULT"; + }; + } + ); + + # RE-INSTANTIATION: identical option-declaring modules collected across + # multiple {host,user} scopes must DEDUP at the re-instantiating target + # (route `reinstantiate` keeps each collected wrapper's `key` intact). Two + # guest users include the SAME option-declaring aspect, so it is emitted at + # both user scopes and collected twice. Pre-fix the route unwrapped the keyed + # wrappers → two keyless declarations of `options.demo.flag` → "already + # declared" throw. Post-fix the wrappers share `nixos@demo-decl` and dedup. }; } From 58cb6990d20887d9665c6fd2c3abdc02d461a1f6 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Thu, 11 Jun 2026 17:28:02 -0700 Subject: [PATCH 027/101] fix(hasAspect): drop ancestor entity-kinds from projected scopeId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-context (projected) hasAspect looks up an aspect's path key in the owning entity's `__pathSetByScope`, bucketed by scopes WITHIN that entity's own resolution — keyed by the owner kind and its descendants (host → user/home), e.g. "host=igloo". But `decomposeSchemaEffect` computed the lookup scopeId from EVERY entity-kind binding present in the production ctx, including ANCESTOR kinds the host inherits under a multi-tier topology (a fleet environment: host.parent = environment). So the lookup key "environment=prod,host=axon-01" never matched the bucket key "host=axon-01", and every in-context hasAspect on such a host read false. In nix-config this surfaced as agenix computing `host.hasAspect den.aspects.core.impermanence == false` on the axon hosts, pointing identityPaths at /etc/ssh instead of /persist/etc/ssh. den's default flake→system→host walk hid it because `system` is a plain string, not an entity kind, so it was already filtered out. Restrict the projected scopeId to the owner subtree: keep a kind only if it is the owner or a descendant of the owner (via den.schema..parent). The hasAspect override still applies to every in-ctx entity kind; only the scope key is narrowed. Regression test reproduces a flake→tier→host topology where the host inherits an ancestor `tier` binding. --- nix/lib/aspects/fx/policy/schema.nix | 26 ++++++- .../internal-api/hasaspect-ancestor-scope.nix | 72 +++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 templates/ci/modules/internal-api/hasaspect-ancestor-scope.nix diff --git a/nix/lib/aspects/fx/policy/schema.nix b/nix/lib/aspects/fx/policy/schema.nix index b68d9e928..1c30985f1 100644 --- a/nix/lib/aspects/fx/policy/schema.nix +++ b/nix/lib/aspects/fx/policy/schema.nix @@ -69,10 +69,34 @@ let overrideKinds = builtins.filter ( k: schemaEntityKindsSet ? ${k} && builtins.isAttrs (rawScopedCtx.${k} or null) ) (builtins.attrNames rawScopedCtx); - scopeId = mkScopeId (lib.getAttrs overrideKinds rawScopedCtx); # Host run buckets every user scope; a host-less entity uses its own. + ownerKind = if (rawScopedCtx.host.__pathSetByScope or null) != null then "host" else targetKind; ownerPathSet = rawScopedCtx.host.__pathSetByScope or rawScopedCtx.${targetKind}.__pathSetByScope or { }; + # The owning entity's `__pathSetByScope` is keyed by scopes WITHIN its own + # resolution — the owner kind and its descendants (host → user/home/…), + # NOT the ANCESTOR topology kinds it inherits in a production ctx (e.g. a + # fleet `environment`/`fleet`). So the projected scopeId must drop those + # ancestor kinds, else the lookup key (`environment=…,host=…`) never matches + # the bucket key (`host=…`) and hasAspect always reads false. Keep + # `overrideKinds` for the hasAspect override (every in-ctx entity kind), but + # restrict the scopeId to the owner subtree. + inOwnerSubtree = + k: + let + go = + c: + c == ownerKind + || ( + let + p = den.schema.${c}.parent or null; + in + p != null && p != c && go p + ); + in + go k; + scopeIdKinds = builtins.filter inOwnerSubtree overrideKinds; + scopeId = mkScopeId (lib.getAttrs scopeIdKinds rawScopedCtx); projected = den.lib.aspects.mkProjectedHasAspect { pathSetByScope = ownerPathSet; inherit scopeId; diff --git a/templates/ci/modules/internal-api/hasaspect-ancestor-scope.nix b/templates/ci/modules/internal-api/hasaspect-ancestor-scope.nix new file mode 100644 index 000000000..029412c86 --- /dev/null +++ b/templates/ci/modules/internal-api/hasaspect-ancestor-scope.nix @@ -0,0 +1,72 @@ +# Regression: projected (in-context) hasAspect under an ANCESTOR entity-kind +# scope. +# +# The owning host's `__pathSetByScope` is bucketed by scopes WITHIN the host's +# own resolution (host + descendants: user/home), keyed e.g. "host=igloo". But a +# host resolved under a fleet-style topology (flake → tier → host, where `tier` +# is an entity kind like an environment) inherits the `tier` binding in its +# production ctx. The projected hasAspect must NOT fold that ancestor kind into +# its lookup scopeId — else it looks up "host=igloo,tier=prod" against a bucket +# keyed "host=igloo" and every in-context hasAspect reads false (the +# /persist-vs-/etc agenix identityPath bug). den's default flake→system→host +# walk hides this because `system` is a plain string, not an entity kind. +{ denTest, lib, ... }: +{ + flake.tests.hasaspect-ancestor-scope = { + + test-projected-hasaspect-under-ancestor-tier = denTest ( + { den, igloo, ... }: + let + inherit (den.lib.policy) resolve instantiate; + in + { + # Insert an entity-kind `tier` between flake-system and host: + # flake-system → tier → host. host.parent = tier makes tier an ANCESTOR. + den.schema.tier.isEntity = true; + den.schema.host.parent = "tier"; + den.schema.flake-system.excludes = [ den.policies.system-to-os-outputs ]; + den.policies.test-tier-walk = { system, ... }: [ (resolve.to "tier" { tier.name = "prod"; }) ]; + den.policies.test-tier-to-host = + { system, ... }: + lib.concatMap ( + host: + lib.optionals (host.intoAttr != [ ]) [ + (resolve.to "host" { inherit host; }) + (instantiate host) + ] + ) (builtins.attrValues (den.hosts.${system} or { })); + den.schema.flake-system.includes = [ den.policies.test-tier-walk ]; + den.schema.tier.includes = [ den.policies.test-tier-to-host ]; + + den.hosts.x86_64-linux.igloo.users.tux = { }; + # transitively-included, settings-bearing, namespaced aspect (the + # core.impermanence shape). + den.aspects.svc.feature = { + settings.opt = lib.mkOption { + type = lib.types.bool; + default = true; + }; + nixos = { }; + }; + den.aspects.role.includes = [ den.aspects.svc.feature ]; + den.aspects.igloo.includes = [ den.aspects.role ]; + den.hosts.x86_64-linux.igloo.settings.svc.feature.opt = true; + + # agenix-shape host-include that reads the PROJECTED host.hasAspect. + den.schema.host.includes = [ + ( + { host, ... }: + { + nixos.environment.etc."has-feature".text = lib.boolToString ( + host.hasAspect den.aspects.svc.feature + ); + } + ) + ]; + + expr = igloo.environment.etc."has-feature".text; + expected = "true"; + } + ); + }; +} From ab212dc2213f4726c501ee5707fd9308d2180e46 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Thu, 11 Jun 2026 19:33:06 -0700 Subject: [PATCH 028/101] refactor(route): keep reinstantiate flag in core, drop delivered-child policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delivered-child-host policy was a consumer composition built entirely from public den primitives (resolve.to.withIncludes, route, schema registration) yet lived in den core, auto-registering a delivered-guest entity kind and a deliveredChildren host option for every consumer — core bloat for a single (nix-config microvm) use case. Keep only the genuine route-engine primitive in den: the route `reinstantiate` flag (wrap.nix/apply.nix), now covered by a standalone, hand-rolled route test (resolve + route + a re-instantiating slot) instead of the policy-coupled acceptance suite. The policy itself moves to its sole consumer (nix-config), where it is named for its domain (host.guests). - remove modules/policies/delivered-child-host.nix - remove the policy acceptance suite (public-api/delivered-child-host.nix) - add route.test-route-reinstantiate-base-context (flag tested directly) - entity-topology test: host.children no longer includes delivered-guest --- modules/policies/delivered-child-host.nix | 316 ---------- .../internal-api/entity-gen-schema.nix | 3 - .../public-api/delivered-child-host.nix | 565 ------------------ templates/ci/modules/public-api/route.nix | 122 ++++ 4 files changed, 122 insertions(+), 884 deletions(-) delete mode 100644 modules/policies/delivered-child-host.nix delete mode 100644 templates/ci/modules/public-api/delivered-child-host.nix diff --git a/modules/policies/delivered-child-host.nix b/modules/policies/delivered-child-host.nix deleted file mode 100644 index 1ea117d96..000000000 --- a/modules/policies/delivered-child-host.nix +++ /dev/null @@ -1,316 +0,0 @@ -# Delivered child host — a reusable primitive for nesting a *guest* host -# inside a *parent* host's instantiated configuration (e.g. a microvm guest -# realized into parent.microvm.vms..config) instead of producing a -# standalone nixosConfigurations. output. -# -# ===================== DESIGN (entity-isolation) =========================== -# -# DELIVERY = entity-isolation + a collect/append-decoupled route. The guest -# authors honest `nixos` content; a dedicated `delivered-guest` kind marked -# `isolated = true` keeps that content out of the parent's own toplevel nixos -# partition (isolation-aware subtree extraction skips isolated descendants and -# everything below them — see spec -# 2026-06-11-entity-isolation-aware-extraction-design.md). -# -# Two EXISTING mechanisms composed, NO further den-core change here: -# -# 1. resolve.to.withIncludes "" [ deliverPolicy ] -# { delivered-guest = guest; host = guest; } -# nests the guest as an isolated child entity scope under the parent host. -# The `host` binding rebinds host to the guest INSIDE the guest scope so -# curated host-include policies (host-to-users, the standard home-manager -# battery, den.default) fire for the guest as if it were a host. The -# `delivered-guest` binding makes resolveEntityClass derive the guest's -# class (always nixos here). -# -# 2. The delivery route is registered INSIDE the guest scope (a resolve -# include). It collects `fromClass = "nixos"` rooted at the guest scope -# (the collection root is exempt from isolation) and, via -# `appendToParent = true`, lands the wrapped result at the PARENT scope -# under the delivery path. The guest carries intoAttr = [] and gets NO -# policy.instantiate, so it produces NO standalone flake output. -# -# (redirect-instantiate — writing into flake.nixosConfigurations.

.config.* -# — is a DEAD END: nixosConfigurations is lazyAttrsOf raw, an already- -# evaluated nixosSystem; writing its .config subpath collides with the -# read-only result instead of injecting a module.) -# -# KIND = a DEDICATED `delivered-guest` kind whose `includes` are a CURATED -# subset of `den.schema.host.includes`, DERIVED (not hand-copied) so it tracks -# host.includes: -# INHERIT participation/identity/collect includes (host-to-users, the -# standard home-manager battery, den.default). -# OMIT includes producing a standalone instantiate output (nix-config's -# colmena host-modules-capture) — a child must not instantiate. Named -# via `den.deliveredChild.omitIncludeNames`. No-op in den-only tests -# (colmena is a nix-config host-include, not a den one). -# RETARGET / OVERRIDE agenix-style host-includes whose class lookup or key -# paths assume the host class: the consumer points public_key / -# secret sources at the parent and targets nixos. Expressed as -# ordinary guest-targeted includes the consumer adds. -# =========================================================================== -{ - den, - config, - lib, - inputs, - ... -}: -let - inherit (den.lib.policy) resolve route mkPolicy; - - guestKind = "delivered-guest"; - - cfg = config.den.deliveredChild; - - # An include entry's stable identifier, used for OMIT filtering. Named - # policy includes carry `.name`; bare functions/attrsets have none. - includeName = inc: if builtins.isAttrs inc && inc ? name then inc.name else null; - - # CURATED includes for the guest kind, DERIVED from host.includes so the - # guest tracks the host's participation surface minus the omitted entries. - # (Read host.includes; we never write back to it from here, so no cycle.) - hostIncludes = config.den.schema.host.includes or [ ]; - curatedFromHost = builtins.filter ( - inc: - let - n = includeName inc; - in - n == null || !(builtins.elem n cfg.omitIncludeNames) - ) hostIncludes; - - # The guest class is always nixos, so the hardcoded `.nixosModules` accessor - # is correct here. Used as the home-manager module default pinned on a raw - # guest record (which bypasses the host submodule's option defaults — gap G6). - hmNixosModule = inputs.home-manager.nixosModules.home-manager; - - # A nixos stateVersion default for the delivered guest, kept as an option - # contract for consumers. den.default's nixos content now DOES emit at the - # guest scope (the guest authors honest nixos), but a guest may still want an - # explicit stateVersion independent of the fleet default. Plain assignment - # matches the historical behavior; set stateVersion to null to apply none. - guestDefault = lib.optional (cfg.stateVersion != null) { - name = "delivered-guest-default"; - nixos.system.stateVersion = cfg.stateVersion; - }; - - # Per-user home-manager synthesis bridge for the delivered guest. - # - # The STANDARD home-manager battery fires for the guest naturally (guest - # class = nixos, host.home-manager.enable defaulted true below), but it - # forwards each user's homeManager content from a NESTED user-under-guest - # resolve sub-scope via a per-scope forward-route. Simple routes (the delivery - # route) collect from the ORIGINAL pre-route per-scope state, so the battery's - # user-scope append is invisible to the delivery route's collection. Worse, - # the user-under-guest scope sits BELOW the isolated guest, so isolation drops - # that battery copy from the parent entirely. - # - # This policy bridges the gap at the GUEST HOST scope (which IS the delivery - # route's collection root): it RESOLVES each homeManager user's homeManager - # content (den.lib.aspects.resolveImports — the same resolver the battery - # forward uses) and emits it as nixos config under home-manager.users. - # as a `{ imports = [...]; }` module. The guest's real home-manager module - # (host.home-manager.module) evaluates those imports when the microvm - # RE-INSTANTIATES the delivered config as the guest's own nixosSystem — - # exactly the standard home-manager.users. submodule contract. There is no - # double-delivery: the battery-forward copy is dropped by isolation. - guestHmUserForward = - { host, ... }: - let - hmUsers = lib.filter (u: lib.elem "homeManager" (u.classes or [ ])) ( - lib.attrValues (host.users or { }) - ); - userHmModule = - user: - den.lib.aspects.resolveImports "homeManager" (den.lib.resolveEntity "user" { inherit host user; }); - in - [ - (den.lib.policy.include { - nixos.home-manager.users = lib.listToAttrs ( - map (user: lib.nameValuePair user.userName (userHmModule user)) hmUsers - ); - }) - ]; - - # The guest kind's includes: curated host participation + the per-user - # home-manager synthesis bridge + the expose policy + a stateVersion default. - curatedIncludes = - curatedFromHost - ++ [ - (mkPolicy "guest-hm-user-forward" guestHmUserForward) - (mkPolicy "expose-child-quirks" exposePolicy) - ] - ++ guestDefault; - - # The home-manager module pinned by the raw guest record. Materialized here - # because a RAW delivered guest bypasses the host submodule (gap G6), so the - # `home-manager.enable`/`.module` option DEFAULTS the standard battery's - # hostConf defines never apply to the `host` binding. We replicate those - # defaults on the raw guest record below (mirroring nix/lib/home-env.nix: - # hostOptions) so: - # - mkDetectHost sees `host.home-manager.enable` = true (a homeManager user - # exists) and does not short-circuit, and - # - the battery's hostModule can read `host.home-manager.module`. - guestHmModule = hmNixosModule; - guestHasHmUser = - guest: - builtins.any (u: builtins.elem "homeManager" (u.classes or [ ])) ( - builtins.attrValues (guest.users or { }) - ); - - # Delivery route, registered INSIDE the guest scope (sourceScopeId = guest = - # collection root); appendToParent lands the wrapped result at the parent. - # Gated against re-fire in nested sub-scopes (user/home), where it would - # self-deliver the guest's content into its own nixos. - deliverPolicyFor = - name: - mkPolicy "deliver-child-${name}" ( - { ... }@args: - lib.optionals (!(args ? user) && !(args ? home)) [ - (route { - fromClass = "nixos"; - intoClass = "nixos"; - collectSubtree = true; - appendToParent = true; - path = cfg.deliveryPathFor name; - # The delivery target (microvm.vms..config) RE-INSTANTIATES the - # collected nixos as its own NixOS system, so deliver the keyed module - # wrappers verbatim (base-module defaults apply at the target, keys - # dedup {host,user}-scope re-declarations) instead of pre-evaluating - # them into resolved config (which strips defaults + keys). - reinstantiate = true; - }) - ] - ); - - # Per-child delivery: resolve the guest as a nested isolated child entity - # authoring honest nixos, carrying its own delivery route into the guest - # scope. Rebinding `host` to the guest makes curated host-includes and the - # standard home-manager battery see the guest as `host`. - resolveChild = - name: guest: - let - # Default the home-manager host option on the raw guest unless the guest - # already declares it (consumer override wins). - hmDefault = lib.optionalAttrs (!(guest ? home-manager)) { - home-manager = { - enable = guestHasHmUser guest; - module = guestHmModule; - }; - }; - withClass = - guest - // hmDefault - // { - class = "nixos"; - intoAttr = [ ]; - }; - in - resolve.to.withIncludes guestKind [ (deliverPolicyFor name) ] { - ${guestKind} = withClass; - host = withClass; - }; - - resolvePolicy = { host, ... }: lib.mapAttrsToList resolveChild (host.deliveredChildren or { }); - - # EXPOSE policy — runs inside the GUEST scope (it is part of the guest kind's - # curated includes, NOT the parent's host.includes). pipe.expose must fire in - # the scope that EMITS the quirk so the value flows up to the parent. Only - # quirks registered in den.quirks are exposed; the default set is opt-in (a - # consumer declares ollama-endpoints / prometheus-targets in its fleet config), - # and referencing an undeclared quirk is a silent no-op. - exposePolicy = - { ... }: - map (q: den.lib.policy.pipe.from q [ den.lib.policy.pipe.expose ]) ( - builtins.filter (q: den.quirks or { } ? ${q}) cfg.exposeQuirks - ); - - # Parent-host option: explicit, per-parent declaration of delivered children. - hostConf = { - options.deliveredChildren = lib.mkOption { - description = '' - Guest host entities delivered as nested children of this host. Each - guest is resolved as an isolated `${guestKind}` child authoring honest - `nixos`, and its content is routed into this host's configuration at - `den.deliveredChild.deliveryPathFor ` instead of producing a - standalone flake output. - ''; - type = lib.types.attrsOf lib.types.raw; - default = { }; - }; - }; -in -{ - config.den.schema.${guestKind} = { - isEntity = true; - isolated = true; - parent = "host"; - includes = curatedIncludes; - }; - - config.den.schema.host.imports = [ hostConf ]; - - config.den.policies.resolve-child-host = resolvePolicy; - - # Wire the PARENT resolve policy into every host scope. GATED on - # host.deliveredChildren so non-parent hosts pay no cost (no-op include → - # byte-identical toplevel). The delivery route is NOT here — it lives in the - # guest kind's curated includes so it fires in the guest scope (its own - # collection root). The expose policy likewise lives in the guest includes. - config.den.schema.host.includes = [ - den.policies.resolve-child-host - ]; - - options.den.deliveredChild = { - deliveryPathFor = lib.mkOption { - description = '' - Function mapping a child name to the parent-config path the child's - content is routed into. Defaults to the microvm guest slot - `microvm.vms..config`; override for other delivery targets. - ''; - type = lib.types.functionTo (lib.types.listOf lib.types.str); - default = name: [ - "microvm" - "vms" - name - "config" - ]; - defaultText = lib.literalExpression ''name: [ "microvm" "vms" name "config" ]''; - }; - omitIncludeNames = lib.mkOption { - description = '' - Names of host-includes to OMIT from the curated guest kind. The default - targets nix-config's colmena `host-modules-capture` host-include, which - runs a `policy.instantiate` producing a standalone OS module list — a - delivered child must not instantiate. - - NOTE: colmena lives in nix-config (modules/den/batteries/colmena.nix), - NOT in den, so `host-modules-capture` is NOT a den host-include. This - omit is therefore a NO-OP in den-only tests (nothing to filter) and is - only exercised by the nix-config consumer. The name was verified against - nix-config: `den.policies.host-modules-capture` → - `den.schema.host.includes`, whose policy `.name` is "host-modules-capture". - ''; - type = lib.types.listOf lib.types.str; - default = [ "host-modules-capture" ]; - }; - exposeQuirks = lib.mkOption { - description = "Fleet quirks exposed from each delivered child up to the parent."; - type = lib.types.listOf lib.types.str; - default = [ - "ollama-endpoints" - "prometheus-targets" - ]; - }; - stateVersion = lib.mkOption { - description = '' - stateVersion default applied in the guest's nixos class. The guest now - authors honest nixos and den.default's nixos content emits at the guest - scope, but this provides an explicit per-guest stateVersion contract. - Set to null to apply none. - ''; - type = lib.types.nullOr lib.types.str; - default = null; - }; - }; -} diff --git a/templates/ci/modules/internal-api/entity-gen-schema.nix b/templates/ci/modules/internal-api/entity-gen-schema.nix index 87647c7c3..711e74523 100644 --- a/templates/ci/modules/internal-api/entity-gen-schema.nix +++ b/templates/ci/modules/internal-api/entity-gen-schema.nix @@ -50,9 +50,6 @@ { expr = den.schema._topology.host.children; expected = [ - # delivered-guest: the delivered-child-host primitive registers a - # dedicated guest kind nested under host (modules/policies). - "delivered-guest" "home" "user" ]; diff --git a/templates/ci/modules/public-api/delivered-child-host.nix b/templates/ci/modules/public-api/delivered-child-host.nix deleted file mode 100644 index 3e47689e4..000000000 --- a/templates/ci/modules/public-api/delivered-child-host.nix +++ /dev/null @@ -1,565 +0,0 @@ -# Acceptance tests for the delivered-child-host PRIMITIVE -# (modules/policies/delivered-child-host.nix). -# -# A delivered child is a guest host that resolves as an ISOLATED nested child -# scope under its parent host and is realized INTO the parent's config -# (microvm.vms..config) instead of producing a standalone -# nixosConfigurations. output. The guest authors honest `nixos`; an -# entity-isolation marker keeps that content out of the parent's own toplevel, -# and a collect/append-decoupled delivery route lands it at the delivery path. -# -# HARNESS NOTE: denTest only exposes INSTANTIATED hosts -# (config.flake.nixosConfigurations..config). A delivered child has NO -# denTest handle, so EVERY assertion observes the child THROUGH the parent's -# instantiated output (`igloo`), reading a child-sourced value back out of -# `igloo.microvm.vms..config.*`. -# -# The primitive supplies: a parent option (host.deliveredChildren), a dedicated -# isolated `delivered-guest` kind whose includes are a curated subset of -# den.schema.host.includes, the delivery policy (resolve + isolation + -# route+collectSubtree+appendToParent), and an expose policy. Tests use the -# primitive — they declare children via den.hosts..igloo.deliveredChildren -# rather than hand-rolling the resolve/route pair. -{ denTest, lib, ... }: -let - # Minimal stand-in for the home-manager nixos module, used only to satisfy - # the guest's host-submodule home-manager.module option (detection). The real - # home-manager module self-references `config.home-manager` and only resolves - # when the guest is re-instantiated as its own nixosSystem; these unit tests - # never re-instantiate (the guest's nixos content is collected into the - # freeform microvm slot), so the per-user home-manager modules the primitive - # emits are re-evaluated explicitly in the assertion instead. Real consumers - # (nix-config) use the real home-manager module when the microvm genuinely - # instantiates. - hmStub = { - options.home-manager.users = lib.mkOption { - type = lib.types.lazyAttrsOf ( - lib.types.submodule { freeformType = lib.types.lazyAttrsOf lib.types.anything; } - ); - default = { }; - }; - }; - - # Stub of the microvm.vms..config slot the real microvm.nixos module - # provides on the PARENT. Freeform so delivered child config lands here. - microvmSlot = - { lib, ... }: - { - options.microvm.vms = lib.mkOption { - type = lib.types.attrsOf ( - lib.types.submodule { - options.config = lib.mkOption { - type = lib.types.submoduleWith { - modules = [ - { config._module.freeformType = lib.types.lazyAttrsOf lib.types.anything; } - ]; - }; - default = { }; - }; - } - ); - default = { }; - }; - }; - - # Stub of the agenix `age.*` options. Host-includes authored `nixos.age.*` - # now fire at the PARENT scope as well as the guest scope (the guest authors - # honest nixos), where a real nixosSystem has no age options. This absorbs - # those option writes on the parent. - ageStub = - { lib, ... }: - { - options.age = lib.mkOption { - type = lib.types.lazyAttrsOf lib.types.anything; - default = { }; - }; - }; - - # A guest entity. The primitive sets class/intoAttr; the realistic gap-table - # (G6) still applies: a raw delivered child bypasses the host submodule's - # userType, so user records must be FULL ({ name; userName; classes; }). - mkGuest = - den: extra: - { - name = "guest"; - system = "x86_64-linux"; - users = { }; - aspect = den.aspects.guest-aspect; - } - // extra; - - # Common parent wiring: the microvm slot + age stub on igloo. Returned as a - # module so it merges (NOT `//`, which would clobber the test body's own - # `den` attr). - parentBase = den: { - den.aspects.igloo.includes = [ den.aspects.microvm-slot ]; - den.aspects.microvm-slot.nixos.imports = [ - microvmSlot - ageStub - ]; - }; - - # ---- Faithful RE-INSTANTIATING slot ------------------------------------- - # `microvmSlot` is a freeform stub: it stores the delivered config but never - # re-evaluates it, so it CANNOT catch a delivery that strips base-module - # context or drops module keys. This slot mirrors microvm.nix's real - # `microvm.vms..config`: a custom option type whose `merge` re-runs - # `evalModules` over the delivered defs together with a base module-list. The - # base declares `fromBase` WITH A DEFAULT (analogue of NixOS boot.* defaults) - # and is freeform so authored content lands. - reinstantiatingBase = - { lib, ... }: - { - options.fromBase = lib.mkOption { - type = lib.types.str; - default = "BASE-DEFAULT"; - }; - config._module.freeformType = lib.types.lazyAttrsOf lib.types.anything; - }; - reinstantiatingSlot = - { lib, ... }: - { - options.microvm.vms = lib.mkOption { - default = { }; - type = lib.types.attrsOf ( - lib.types.submodule { - options.config = lib.mkOption { - default = null; - type = lib.types.nullOr ( - lib.mkOptionType { - name = "reinstantiated NixOS config"; - # Re-instantiate the delivered modules WITH base modules — - # exactly as microvm's eval-config does. Returns the full - # evalModules result, so the consumer reads `.config.config.*`. - merge = - _loc: defs: - lib.evalModules { - modules = [ reinstantiatingBase ] ++ map (d: d.value) defs; - }; - } - ); - }; - } - ); - }; - }; - parentReinstantiating = den: { - den.aspects.igloo.includes = [ den.aspects.microvm-reslot ]; - den.aspects.microvm-reslot.nixos.imports = [ reinstantiatingSlot ]; - }; -in -{ - flake.tests.delivered-child-host = { - - # DELIVERY (crux): parent reads a child-ONLY value back through its - # instantiated config. Declared purely via the primitive's parent option. - test-delivery = denTest ( - { den, igloo, ... }: - { - imports = [ (parentBase den) ]; - den.hosts.x86_64-linux.igloo.deliveredChildren.guest = mkGuest den { }; - den.aspects.guest-aspect.nixos.networking.hostName = "guest-vm"; - - expr = igloo.microvm.vms.guest.config.networking.hostName; - expected = "guest-vm"; - } - ); - - # PARTICIPATION: a curated host-include value fires in the CHILD scope and - # arrives in the delivered config. The host-include emits into nixos, so it - # now fires at the PARENT scope too — assert membership in both, not - # list-equality (the parent carries nixpkgs defaults like atkbd/loop). - test-participation = denTest ( - { den, igloo, ... }: - { - imports = [ (parentBase den) ]; - den.hosts.x86_64-linux.igloo.deliveredChildren.guest = mkGuest den { }; - den.schema.host.includes = [ - { nixos.boot.kernelModules = [ "from-host-include" ]; } - ]; - den.aspects.guest-aspect.nixos.networking.hostName = "guest-vm"; - - expr = { - hn = igloo.microvm.vms.guest.config.networking.hostName; - deliveredHasInclude = lib.elem "from-host-include" igloo.microvm.vms.guest.config.boot.kernelModules; - parentHasInclude = lib.elem "from-host-include" igloo.boot.kernelModules; - }; - expected = { - hn = "guest-vm"; - deliveredHasInclude = true; - parentHasInclude = true; - }; - } - ); - - # EXPOSE: child emits a fleet quirk; the primitive's expose policy lifts it - # to the parent, which consumes it. The quirk is declared + added to the - # primitive's exposeQuirks set. - test-expose = denTest ( - { den, igloo, ... }: - { - den.quirks.guest-ports.description = "ports the guest needs forwarded upward"; - den.deliveredChild.exposeQuirks = [ "guest-ports" ]; - - den.aspects.igloo.includes = [ - den.aspects.microvm-slot - den.aspects.port-consumer - ]; - den.aspects.microvm-slot.nixos.imports = [ microvmSlot ]; - den.hosts.x86_64-linux.igloo.deliveredChildren.guest = mkGuest den { }; - - den.aspects.guest-aspect = { - nixos.networking.hostName = "guest-vm"; - guest-ports = [ 2222 ]; - }; - den.aspects.port-consumer.nixos = - { guest-ports, ... }: - { - networking.firewall.allowedTCPPorts = guest-ports; - }; - - expr = igloo.networking.firewall.allowedTCPPorts; - expected = [ 2222 ]; - } - ); - - # NO STANDALONE OUTPUT: the primitive gives the guest intoAttr = [] and no - # policy.instantiate, so nixosConfigurations.guest does NOT exist; only the - # parent is instantiated. - test-no-standalone-output = denTest ( - { den, config, ... }: - { - imports = [ (parentBase den) ]; - den.hosts.x86_64-linux.igloo.deliveredChildren.guest = mkGuest den { }; - den.aspects.guest-aspect.nixos.networking.hostName = "guest-vm"; - - expr = { - iglooExists = config.flake.nixosConfigurations ? igloo; - guestExists = config.flake.nixosConfigurations ? guest; - }; - expected = { - iglooExists = true; - guestExists = false; - }; - } - ); - - # AGENIX DOESN'T THROW (retarget + parent key): an agenix-like host-include - # reads host.public_key via builtins.readFile. The guest sets public_key to - # the parent's existing key path (clean override), so it resolves and the - # value lands in the delivered config through the parent. The parent - # (igloo) also sets public_key, so the parent-scope evaluation of the - # include resolves too (ageStub absorbs the option). - test-agenix-tailored = denTest ( - { den, igloo, ... }: - let - agenixLike = - { host, ... }: - { - nixos.age.hostPubkey = builtins.readFile host.public_key; - }; - in - { - imports = [ (parentBase den) ]; - den.hosts.x86_64-linux.igloo = { - public_key = ./delivered-child-host.nix; - deliveredChildren.guest = mkGuest den { public_key = ./delivered-child-host.nix; }; - }; - den.schema.host.includes = [ agenixLike ]; - den.aspects.guest-aspect.nixos.networking.hostName = "guest-vm"; - - expr = - igloo.microvm.vms.guest.config.age.hostPubkey != "" - && igloo.microvm.vms.guest.config.networking.hostName == "guest-vm"; - expected = true; - } - ); - - # NEGATIVE (why tailoring is required): a verbatim guest WITHOUT public_key - # hard-blocks the agenix-like readFile the moment the delivered value is - # forced through the parent. (igloo itself HAS public_key, so the parent - # scope is fine; the guest binding lacks it.) - test-agenix-verbatim-blocks = denTest ( - { den, igloo, ... }: - let - agenixLike = - { host, ... }: - { - nixos.age.hostPubkey = builtins.readFile host.public_key; - }; - in - { - imports = [ (parentBase den) ]; - den.hosts.x86_64-linux.igloo = { - public_key = ./delivered-child-host.nix; - deliveredChildren.guest = mkGuest den { }; # NO public_key. - }; - den.schema.host.includes = [ agenixLike ]; - den.aspects.guest-aspect.nixos.networking.hostName = "guest-vm"; - - expr = igloo.microvm.vms.guest.config.age.hostPubkey; - expectedError = { - type = "EvalError"; - msg = "public_key"; - }; - } - ); - - # REALISTIC GUEST: real users + agenix (host pubkey + per-user secret) + - # the stateVersion default. Exercises the COMPLETE tailoring surface - # through the primitive. A delivered child built as a raw entity bypasses - # userType, so the user is a FULL record (gap G6). - test-realistic-guest = denTest ( - { den, igloo, ... }: - let - agenixBattery = - { host, ... }: - { - nixos.age.hostPubkey = builtins.readFile host.public_key; - nixos.age.secrets."tux-password".file = host.public_key; - }; - in - { - imports = [ (parentBase den) ]; - den.deliveredChild.stateVersion = "25.11"; - - den.hosts.x86_64-linux.igloo = { - public_key = ./delivered-child-host.nix; - deliveredChildren.guest = mkGuest den { - public_key = ./delivered-child-host.nix; - # tux is a homeManager user → HM synthesis now fires; pin the stub - # module (the real module needs guest re-instantiation). - home-manager = { - enable = true; - module = hmStub; - }; - users.tux = { - name = "tux"; - userName = "tux"; - classes = [ "homeManager" ]; - }; - }; - }; - den.schema.host.includes = [ agenixBattery ]; - den.aspects.guest-aspect.nixos.networking.hostName = "guest-vm"; - - expr = { - pubkeyResolved = igloo.microvm.vms.guest.config.age.hostPubkey != ""; - secretPresent = igloo.microvm.vms.guest.config.age.secrets ? "tux-password"; - hn = igloo.microvm.vms.guest.config.networking.hostName; - stateVersion = igloo.microvm.vms.guest.config.system.stateVersion; - }; - expected = { - pubkeyResolved = true; - secretPresent = true; - hn = "guest-vm"; - stateVersion = "25.11"; - }; - } - ); - - # HOME-MANAGER SYNTHESIS: a guest user with classes = ["homeManager"] and a - # homeManager aspect must produce a home-manager OUTPUT in the delivered - # config — i.e. igloo.microvm.vms.guest.config.home-manager.users.tux.. - # - # This exercises the guest-hm-user-forward bridge: the standard battery's - # per-user forward appends at the user-under-guest scope (below the isolated - # guest), which isolation drops from the parent. The bridge resolves each - # homeManager user's homeManager content at the GUEST scope (the delivery - # route's collection root) and delivers it under nixos - # home-manager.users.. WITHOUT it the delivered config has NO - # home-manager.users.tux and this test FAILS. - # - # The delivered content is a `{ imports = [...]; }` home-manager module — the - # exact shape the guest's real home-manager module evaluates when the microvm - # re-instantiates the guest config downstream. These unit tests never - # re-instantiate (the guest's nixos content lands in the freeform microvm - # slot), so the assertion re-evaluates the delivered imports to observe the - # actual home-manager output. nix-config exercises the real module on - # instantiation. - test-home-synthesis = denTest ( - { - den, - lib, - igloo, - ... - }: - { - imports = [ (parentBase den) ]; - den.deliveredChild.stateVersion = "25.11"; - - den.hosts.x86_64-linux.igloo.deliveredChildren.guest = mkGuest den { - # Consumer override: pin a stub HM module so the host-submodule - # home-manager.enable option exists (detection) without pulling the - # real home-manager module (which only evaluates on re-instantiation). - home-manager = { - enable = true; - module = hmStub; - }; - users.tux = { - name = "tux"; - userName = "tux"; - classes = [ "homeManager" ]; - # The guest user's home config, attached inline as the aspect. - aspect.homeManager.programs.git.enable = true; - }; - }; - den.aspects.guest-aspect.nixos.networking.hostName = "guest-vm"; - - # The delivered config carries the per-user home-manager content as a - # `{ imports = [...]; }` module under home-manager.users.tux — the exact - # shape the guest's real home-manager module evaluates when the microvm - # re-instantiates the delivered config. We re-evaluate those imports - # here (with a permissive freeform module set, the way the microvm - # nixosSystem would) and assert the user's program setting lands. - expr = { - hmUsers = builtins.attrNames igloo.microvm.vms.guest.config.home-manager.users; - gitEnabled = - (lib.evalModules { - modules = [ - { config._module.freeformType = lib.types.lazyAttrsOf lib.types.anything; } - ] - ++ igloo.microvm.vms.guest.config.home-manager.users.tux.imports; - }).config.programs.git.enable; - }; - expected = { - hmUsers = [ "tux" ]; - gitEnabled = true; - }; - } - ); - - # THE cortex repro: a guest-only option must not leak onto the parent's - # toplevel (microvm.guest does not exist there) and must arrive at the - # delivery path. - test-no-guest-option-leak = denTest ( - { den, igloo, ... }: - { - imports = [ (parentBase den) ]; - den.hosts.x86_64-linux.igloo.deliveredChildren.guest = mkGuest den { }; - den.aspects.guest-aspect.nixos = { - networking.hostName = "guest-vm"; - microvm.guest.enable = true; - }; - - # Forcing the parent's toplevel would throw 'option microvm.guest does - # not exist' if the guest's nixos leaked. networking.hostName is unset - # on the parent, so it falls back to the nixos default. - expr = { - parentEvals = igloo.networking.hostName; - delivered = igloo.microvm.vms.guest.config.microvm.guest.enable; - }; - expected = { - parentEvals = "nixos"; - delivered = true; - }; - } - ); - - # THE load-bearing regression: compose entities are NOT isolated — a - # home-manager user on the PARENT still lands in the parent's nixos even - # while a delivered child coexists. We use the real home-manager battery - # (parent igloo is a genuine nixosSystem) and observe the parent user's HM - # content via the tuxHm fixture (igloo.home-manager.users.tux). - test-parent-home-manager-intact = denTest ( - { - den, - tuxHm, - igloo, - ... - }: - { - imports = [ (parentBase den) ]; - den.hosts.x86_64-linux.igloo = { - users.tux = { }; - deliveredChildren.guest = mkGuest den { }; - }; - # Parent user's home-manager content (standard battery + real module), - # authored on the user's own aspect (named after the user). - den.aspects.tux.homeManager.programs.git.enable = true; - den.aspects.guest-aspect.nixos.networking.hostName = "guest-vm"; - - expr = { - parentHm = tuxHm.programs.git.enable; - delivered = igloo.microvm.vms.guest.config.networking.hostName; - }; - expected = { - parentHm = true; - delivered = "guest-vm"; - }; - } - ); - - # Reused fleet aspect: a SHARED nixos-authored aspect composed into the - # guest lands at the delivery path and does NOT leak to the parent. - test-reused-nixos-aspect-delivered = denTest ( - { - den, - lib, - igloo, - ... - }: - { - imports = [ (parentBase den) ]; - den.hosts.x86_64-linux.igloo.deliveredChildren.guest = mkGuest den { }; - den.aspects.shared-role.nixos.boot.kernelModules = [ "shared-role-module" ]; - den.aspects.guest-aspect = { - includes = [ den.aspects.shared-role ]; - nixos.networking.hostName = "guest-vm"; - }; - - expr = { - delivered = lib.elem "shared-role-module" igloo.microvm.vms.guest.config.boot.kernelModules; - parent = lib.elem "shared-role-module" igloo.boot.kernelModules; - }; - expected = { - delivered = true; - parent = false; - }; - } - ); - - # RE-INSTANTIATION: base-module context is preserved (route `reinstantiate`). - # The delivery target re-evaluates the guest's collected nixos as its own - # module set together with BASE modules. A guest module that READS a - # base-module default (`fromBase`) must see it — proving the route delivered - # live MODULES (re-evaluated WITH the base) and not a pre-frozen resolved - # attrset. Pre-fix (nestPlain pre-evaluates each module in an isolated - # freeform evalModules WITHOUT the base) this read has no `fromBase` and the - # delivery THROWS. Only a re-instantiating slot exercises this — the freeform - # `microvmSlot` cannot. - test-reinstantiation-applies-base-context = denTest ( - { den, igloo, ... }: - { - imports = [ (parentReinstantiating den) ]; - den.hosts.x86_64-linux.igloo.deliveredChildren.guest = mkGuest den { }; - den.aspects.guest-aspect.nixos = - { config, ... }: - { - networking.hostName = "guest-vm"; - # Reads a default declared by a BASE module of the target system. - echoed = config.fromBase; - }; - - # `.config.config` — first `.config` is the slot option, second is the - # re-instantiated evalModules result's config. - expr = { - hn = igloo.microvm.vms.guest.config.config.networking.hostName; - echoed = igloo.microvm.vms.guest.config.config.echoed; - }; - expected = { - hn = "guest-vm"; - echoed = "BASE-DEFAULT"; - }; - } - ); - - # RE-INSTANTIATION: identical option-declaring modules collected across - # multiple {host,user} scopes must DEDUP at the re-instantiating target - # (route `reinstantiate` keeps each collected wrapper's `key` intact). Two - # guest users include the SAME option-declaring aspect, so it is emitted at - # both user scopes and collected twice. Pre-fix the route unwrapped the keyed - # wrappers → two keyless declarations of `options.demo.flag` → "already - # declared" throw. Post-fix the wrappers share `nixos@demo-decl` and dedup. - }; -} diff --git a/templates/ci/modules/public-api/route.nix b/templates/ci/modules/public-api/route.nix index 5097c228b..ea78c4b4d 100644 --- a/templates/ci/modules/public-api/route.nix +++ b/templates/ci/modules/public-api/route.nix @@ -195,5 +195,127 @@ in } ); + # route `reinstantiate`: deliver collected modules VERBATIM into a target + # whose option `merge` RE-INSTANTIATES them as their own module set together + # with base modules (e.g. microvm.nix's `microvm.vms..config`, whose + # merge re-runs eval-config). The flag keeps each collected wrapper's keyed + # module intact instead of pre-evaluating it in an isolated freeform + # evalModules. A guest module that READS a base-module default must see it — + # proving the route shipped live MODULES re-evaluated WITH the base, not a + # pre-frozen resolved attrset. Pre-flag (nestPlain) the read has no + # `fromBase` default in scope and the delivery THROWS. Hand-rolled (no + # delivered-child policy): a bare resolve into an isolated guest scope + a + # delivery route carrying reinstantiate, into a faithfully re-instantiating + # slot. A freeform stub slot (test-isolated-delivery-exactly-once) cannot + # exercise this — it stores defs without re-evaluating them. + test-route-reinstantiate-base-context = denTest ( + { den, igloo, ... }: + let + guestEntity = { + name = "guest"; + system = "x86_64-linux"; + class = "nixos"; + intoAttr = [ ]; + users = { }; + aspect = den.aspects.guest-aspect; + }; + # Delivery route registered INSIDE the guest scope (collection root), + # gated against re-fire in nested sub-scopes. `reinstantiate = true` + # ships the keyed wrappers verbatim. + deliverPolicy = den.lib.policy.mkPolicy "deliver-reinst" ( + { ... }@args: + lib.optionals (!(args ? user) && !(args ? home)) [ + (den.lib.policy.route { + fromClass = "nixos"; + intoClass = "nixos"; + collectSubtree = true; + appendToParent = true; + reinstantiate = true; + path = [ + "microvm" + "vms" + "guest" + "config" + ]; + }) + ] + ); + # Base module of the target system: declares `fromBase` WITH A DEFAULT + # (the analogue of NixOS boot.* defaults) and is freeform so authored + # guest content lands. + reinstantiatingBase = + { lib, ... }: + { + options.fromBase = lib.mkOption { + type = lib.types.str; + default = "BASE-DEFAULT"; + }; + config._module.freeformType = lib.types.lazyAttrsOf lib.types.anything; + }; + # A target slot whose `merge` re-runs evalModules over the delivered + # defs together with the base module-list — exactly as microvm's + # eval-config does. Consumer reads `.config.config.*`. + reinstantiatingSlot = + { lib, ... }: + { + options.microvm.vms = lib.mkOption { + default = { }; + type = lib.types.attrsOf ( + lib.types.submodule { + options.config = lib.mkOption { + default = null; + type = lib.types.nullOr ( + lib.mkOptionType { + name = "reinstantiated NixOS config"; + merge = + _loc: defs: + lib.evalModules { + modules = [ reinstantiatingBase ] ++ map (d: d.value) defs; + }; + } + ); + }; + } + ); + }; + }; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.schema.iso-kind = { + isEntity = true; + parent = "host"; + isolated = true; + }; + den.policies.resolve-reinst-child = + { host, ... }: + lib.optionals (host.name == "igloo") [ + (den.lib.policy.resolve.to.withIncludes "iso-kind" [ deliverPolicy ] { + iso-kind = guestEntity; + }) + ]; + den.schema.host.includes = [ den.policies.resolve-reinst-child ]; + den.aspects.igloo.nixos.imports = [ reinstantiatingSlot ]; + den.aspects.guest-aspect.nixos = + { config, ... }: + { + networking.hostName = "guest-vm"; + # Reads a default declared by a BASE module of the target system. + echoed = config.fromBase; + }; + + # `.config.config` — first `.config` is the slot option, second is the + # re-instantiated evalModules result's config. + expr = { + hn = igloo.microvm.vms.guest.config.config.networking.hostName; + echoed = igloo.microvm.vms.guest.config.config.echoed; + }; + expected = { + hn = "guest-vm"; + echoed = "BASE-DEFAULT"; + }; + } + ); + }; } From 48bf339c80ff3009cae8bc613e8f28bdc7096a72 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 12 Jun 2026 08:53:15 -0700 Subject: [PATCH 029/101] refactor(hasAspect): key projected path-set by entity id_hash, not scope-string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The projected (in-context) hasAspect resolved an in-flight scope's membership against the owning entity's `__pathSetByScope`, which is PRODUCED by that entity's standalone resolve (host as root, no ancestors) but CONSUMED in the fleet resolve (host nested under environment/fleet). Both keyed by `mkScopeId`, but on different ctx — so the consumer had to reconstruct an owner-relative scope-string by walking the parent DAG to strip ancestor entity-kinds and filtering non-entity keys. That reconciliation was the whole exception cluster (ownerKind / inOwnerSubtree / scopeIdKinds / a second mkScopeId), and the class of bug behind the agenix /persist-vs-/etc regression. Re-key the buckets by entity identity (`id_hash`) at the entity surface instead. id_hash is context-free (kind+name, not ancestry) and stable across the standalone-produce and fleet-consume runs, so each in-ctx entity looks up its OWN delivered set by its OWN id_hash — no scope-string, no ancestor stripping, no parent-DAG walk. The produce side is untouched (the structural walk still buckets by scope-string); the re-key is a single pure transform in entities/_types.nix:pathSetByScopeOption, where the root scope's entity is `config` itself (its kind passed in, since the root is seeded without a push-scope record). - resolve.nix: surface scopeContexts + scopeEntityKind on the resolve result - _types.nix: re-key pathSetByScope by id_hash (fold-union on id_hash collision — id_hash is parent-blind, so same-named siblings union rather than last-wins; over-approximation is the safe direction, dropping a bucket false-negatives) - schema.nix decomposeSchemaEffect: drop ownerKind/inOwnerSubtree/scopeIdKinds/ scopeId; each entity binding's hasAspect keys on its own id_hash - has-aspect.nix mkProjectedHasAspect: { pathSetByScope, key } (null-guarded) den CI 915/915; agenix hasAspect core.impermanence resolves /persist on the axon/cortex fleet and the delivered cortex-cuda guest. --- nix/lib/aspects/fx/policy/schema.nix | 63 +++++++------------ nix/lib/aspects/fx/resolve.nix | 4 ++ nix/lib/aspects/has-aspect.nix | 11 ++-- nix/lib/entities/_types.nix | 42 +++++++++++-- nix/lib/entities/home.nix | 2 +- nix/lib/entities/host.nix | 2 +- .../internal-api/hasaspect-ancestor-scope.nix | 21 ++++--- .../internal-api/path-set-by-scope.nix | 4 +- 8 files changed, 87 insertions(+), 62 deletions(-) diff --git a/nix/lib/aspects/fx/policy/schema.nix b/nix/lib/aspects/fx/policy/schema.nix index 1c30985f1..67393c670 100644 --- a/nix/lib/aspects/fx/policy/schema.nix +++ b/nix/lib/aspects/fx/policy/schema.nix @@ -56,53 +56,36 @@ let # In-context `.hasAspect` answers PROJECTED membership — what's delivered # into this scope (incl. `provides`), not the structural registry tree. The - # owning host's production run already bucketed each scope's path set under - # `__pathSetByScope`. The lookup is pure and forced lazily (at a class-module - # `mkIf`), so it never re-enters the resolve that produced it — except from - # an `includes` position, which forces the host's own in-flight - # `__resolveResult` and recurses. So: don't decide includes from it. + # owning host's production run bucketed each scope's path set under + # `__pathSetByScope`, RE-KEYED by entity identity (`id_hash`) at the entity + # surface (entities/_types.nix:pathSetByScopeOption). The lookup is pure and + # forced lazily (at a class-module `mkIf`), so it never re-enters the resolve + # that produced it — except from an `includes` position, which forces the + # host's own in-flight `__resolveResult` and recurses. So: don't decide + # includes from it. # - # Key by entity-kind bindings only: enrichment may add non-entity keys (e.g. - # `system`) to the ctx, but buckets are keyed by entity scope — restricting - # here keeps the lookup key matched. Only `.hasAspect` is swapped, and - # `mkScopeId` keys off `.name`, so the scope ids are otherwise unperturbed. + # Because buckets are keyed by `id_hash` (context-free: kind+name, not + # ancestry), each in-ctx entity looks up its OWN delivered set by its OWN + # id_hash — no scope-string reconstruction, no ancestor stripping. Swap + # `.hasAspect` on every entity-kind binding; ancestors (e.g. a fleet + # `environment`) simply aren't keys in the owner's bucket and read false. overrideKinds = builtins.filter ( k: schemaEntityKindsSet ? ${k} && builtins.isAttrs (rawScopedCtx.${k} or null) ) (builtins.attrNames rawScopedCtx); - # Host run buckets every user scope; a host-less entity uses its own. - ownerKind = if (rawScopedCtx.host.__pathSetByScope or null) != null then "host" else targetKind; + # Host run buckets every descendant scope; a host-less entity uses its own. ownerPathSet = rawScopedCtx.host.__pathSetByScope or rawScopedCtx.${targetKind}.__pathSetByScope or { }; - # The owning entity's `__pathSetByScope` is keyed by scopes WITHIN its own - # resolution — the owner kind and its descendants (host → user/home/…), - # NOT the ANCESTOR topology kinds it inherits in a production ctx (e.g. a - # fleet `environment`/`fleet`). So the projected scopeId must drop those - # ancestor kinds, else the lookup key (`environment=…,host=…`) never matches - # the bucket key (`host=…`) and hasAspect always reads false. Keep - # `overrideKinds` for the hasAspect override (every in-ctx entity kind), but - # restrict the scopeId to the owner subtree. - inOwnerSubtree = - k: - let - go = - c: - c == ownerKind - || ( - let - p = den.schema.${c}.parent or null; - in - p != null && p != c && go p - ); - in - go k; - scopeIdKinds = builtins.filter inOwnerSubtree overrideKinds; - scopeId = mkScopeId (lib.getAttrs scopeIdKinds rawScopedCtx); - projected = den.lib.aspects.mkProjectedHasAspect { - pathSetByScope = ownerPathSet; - inherit scopeId; - }; + projectedFor = + entity: + den.lib.aspects.mkProjectedHasAspect { + pathSetByScope = ownerPathSet; + key = entity.id_hash or null; + }; scopedCtx = - rawScopedCtx // lib.genAttrs overrideKinds (k: rawScopedCtx.${k} // { hasAspect = projected; }); + rawScopedCtx + // lib.genAttrs overrideKinds ( + k: rawScopedCtx.${k} // { hasAspect = projectedFor rawScopedCtx.${k}; } + ); in { inherit diff --git a/nix/lib/aspects/fx/resolve.nix b/nix/lib/aspects/fx/resolve.nix index d557e26be..fc4bdc974 100644 --- a/nix/lib/aspects/fx/resolve.nix +++ b/nix/lib/aspects/fx/resolve.nix @@ -768,6 +768,10 @@ let imports = phase4.${class} or [ ]; # Surfaced from the SAME result.state — Task 1 thunked this onto state. pathSetByScope = result.state.pathSetByScope null; + # Per-scope ctx + entity-kind, so the entity surface can re-key the path + # set from scope-string to entity identity (id_hash) for projected + # hasAspect (see entities/_types.nix:pathSetByScopeOption). + inherit scopeContexts scopeEntityKind; }; # Back-compatible projection: imports only. Protects deferredModule consumers diff --git a/nix/lib/aspects/has-aspect.nix b/nix/lib/aspects/has-aspect.nix index bc24a37f7..1fa50f000 100644 --- a/nix/lib/aspects/has-aspect.nix +++ b/nix/lib/aspects/has-aspect.nix @@ -46,13 +46,14 @@ let (collectPathSet { inherit tree class; }) ? ${refKey ref}; # Projected hasAspect: pure lookup over an already-computed per-scope path - # set (byproduct of the owning entity's production run). No pipeline. - # `pathSetByScope`/`scopeId` are read lazily — forced only when the result - # boolean is scrutinised (e.g. at a class-module `mkIf`, post-run). + # set (byproduct of the owning entity's production run), keyed by entity + # identity (`id_hash`). No pipeline. `pathSetByScope`/`key` are read lazily — + # forced only when the result boolean is scrutinised (e.g. at a class-module + # `mkIf`, post-run). mkProjectedHasAspect = - { pathSetByScope, scopeId }: + { pathSetByScope, key }: let - check = ref: (pathSetByScope.${scopeId} or { }) ? ${refKey ref}; + check = ref: key != null && (pathSetByScope.${key} or { }) ? ${refKey ref}; in { __functor = _: check; diff --git a/nix/lib/entities/_types.nix b/nix/lib/entities/_types.nix index b29702a77..f5bf3c3ff 100644 --- a/nix/lib/entities/_types.nix +++ b/nix/lib/entities/_types.nix @@ -49,16 +49,50 @@ let default = { inherit (config.__resolveResult) imports; }; }; - # Per-scope path set, surfaced for the projected (in-context) hasAspect. + # Per-scope path set for the projected (in-context) hasAspect, RE-KEYED from + # scope-string to ENTITY IDENTITY (id_hash). + # + # The structural walk buckets each node under its scope STRING (`host=…`, + # `host=…,user=…`). But projected hasAspect is consumed in a DEEPER context — + # the fleet resolve, where the owner inherits ancestor scopes (`environment=…`) + # — so a scope-string key can never match the owner's standalone-rooted bucket. + # An entity's `id_hash` is context-free (kind+name, NOT ancestry) and stable + # across the standalone-produce and fleet-consume runs, so re-keying by it lets + # the consumer look up by the consuming entity's OWN id_hash with zero ancestor + # reconciliation. The root scope's entity is `config` itself (its kind is + # passed in, since the root scope is seeded without a push-scope record). pathSetByScopeOption = - _den: config: + _den: kind: config: lib.mkOption { internal = true; visible = false; readOnly = true; type = lib.types.raw; - defaultText = "config.__resolveResult.pathSetByScope"; - default = config.__resolveResult.pathSetByScope; + defaultText = "config.__resolveResult.pathSetByScope (re-keyed by entity id_hash)"; + default = + let + r = config.__resolveResult; + scopeCtxs = r.scopeContexts or { }; + entityKinds = r.scopeEntityKind or { }; + entityForScope = + scopeStr: + let + k = entityKinds.${scopeStr} or kind; + in + (scopeCtxs.${scopeStr} or { }).${k} or config; + in + # Fold (not mapAttrs') so that if two scopes share an id_hash — id_hash + # is parent-blind (kind+name), so same-named siblings under different + # parents collide — their path sets UNION rather than last-wins. Union + # over-approximates membership (the safe direction); dropping a bucket + # would false-negative, the original /persist regression. + lib.foldl' ( + acc: scopeStr: + let + k = (entityForScope scopeStr).id_hash or scopeStr; + in + acc // { ${k} = (acc.${k} or { }) // r.pathSetByScope.${scopeStr}; } + ) { } (builtins.attrNames r.pathSetByScope); }; # Entity kinds from the schema's own kind list (gen-schema _kindNames is diff --git a/nix/lib/entities/home.nix b/nix/lib/entities/home.nix index a9a811d6f..03b91b587 100644 --- a/nix/lib/entities/home.nix +++ b/nix/lib/entities/home.nix @@ -192,7 +192,7 @@ let }; mainModule = mainModuleOption den config; __resolveResult = resolveResultOption den config; - __pathSetByScope = pathSetByScopeOption den config; + __pathSetByScope = pathSetByScopeOption den "home" config; }; } ) diff --git a/nix/lib/entities/host.nix b/nix/lib/entities/host.nix index 0c790d8ba..372ab6f2c 100644 --- a/nix/lib/entities/host.nix +++ b/nix/lib/entities/host.nix @@ -146,7 +146,7 @@ let }; mainModule = mainModuleOption den config; __resolveResult = resolveResultOption den config; - __pathSetByScope = pathSetByScopeOption den config; + __pathSetByScope = pathSetByScopeOption den "host" config; }; } ) diff --git a/templates/ci/modules/internal-api/hasaspect-ancestor-scope.nix b/templates/ci/modules/internal-api/hasaspect-ancestor-scope.nix index 029412c86..0c8795f37 100644 --- a/templates/ci/modules/internal-api/hasaspect-ancestor-scope.nix +++ b/templates/ci/modules/internal-api/hasaspect-ancestor-scope.nix @@ -1,15 +1,18 @@ # Regression: projected (in-context) hasAspect under an ANCESTOR entity-kind # scope. # -# The owning host's `__pathSetByScope` is bucketed by scopes WITHIN the host's -# own resolution (host + descendants: user/home), keyed e.g. "host=igloo". But a -# host resolved under a fleet-style topology (flake → tier → host, where `tier` -# is an entity kind like an environment) inherits the `tier` binding in its -# production ctx. The projected hasAspect must NOT fold that ancestor kind into -# its lookup scopeId — else it looks up "host=igloo,tier=prod" against a bucket -# keyed "host=igloo" and every in-context hasAspect reads false (the -# /persist-vs-/etc agenix identityPath bug). den's default flake→system→host -# walk hides this because `system` is a plain string, not an entity kind. +# A host resolved under a fleet-style topology (flake → tier → host, where +# `tier` is an entity kind like an environment) inherits the `tier` binding in +# its production ctx. The projected hasAspect must still resolve against the +# owning host's bucket — which is produced by the host's OWN standalone run and +# knows nothing of the ancestor `tier`. Buckets are keyed by entity identity +# (`id_hash`), which is context-free (kind+name, not ancestry), so the consuming +# host looks itself up by its own id_hash regardless of any ancestor scopes — +# no scope-string reconstruction, no ancestor to strip. The historical bug +# (keying by a reconstructed "tier=prod,host=igloo" scope-string vs a bucket +# keyed "host=igloo") read false here and dropped agenix's /persist prefix +# (identityPaths → /etc/ssh). den's default flake→system→host walk hides it +# because `system` is a plain string, not an entity kind. { denTest, lib, ... }: { flake.tests.hasaspect-ancestor-scope = { diff --git a/templates/ci/modules/internal-api/path-set-by-scope.nix b/templates/ci/modules/internal-api/path-set-by-scope.nix index 280a71f15..2dafef236 100644 --- a/templates/ci/modules/internal-api/path-set-by-scope.nix +++ b/templates/ci/modules/internal-api/path-set-by-scope.nix @@ -54,11 +54,11 @@ mk = den.lib.aspects.mkProjectedHasAspect; h = mk { pathSetByScope = { - "host=x,user=y" = { + "id:abc" = { "foo" = true; }; }; - scopeId = "host=x,user=y"; + key = "id:abc"; }; in { From 1f9584d4fee6b82daa7ea6978dac7b461e98d065 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 12 Jun 2026 11:42:45 -0700 Subject: [PATCH 030/101] feat(fx): pure arg classifier over the schema entity DAG --- nix/lib/aspects/fx/arg-class.nix | 40 +++++++ nix/lib/aspects/fx/default.nix | 1 + .../ci/modules/internal-api/arg-class.nix | 113 ++++++++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 nix/lib/aspects/fx/arg-class.nix create mode 100644 templates/ci/modules/internal-api/arg-class.nix diff --git a/nix/lib/aspects/fx/arg-class.nix b/nix/lib/aspects/fx/arg-class.nix new file mode 100644 index 000000000..0b68e4cff --- /dev/null +++ b/nix/lib/aspects/fx/arg-class.nix @@ -0,0 +1,40 @@ +# Pure classification of parametric-aspect args against the schema entity DAG. +# An entity-kind arg at scope-kind `scopeKind` is either bindable from ctx +# (handled upstream in bind), a DESCENDANT (relationship fan-out at the +# emitting scope), or misplaced (inert). Non-entity args never reach this. +# +# Child enumeration is convention-based: a parent record holds its `kind` +# children in attr "${kind}s" (host.users; nix-config's guest kind follows +# the same convention with host.guests — note `guest` is NOT in den's +# default schema). A schema-declared override is deliberately deferred +# until a consumer needs one (YAGNI; both known collections follow the +# convention). +# +# Related: resolve.nix's isAncestorOf walks scope-parent chains (different +# map shape, self-loop guard only) — keep the two in mind if either changes. +{ ... }: +rec { + # True when argKind's parent chain reaches scopeKind (strict descendant). + isDescendantOf = + schema: scopeKind: argKind: + let + walk = + k: seen: + let + p = schema.${k}.parent or null; + in + if p == null || builtins.elem p seen then + false + else if p == scopeKind then + true + else + walk p (seen ++ [ k ]); + in + scopeKind != null && argKind != scopeKind && walk argKind [ argKind ]; + + childrenAttrFor = argKind: "${argKind}s"; + + # Child records of `parentRecord` for `argKind`; [ ] when absent. + childrenOf = + parentRecord: argKind: builtins.attrValues (parentRecord.${childrenAttrFor argKind} or { }); +} diff --git a/nix/lib/aspects/fx/default.nix b/nix/lib/aspects/fx/default.nix index a1c589ec1..ab5aa6f7a 100644 --- a/nix/lib/aspects/fx/default.nix +++ b/nix/lib/aspects/fx/default.nix @@ -14,4 +14,5 @@ pipeline = import ./pipeline.nix { inherit lib den; }; wrapClasses = import ./wrap-classes.nix { inherit lib den; }; keyClassification = import ./key-classification.nix { inherit lib den; }; + argClass = import ./arg-class.nix { inherit lib den; }; } diff --git a/templates/ci/modules/internal-api/arg-class.nix b/templates/ci/modules/internal-api/arg-class.nix new file mode 100644 index 000000000..5c336227c --- /dev/null +++ b/templates/ci/modules/internal-api/arg-class.nix @@ -0,0 +1,113 @@ +# Pure-fn tests for the arg classifier over the schema entity DAG. +# Exercises isDescendantOf (parent-chain walk, cycle-safe) and the +# convention-based childrenOf enumeration. No entity wiring — these call +# den.lib.aspects.fx.argClass.* directly against literal schemas. +{ denTest, lib, ... }: +let + schema = { + tier = { }; + host.parent = "tier"; + user.parent = "host"; + home.parent = "host"; + }; + cyclic = { + a.parent = "b"; + b.parent = "a"; + }; +in +{ + flake.tests.arg-class = { + + test-isDescendantOf-direct = denTest ( + { den, ... }: + { + expr = den.lib.aspects.fx.argClass.isDescendantOf schema "host" "user"; + expected = true; + } + ); + + test-isDescendantOf-transitive = denTest ( + { den, ... }: + { + expr = den.lib.aspects.fx.argClass.isDescendantOf schema "tier" "user"; + expected = true; + } + ); + + test-isDescendantOf-self = denTest ( + { den, ... }: + { + expr = den.lib.aspects.fx.argClass.isDescendantOf schema "host" "host"; + expected = false; + } + ); + + test-isDescendantOf-ancestor-inverted = denTest ( + { den, ... }: + { + expr = den.lib.aspects.fx.argClass.isDescendantOf schema "user" "host"; + expected = false; + } + ); + + test-isDescendantOf-null-scope = denTest ( + { den, ... }: + { + expr = den.lib.aspects.fx.argClass.isDescendantOf schema null "user"; + expected = false; + } + ); + + test-isDescendantOf-cycle-safe = denTest ( + { den, ... }: + { + expr = den.lib.aspects.fx.argClass.isDescendantOf cyclic "x" "a"; + expected = false; + } + ); + + test-childrenOf-convention = denTest ( + { den, ... }: + { + expr = lib.sort builtins.lessThan ( + map (c: c.n) ( + den.lib.aspects.fx.argClass.childrenOf { + users = { + tux = { + n = 1; + }; + pingu = { + n = 2; + }; + }; + } "user" + ) + ); + expected = [ + 1 + 2 + ]; + } + ); + + test-isDescendantOf-unknown-kind = denTest ( + { den, ... }: + { + expr = den.lib.aspects.fx.argClass.isDescendantOf { + user.parent = "host"; + host = { }; + } "host" "ghost"; + expected = false; + } + ); + + test-childrenOf-absent = denTest ( + { den, ... }: + { + expr = den.lib.aspects.fx.argClass.childrenOf { } "user"; + expected = [ ]; + } + ); + + }; +} From 761c90f49378394d5b2d56f461bf65f89c0d9cd9 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 12 Jun 2026 12:19:44 -0700 Subject: [PATCH 031/101] feat(fx): synchronous relationship fan-out for descendant entity args --- nix/lib/aspects/fx/handlers/bind.nix | 150 +++++++-- .../fx/handlers/compile-parametric.nix | 15 + .../modules/features/relationship-fanout.nix | 297 ++++++++++++++++++ 3 files changed, 443 insertions(+), 19 deletions(-) create mode 100644 templates/ci/modules/features/relationship-fanout.nix diff --git a/nix/lib/aspects/fx/handlers/bind.nix b/nix/lib/aspects/fx/handlers/bind.nix index 7e8ba2788..d8ac62024 100644 --- a/nix/lib/aspects/fx/handlers/bind.nix +++ b/nix/lib/aspects/fx/handlers/bind.nix @@ -7,6 +7,9 @@ }: let inherit (den.lib) fx; + inherit (den.lib.aspects.fx) argClass; + schema = den.schema or { }; + isEntityKind = k: builtins.isAttrs (schema.${k} or null) && (schema.${k}.isEntity or false); in { bindHandler = { @@ -65,31 +68,140 @@ in } else aspect; - probeArgs = + # Kind of the entity that owns the current scope (K_S), from state. + scopeKind = + if currentScope == null then + null + else + ((state.scopeEntityKind or (_: { })) null).${currentScope} or null; + inherit (den.lib.aspects.fx) identity; + # Per-scope include ancestry of the aspect currently binding. Element 0 + # is the entity-kind root (== scopeKind); element 1 is the schema-include + # node that delivered this aspect into the current scope (its identity + # key). Deeper elements are nested sub-includes — the PROVENANCE ROOT we + # care about stays at index 1 regardless of nesting depth. + scopeChain = ((state.scopedIncludesChain or (_: { })) null).${currentScope} or [ ]; + # identity.key of the schema-include node this aspect descends from, or + # null if the aspect isn't a schema-include descendant (e.g. the entity + # self-aspect with an empty/length-1 chain). + aspectIncludeRoot = if builtins.length scopeChain >= 2 then builtins.elemAt scopeChain 1 else null; + # True when the SAME schema-include node that delivered this aspect into + # the current (ancestor) scope is ALSO registered in `argKind`'s own + # schema includes — i.e. one source injected at both the ancestor AND the + # descendant entity kind (e.g. `den.default`, registered into + # schema.{host,user,home}.includes). Such an aspect reaches the + # descendant directly via its own scope's resolution, so fanning it out + # at the ancestor here would double-cover; it is inert at the ancestor. + # + # Structural-identity comparison: matches on identity.key of the include + # NODE (the provenance root captured in scopedIncludesChain), NOT a + # head-of-display-name string. This kills the false positive on + # coincidental name collisions and the false negative on nested include + # chains that the old `splitString aspect.name` heuristic carried — + # `chain[1]` is the schema-include root at any nesting depth, and + # identity.key is the same stable key both lists register the node under + # (den.default appears as the same value → same key in each). + sharedWithDescendant = + argKind: + let + descIncludes = (schema.${argKind} or { }).includes or [ ]; + in + aspectIncludeRoot != null && builtins.any (inc: identity.key inc == aspectIncludeRoot) descIncludes; + # Per-key probe: which required keys have NO handler anywhere. + probeMissing = keys: - if keys == [ ] then - fx.pure true + builtins.foldl' ( + acc: key: + fx.bind acc ( + missing: + fx.bind (fx.effects.hasHandler key) ( + isAvailable: fx.pure (missing ++ lib.optionals (!isAvailable) [ key ]) + ) + ) + ) (fx.pure [ ]) keys; + # Fan out over the scope's K_a-children for descendant entity-arg + # argKind, refiring `bind` bound to each child. Recursion on "bind" + # discovers further descendant args per child → cartesian for free. + # All instances EMIT AT THE CURRENT SCOPE. + fanOut = + argKind: + let + parentRecord = if scopeKind != null then scopeCtx.${scopeKind} or null else null; + children = if parentRecord == null then [ ] else argClass.childrenOf parentRecord argKind; + bindChild = + idx: child: + fx.send "bind" { + aspect = augmentedAspect // { + __scopeHandlers = + (augmentedAspect.__scopeHandlers or { }) + // den.lib.aspects.fx.handlers.constantHandler { ${argKind} = child; }; + __ctxId = "${ + augmentedAspect.__ctxId or augmentedAspect.name or "fanout" + }@${argKind}=${child.name or (toString idx)}"; + # All fan-out instances emit at the SAME (emitting) scope, so + # per-scope dedup can't keep them apart. Force context + # dependence so emit-class keys each by its ctx-qualified + # identity (preserving the {ctxId} suffix) rather than + # collapsing siblings to a shared base identity. + meta = (augmentedAspect.meta or { }) // { + contextDependent = true; + }; + }; + inherit compileFn; + }; + # Fold child binds, collecting r.value singletons ++ r.fanOut lists. + collect = builtins.foldl' ( + acc: i: + fx.bind acc ( + vals: + fx.bind (bindChild i (builtins.elemAt children i)) ( + r: fx.pure (vals ++ (r.fanOut or (lib.optional (r ? value) r.value))) + ) + ) + ) (fx.pure [ ]) (lib.genList lib.id (builtins.length children)); + in + if children == [ ] then + fx.pure { inert = true; } else - let - key = builtins.head keys; - rest = builtins.tail keys; - in - fx.bind (fx.effects.hasHandler key) ( - isAvailable: if isAvailable then probeArgs rest else fx.pure false - ); + fx.bind collect (vals: fx.pure { fanOut = vals; }); in { - resume = fx.bind (probeArgs keysAfterStateFallback) ( - allAvailable: - if allAvailable then + resume = fx.bind (probeMissing keysAfterStateFallback) ( + missingKeys: + if missingKeys == [ ] then fx.bind (compileFn augmentedAspect) (result: fx.pure { value = result; }) else - fx.bind (fx.send "defer" { - child = aspect; - inherit requiredKeys; - requiredArgs = keysAfterStateFallback; - inherit hasPipeArgs; - }) (_: fx.pure { deferred = true; }) + let + # Entity classification only applies once we're AT an entity + # scope (K_S known). At the root scope (scopeKind == null) entity + # args defer as before, binding as the pipeline descends into the + # owning entity scope. + entityMissing = if scopeKind == null then [ ] else builtins.filter isEntityKind missingKeys; + descendants = builtins.filter (argClass.isDescendantOf schema scopeKind) entityMissing; + misplaced = builtins.filter (k: !(builtins.elem k descendants)) entityMissing; + in + # An entity arg that is neither in-ctx nor a descendant → inert. + if misplaced != [ ] then + fx.pure { inert = true; } + # First descendant arg fans out — unless the same source is also + # injected at the descendant kind (e.g. den.default), in which case + # it reaches the descendant directly and fanning out here would + # double-cover; such an aspect is inert at this scope. + else if descendants != [ ] then + ( + if sharedWithDescendant (builtins.head descendants) then + fx.pure { inert = true; } + else + fanOut (builtins.head descendants) + ) + # Only non-entity (pipe/conditional/enrichment) args remain → defer. + else + fx.bind (fx.send "defer" { + child = aspect; + inherit requiredKeys; + requiredArgs = missingKeys; + inherit hasPipeArgs; + }) (_: fx.pure { deferred = true; }) ); inherit state; }; diff --git a/nix/lib/aspects/fx/handlers/compile-parametric.nix b/nix/lib/aspects/fx/handlers/compile-parametric.nix index c313ac707..6c21b2801 100644 --- a/nix/lib/aspects/fx/handlers/compile-parametric.nix +++ b/nix/lib/aspects/fx/handlers/compile-parametric.nix @@ -62,6 +62,21 @@ in inherit (param) identity ctx; gated = true; } + # Relationship fan-out: one compiled aspect per descendant + # child, each re-resolved at the current (emitting) scope. + else if bindResult ? fanOut then + builtins.foldl' ( + acc: compiled: + fx.bind acc ( + prev: + fx.bind (fx.send "resolve" { + aspect = compiled; + inherit (param) identity ctx; + gated = true; + }) (resolved: fx.pure (prev ++ resolved)) + ) + ) (fx.pure [ ]) bindResult.fanOut + # deferred or inert → contributes nothing here. else fx.pure [ ] ) diff --git a/templates/ci/modules/features/relationship-fanout.nix b/templates/ci/modules/features/relationship-fanout.nix new file mode 100644 index 000000000..00d66d463 --- /dev/null +++ b/templates/ci/modules/features/relationship-fanout.nix @@ -0,0 +1,297 @@ +# Synchronous relationship fan-out: a parametric aspect at scope S destructuring +# an entity-kind arg K_a that is a schema-DAG DESCENDANT of S's kind fans out +# over S's K_a-children, refiring bound to each, EMITTING AT S. Class content +# foreign to S (e.g. homeManager on a host scope) becomes inert. +# +# A misplaced entity arg (K_a neither in-ctx nor a descendant) → whole aspect +# inert, silently. Zero children → inert. Fan-out is synchronous inside `bind`; +# entity args never reach `defer`. +{ denTest, lib, ... }: +{ + flake.tests.relationship-fanout = { + + # 1. Per-user distinct content lands on the host once per user. + test-per-user-distinct-lands-on-host = denTest ( + { + den, + igloo, + lib, + ... + }: + { + den.hosts.x86_64-linux.igloo.users = { + tux = { }; + pingu = { }; + }; + + den.aspects.igloo.nixos.options.funny = lib.mkOption { + default = [ ]; + type = lib.types.listOf lib.types.str; + }; + + # { user } at host scope: user is a descendant of host → fan out. + den.aspects.igloo.includes = [ + ( + { user, ... }: + { + nixos.funny = [ "nixos ${user.name}" ]; + } + ) + ]; + + expr = lib.sort lib.lessThan igloo.funny; + expected = [ + "nixos pingu" + "nixos tux" + ]; + } + ); + + # 2. Dedup guard: identical STATIC content per user appears TWICE in the + # merged list (the per-child __ctxId tag must keep the two fan-out instances + # distinct so neither collapses). + test-identical-static-appears-per-user = denTest ( + { + den, + igloo, + lib, + ... + }: + { + den.hosts.x86_64-linux.igloo.users = { + tux = { }; + pingu = { }; + }; + + den.aspects.igloo.nixos.options.funny = lib.mkOption { + default = [ ]; + type = lib.types.listOf lib.types.str; + }; + + den.aspects.igloo.includes = [ + ( + { user, ... }: + { + nixos.funny = [ "static" ]; + } + ) + ]; + + expr = lib.sort lib.lessThan igloo.funny; + expected = [ + "static" + "static" + ]; + } + ); + + # 3. homeManager content in a HOST-scope fan-out aspect is class-local to the + # host (which does not resolve homeManager) → inert; it must NOT land on the + # users' HM eval. The old cross-scope defer carrier is starved (entity args + # bypass defer), so the leak is gone. + test-homemanager-content-inert-at-host-scope = denTest ( + { + den, + tuxHm, + pinguHm, + ... + }: + { + den.hosts.x86_64-linux.igloo.users = { + tux = { }; + pingu = { }; + }; + + den.aspects.igloo.includes = [ + ( + { user, ... }: + { + homeManager.programs.direnv.enable = true; + } + ) + ]; + + expr = [ + tuxHm.programs.direnv.enable + pinguHm.programs.direnv.enable + ]; + # Inert: host scope resolves no homeManager class. Users never receive it. + expected = [ + false + false + ]; + } + ); + + # 4. Misplaced: a fresh entity kind with no parent, destructured at host + # scope → inert, no error, host still evaluates. + test-misplaced-entity-arg-inert = denTest ( + { + den, + igloo, + lib, + ... + }: + { + den.schema.gadget.isEntity = true; + + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.igloo = { + nixos.networking.hostName = "igloo"; + includes = [ + ( + { gadget, ... }: + { + nixos.networking.hostName = "should-not-appear"; + } + ) + ]; + }; + + expr = igloo.networking.hostName; + expected = "igloo"; + } + ); + + # 5. Zero children: host with no users + { user } aspect → inert, no error. + test-zero-children-inert = denTest ( + { + den, + igloo, + ... + }: + { + den.hosts.x86_64-linux.igloo = { }; + + den.aspects.igloo = { + nixos.networking.hostName = "igloo"; + includes = [ + ( + { user, ... }: + { + nixos.networking.hostName = "should-not-appear"; + } + ) + ]; + }; + + expr = igloo.networking.hostName; + expected = "igloo"; + } + ); + + # 6. Composition of in-ctx + descendant: { host, user } at HOST scope. host is + # in ctx (bound once), user is a descendant (fan-out). Per-user distinct + # content carrying the bound host name lands on the host. + test-host-in-ctx-user-descendant = denTest ( + { + den, + igloo, + lib, + ... + }: + { + den.hosts.x86_64-linux.igloo.users = { + tux = { }; + pingu = { }; + }; + + den.aspects.igloo.nixos.options.funny = lib.mkOption { + default = [ ]; + type = lib.types.listOf lib.types.str; + }; + + den.aspects.igloo.includes = [ + ( + { host, user, ... }: + { + nixos.funny = [ "${user.name}@${host.name}" ]; + } + ) + ]; + + expr = lib.sort lib.lessThan igloo.funny; + expected = [ + "pingu@igloo" + "tux@igloo" + ]; + } + ); + + # 7. TRIPWIRE for carrier removal: this rides the dying cross-scope chain; + # when push-scope inheritance + walkDeferred are removed, classify the flip + # per the formal rule (root scope has no entity kind → strictly inert) and + # update this expectation consciously. + # + # Root-scope path: the top-level resolution starts at the `flake` entity + # (modules/outputs.nix: resolveEntity "flake" {}), whose includes are + # `den.schema.flake.includes` (flake is a non-entity routing kind, so no + # selfProvide). That scope is the rootScopeId with NO scopeEntityKind entry, + # so in bind.nix `scopeKind == null` → the root-scope guard zeroes + # entityMissing and the `{ user, ... }` aspect takes the DEFER path, NOT the + # fan-out path. It is then inherited down through push-scope's + # scopedDeferredIncludes (push-scope.nix:72-79) and refired by walkDeferred + # once `user` is in scope at each user scope — the cross-scope carrier. + # + # CURRENT behavior (determined empirically): both classes are delivered to + # the descendants. nixos content lands on the host once per user + # ("root-saw "), and homeManager content reaches each user's HM eval. + # This is the carrier delivering, NOT the synchronous fan-out path. + # + # When the carrier is removed, the formal rule makes a root-scope entity arg + # strictly inert (root scope has no entity kind to fan out over), so BOTH the + # host funny list AND the per-user HM direnv flip to absent. Update both + # expectations together at that point. + test-root-scope-descendant-arg-tripwire = denTest ( + { + den, + igloo, + tuxHm, + pinguHm, + lib, + ... + }: + { + den.hosts.x86_64-linux.igloo.users = { + tux = { }; + pingu = { }; + }; + + den.aspects.igloo.nixos.options.funny = lib.mkOption { + default = [ ]; + type = lib.types.listOf lib.types.str; + }; + + # { user } aspect at the ROOT (flake) scope — NOT host scope. Verified to + # take the defer path (scopeKind == null), not the fan-out path. + den.schema.flake.includes = [ + ( + { user, ... }: + { + nixos.funny = [ "root-saw ${user.name}" ]; + homeManager.programs.direnv.enable = true; + } + ) + ]; + + expr = { + hostFunny = lib.sort lib.lessThan igloo.funny; + tuxDirenv = tuxHm.programs.direnv.enable; + pinguDirenv = pinguHm.programs.direnv.enable; + }; + # Carrier-delivered (current). On carrier removal: hostFunny → [ ], + # tuxDirenv → false, pinguDirenv → false. + expected = { + hostFunny = [ + "root-saw pingu" + "root-saw tux" + ]; + tuxDirenv = true; + pinguDirenv = true; + }; + } + ); + + }; +} From 13e79ca7f5a69acf42b55626a20ff3b6283ea06c Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 12 Jun 2026 13:02:04 -0700 Subject: [PATCH 032/101] refactor(fx)!: remove cross-scope entity-arg deferral; class-local emission --- nix/lib/aspects/fx/arg-class.nix | 3 + nix/lib/aspects/fx/handlers/bind.nix | 17 ++- nix/lib/aspects/fx/handlers/defer.nix | 14 +- nix/lib/aspects/fx/handlers/push-scope.nix | 43 +++--- .../fx/handlers/resolve-schema-entity.nix | 40 ++---- templates/ci/modules/features/issue-609.nix | 136 ++++++++++++++++++ .../modules/features/relationship-fanout.nix | 48 +++---- .../ci/modules/internal-api/aspect-path.nix | 10 +- .../internal-api/fx-bind-subsystem.nix | 20 ++- .../ci/modules/internal-api/fx-handlers.nix | 15 +- .../modules/internal-api/fx-scope-effects.nix | 29 ++-- .../public-api/user-host-mutual-config.nix | 11 +- 12 files changed, 257 insertions(+), 129 deletions(-) create mode 100644 templates/ci/modules/features/issue-609.nix diff --git a/nix/lib/aspects/fx/arg-class.nix b/nix/lib/aspects/fx/arg-class.nix index 0b68e4cff..c8c02bcaa 100644 --- a/nix/lib/aspects/fx/arg-class.nix +++ b/nix/lib/aspects/fx/arg-class.nix @@ -32,6 +32,9 @@ rec { in scopeKind != null && argKind != scopeKind && walk argKind [ argKind ]; + # True when `k` names a registered entity kind in `schema`. + isEntityKind = schema: k: builtins.isAttrs (schema.${k} or null) && (schema.${k}.isEntity or false); + childrenAttrFor = argKind: "${argKind}s"; # Child records of `parentRecord` for `argKind`; [ ] when absent. diff --git a/nix/lib/aspects/fx/handlers/bind.nix b/nix/lib/aspects/fx/handlers/bind.nix index d8ac62024..2fe904409 100644 --- a/nix/lib/aspects/fx/handlers/bind.nix +++ b/nix/lib/aspects/fx/handlers/bind.nix @@ -9,7 +9,7 @@ let inherit (den.lib) fx; inherit (den.lib.aspects.fx) argClass; schema = den.schema or { }; - isEntityKind = k: builtins.isAttrs (schema.${k} or null) && (schema.${k}.isEntity or false); + isEntityKind = argClass.isEntityKind schema; in { bindHandler = { @@ -172,11 +172,16 @@ in fx.bind (compileFn augmentedAspect) (result: fx.pure { value = result; }) else let - # Entity classification only applies once we're AT an entity - # scope (K_S known). At the root scope (scopeKind == null) entity - # args defer as before, binding as the pipeline descends into the - # owning entity scope. - entityMissing = if scopeKind == null then [ ] else builtins.filter isEntityKind missingKeys; + # Entity classification (the formal rule). An entity-kind missing + # arg at scope S (kind K_S) is: in-ctx (handled upstream, not + # missing) → descendant of K_S → fan out at S → otherwise MISPLACED + # → whole aspect inert, silently. + # + # Root scope (scopeKind == null) has NO entity kind: isDescendantOf + # is false for a null scopeKind, so every entity arg here is + # misplaced → inert. (The old cross-scope defer carrier is gone; a + # root defer would dangle forever — inert is the rule's verdict.) + entityMissing = builtins.filter isEntityKind missingKeys; descendants = builtins.filter (argClass.isDescendantOf schema scopeKind) entityMissing; misplaced = builtins.filter (k: !(builtins.elem k descendants)) entityMissing; in diff --git a/nix/lib/aspects/fx/handlers/defer.nix b/nix/lib/aspects/fx/handlers/defer.nix index 446ffc268..23d442bbe 100644 --- a/nix/lib/aspects/fx/handlers/defer.nix +++ b/nix/lib/aspects/fx/handlers/defer.nix @@ -6,7 +6,10 @@ }: let inherit (den.lib) fx; + inherit (den.lib.aspects.fx) argClass; inherit (import ./state-util.nix) scopedAppend; + schema = den.schema or { }; + isEntityKind = argClass.isEntityKind schema; in { deferHandler = { @@ -14,6 +17,15 @@ in { param, state }: let inherit (param) child requiredKeys requiredArgs; + # Entity-kind args never reach defer: bind classifies every entity arg + # (ctx/fan-out/inert) synchronously. A leaked entity arg here is a + # resolver bug — fail loud rather than silently dangle. + entityArgs = builtins.filter isEntityKind requiredArgs; + guard = + if entityArgs == [ ] then + null + else + throw "den: entity-kind arg '${builtins.head entityArgs}' reached defer for aspect '${child.name or ""}' — bind should have classified it (fan-out/inert); this is a resolver bug"; stub = { name = child.name or ""; meta = (child.meta or { }) // { @@ -22,7 +34,7 @@ in includes = [ ]; }; in - { + builtins.seq guard { resume = fx.bind (fx.send "resolve-complete" stub) (_: fx.pure [ ]); state = scopedAppend state "scopedDeferredIncludes" state.currentScope { inherit child requiredKeys requiredArgs; diff --git a/nix/lib/aspects/fx/handlers/push-scope.nix b/nix/lib/aspects/fx/handlers/push-scope.nix index 726bb0ed4..abf0e8f65 100644 --- a/nix/lib/aspects/fx/handlers/push-scope.nix +++ b/nix/lib/aspects/fx/handlers/push-scope.nix @@ -1,6 +1,9 @@ # Effect handler: push-scope -# Atomically sets currentScope, scopeContexts, scopeParent, -# inherits scopedAspectPolicies, and fans out scopedDeferredIncludes. +# Atomically sets currentScope, scopeContexts, scopeParent, and inherits +# scopedAspectPolicies. Deferred includes are NOT inherited to child scopes: +# entity-kind args are classified synchronously in bind (fan-out/inert/ctx), +# never carried cross-scope. Non-entity (pipe/enrichment) deferred includes are +# drained same-scope (drain.nix / resolve.nix baseDrain / scope-widen). { lib, den, @@ -22,8 +25,6 @@ let scopeHandlers = constantHandler ( scopedCtx // lib.optionalAttrs (entityClass != null) { class = entityClass; } ); - allDeferred = (state.scopedDeferredIncludes or (_: { })) null; - parentItems = allDeferred.${parentScope} or [ ]; in let prevContexts = state.scopeContexts null; @@ -55,28 +56,18 @@ let inherit scopeHandlers; scopeId = newScopeId; }; - state = - state - // { - currentScope = newScopeId; - inLateDispatch = false; - inLateDispatchStack = (state.inLateDispatchStack or [ ]) ++ [ (state.inLateDispatch or false) ]; - scopeContexts = _: updatedContexts; - scopeParent = _: updatedParent; - scopedAspectPolicies = _: updatedPolicies; - scopeEntityClass = _: updatedEntityClass; - scopeEntityKind = _: updatedEntityKind; - scopeSourcePolicy = _: updatedSourcePolicy; - scopeIsolated = _: updatedIsolated; - } - // lib.optionalAttrs (parentItems != [ ]) { - scopedDeferredIncludes = - _: - allDeferred - // { - ${newScopeId} = (allDeferred.${newScopeId} or [ ]) ++ parentItems; - }; - }; + state = state // { + currentScope = newScopeId; + inLateDispatch = false; + inLateDispatchStack = (state.inLateDispatchStack or [ ]) ++ [ (state.inLateDispatch or false) ]; + scopeContexts = _: updatedContexts; + scopeParent = _: updatedParent; + scopedAspectPolicies = _: updatedPolicies; + scopeEntityClass = _: updatedEntityClass; + scopeEntityKind = _: updatedEntityKind; + scopeSourcePolicy = _: updatedSourcePolicy; + scopeIsolated = _: updatedIsolated; + }; }; }; in diff --git a/nix/lib/aspects/fx/handlers/resolve-schema-entity.nix b/nix/lib/aspects/fx/handlers/resolve-schema-entity.nix index 8aa3cbf40..deb6e63b8 100644 --- a/nix/lib/aspects/fx/handlers/resolve-schema-entity.nix +++ b/nix/lib/aspects/fx/handlers/resolve-schema-entity.nix @@ -1,6 +1,8 @@ # Handles: resolve-schema-entity -# Entity resolution: push scope, resolve entity, walk tree, -# drain deferred, propagate forwards, pop scope. +# Entity resolution: push scope, resolve entity, propagate forwards, pop scope. +# Deferred includes are NOT refired here — entity-kind args are classified +# synchronously in bind (fan-out/inert/ctx). Non-entity deferred includes are +# drained post-pipeline (resolve.nix baseDrain) or on context-widen. { lib, den, @@ -31,28 +33,7 @@ let ++ map stripCtxId resolveIncludes; }; - # Walk deferred aspects after entity resolution, collecting results. - walkDeferred = - scopeHandlersForCtx: scopedCtx: prevResults: childResult: satisfiable: - builtins.foldl' ( - acc': deferred: - fx.bind acc' ( - prev: - let - child = deferred.child // { - __scopeHandlers = scopeHandlersForCtx; - }; - in - fx.bind (fx.send "resolve" { - aspect = child; - identity = identity.key child; - ctx = scopedCtx; - gated = true; - }) (resolved: fx.pure (prev ++ [ resolved ])) - ) - ) (fx.pure (prevResults ++ [ childResult ])) satisfiable; - - # Resolve entity tree within scope: resolve-entity → walk → drain → propagate. + # Resolve entity tree within scope: resolve-entity → resolve → propagate → pop. resolveEntityInScope = scopeHandlersForCtx: scopedCtx: param: prevResults: scopeId: parentScope: fx.effects.scope.provide scopeHandlersForCtx ( @@ -74,15 +55,10 @@ let resolvedList: let childResult = builtins.head resolvedList; + allResults = prevResults ++ [ childResult ]; in - fx.bind (fx.send "drain" scopedCtx) ( - satisfiable: - fx.bind (walkDeferred scopeHandlersForCtx scopedCtx prevResults childResult satisfiable) ( - allResults: - fx.bind (fx.send "propagate-routes" { inherit scopeId; }) ( - _: fx.bind (fx.send "restore-scope" { inherit parentScope; }) (_: fx.pure allResults) - ) - ) + fx.bind (fx.send "propagate-routes" { inherit scopeId; }) ( + _: fx.bind (fx.send "restore-scope" { inherit parentScope; }) (_: fx.pure allResults) ) ) ) diff --git a/templates/ci/modules/features/issue-609.nix b/templates/ci/modules/features/issue-609.nix new file mode 100644 index 000000000..573564798 --- /dev/null +++ b/templates/ci/modules/features/issue-609.nix @@ -0,0 +1,136 @@ +# Acceptance for denful/den#609: a host-scope aspect must NOT leak homeManager +# content into its users' HM evaluation. The formal rule (class-local emission): +# a parametric aspect at scope S (entity kind K_S) destructuring entity-kind K_a +# - K_a in ctx → bind once at S +# - K_a descendant of S → fan out over S's K_a-children, EMIT AT S +# - neither → misplaced → whole aspect inert, silently +# Emission is always class-local to the EMITTING scope. The host scope resolves +# nixos but NOT homeManager, so homeManager content in a host-scope aspect is +# inert — it never reaches users. This is the #609 fix: homeManager must reach +# users only via a to-users policy, never a bare host-scope include. +{ denTest, ... }: +{ + flake.tests.issue-609 = { + + # (a) { user, … } at host scope: user is a descendant of host → fan out. + # The nixos content lands per-user, merged on the host, once per user. + test-user-param-nixos-lands-on-host = denTest ( + { + den, + igloo, + lib, + ... + }: + { + den.hosts.x86_64-linux.igloo.users = { + tux = { }; + pingu = { }; + }; + + den.aspects.igloo.nixos.options.funny = lib.mkOption { + default = [ ]; + type = lib.types.listOf lib.types.str; + }; + + den.aspects.igloo.includes = [ + ( + { user, ... }: + { + nixos.funny = [ "nixos ${user.name}" ]; + homeManager.home.sessionVariables.LEAK = "hm ${user.name}"; + } + ) + ]; + + expr = lib.sort lib.lessThan igloo.funny; + expected = [ + "nixos pingu" + "nixos tux" + ]; + } + ); + + # (b) Same aspect's homeManager content does NOT reach users' HM eval. + # The host scope resolves no homeManager class → the LEAK is inert. + test-user-param-hm-does-not-leak = denTest ( + { + den, + tuxHm, + pinguHm, + ... + }: + { + den.hosts.x86_64-linux.igloo.users = { + tux = { }; + pingu = { }; + }; + + den.aspects.igloo.includes = [ + ( + { user, ... }: + { + homeManager.home.sessionVariables.LEAK = "hm ${user.name}"; + } + ) + ]; + + expr = [ + (tuxHm.home.sessionVariables.LEAK or "MISSING") + (pinguHm.home.sessionVariables.LEAK or "MISSING") + ]; + expected = [ + "MISSING" + "MISSING" + ]; + } + ); + + # (c) Plain (non-parametric) host-scope aspect with homeManager content: + # still class-local to the host → MISSING in users' HM eval. + test-plain-hm-at-host-missing = denTest ( + { + den, + tuxHm, + ... + }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.igloo.includes = [ + { + homeManager.home.sessionVariables.LEAK = "plain"; + } + ]; + + expr = tuxHm.home.sessionVariables.LEAK or "MISSING"; + expected = "MISSING"; + } + ); + + # (d) { host, … } host-scope aspect with homeManager content: host is in ctx + # (bound once), but the emission is still class-local to the host → MISSING. + test-host-param-hm-at-host-missing = denTest ( + { + den, + tuxHm, + ... + }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.igloo.includes = [ + ( + { host, ... }: + { + homeManager.home.sessionVariables.LEAK = "hm ${host.name}"; + } + ) + ]; + + expr = tuxHm.home.sessionVariables.LEAK or "MISSING"; + expected = "MISSING"; + } + ); + + }; +} diff --git a/templates/ci/modules/features/relationship-fanout.nix b/templates/ci/modules/features/relationship-fanout.nix index 00d66d463..fc83bcd53 100644 --- a/templates/ci/modules/features/relationship-fanout.nix +++ b/templates/ci/modules/features/relationship-fanout.nix @@ -219,31 +219,20 @@ } ); - # 7. TRIPWIRE for carrier removal: this rides the dying cross-scope chain; - # when push-scope inheritance + walkDeferred are removed, classify the flip - # per the formal rule (root scope has no entity kind → strictly inert) and - # update this expectation consciously. + # 7. Root-scope entity arg → INERT (carrier removed @ this commit; rule: root + # entity arg → inert). The formal rule: a parametric entity arg at a scope + # whose entity kind is NEITHER able to bind it from ctx NOR an ancestor of it + # is misplaced → whole aspect inert, silently. The root (flake) scope has NO + # entity kind, so a `{ user, ... }` aspect there is strictly inert. # - # Root-scope path: the top-level resolution starts at the `flake` entity + # Root-scope path: top-level resolution starts at the `flake` entity # (modules/outputs.nix: resolveEntity "flake" {}), whose includes are - # `den.schema.flake.includes` (flake is a non-entity routing kind, so no - # selfProvide). That scope is the rootScopeId with NO scopeEntityKind entry, - # so in bind.nix `scopeKind == null` → the root-scope guard zeroes - # entityMissing and the `{ user, ... }` aspect takes the DEFER path, NOT the - # fan-out path. It is then inherited down through push-scope's - # scopedDeferredIncludes (push-scope.nix:72-79) and refired by walkDeferred - # once `user` is in scope at each user scope — the cross-scope carrier. - # - # CURRENT behavior (determined empirically): both classes are delivered to - # the descendants. nixos content lands on the host once per user - # ("root-saw "), and homeManager content reaches each user's HM eval. - # This is the carrier delivering, NOT the synchronous fan-out path. - # - # When the carrier is removed, the formal rule makes a root-scope entity arg - # strictly inert (root scope has no entity kind to fan out over), so BOTH the - # host funny list AND the per-user HM direnv flip to absent. Update both - # expectations together at that point. - test-root-scope-descendant-arg-tripwire = denTest ( + # `den.schema.flake.includes` (flake is a non-entity routing kind). That + # scope is the rootScopeId with NO scopeEntityKind entry, so bind.nix sees + # scopeKind == null: isDescendantOf is false, the `user` arg is misplaced → + # inert. Previously this rode the cross-scope defer carrier (push-scope + # deferred inheritance + walkDeferred refire); that carrier is now removed. + test-root-scope-descendant-arg-inert = denTest ( { den, igloo, @@ -280,15 +269,12 @@ tuxDirenv = tuxHm.programs.direnv.enable; pinguDirenv = pinguHm.programs.direnv.enable; }; - # Carrier-delivered (current). On carrier removal: hostFunny → [ ], - # tuxDirenv → false, pinguDirenv → false. + # Inert: root scope has no entity kind, so the { user } aspect is + # misplaced. Neither class is delivered to descendants. expected = { - hostFunny = [ - "root-saw pingu" - "root-saw tux" - ]; - tuxDirenv = true; - pinguDirenv = true; + hostFunny = [ ]; + tuxDirenv = false; + pinguDirenv = false; }; } ); diff --git a/templates/ci/modules/internal-api/aspect-path.nix b/templates/ci/modules/internal-api/aspect-path.nix index cc7f75d93..a8addd983 100644 --- a/templates/ci/modules/internal-api/aspect-path.nix +++ b/templates/ci/modules/internal-api/aspect-path.nix @@ -236,13 +236,15 @@ ); expr = trace "nixos" den.aspects.role; - # perHost wrapper defers when no host context is available in the - # trace pipeline (ctx = {}). The deferred stub shows the name but - # no inner children. + # §6 rule-correct: the trace pipeline runs at the ROOT scope (ctx = {}), + # which has no entity kind. A perHost ({ host }) aspect there destructures + # an entity kind that is neither in-ctx nor a descendant → misplaced → + # inert (no deferred stub emitted). `param` drops from the trace entirely. + # (Previously it deferred and showed a childless stub via the cross-scope + # carrier, now removed.) expected.trace = [ "role" [ "leaf" ] - [ "param" ] ]; } ); diff --git a/templates/ci/modules/internal-api/fx-bind-subsystem.nix b/templates/ci/modules/internal-api/fx-bind-subsystem.nix index 12e4d7e56..6a7934df6 100644 --- a/templates/ci/modules/internal-api/fx-bind-subsystem.nix +++ b/templates/ci/modules/internal-api/fx-bind-subsystem.nix @@ -45,21 +45,24 @@ } ); - # bind: missing scope handlers for required args → defers, returns { deferred = true }. + # bind: missing scope handlers for a NON-entity required arg → defers, + # returns { deferred = true }. (Entity-kind args never defer — bind classifies + # them fan-out/inert/ctx; this test exercises the generic defer path for a + # plain (pipe/enrichment-style) arg.) test-bind-defers-missing = denTest ( { den, ... }: let fx = den.lib.fx; handlers = den.lib.aspects.fx.handlers; aspect = { - name = "needs-host"; + name = "needs-widget"; __fn = - { host }: + { widget }: { - inherit host; + inherit widget; }; __args = { - host = false; + widget = false; }; }; compileFn = _: fx.pure { compiled = true; }; @@ -157,10 +160,13 @@ }; }; }; + # Non-entity arg: defer's loud guard throws on entity-kind args (those + # are classified in bind), so generic queue/stub mechanics are exercised + # with a plain (pipe/enrichment-style) key. comp = fx.send "defer" { inherit child; - requiredKeys = [ "host" ]; - requiredArgs = [ "host" ]; + requiredKeys = [ "widget" ]; + requiredArgs = [ "widget" ]; }; result = fx.handle { handlers = handlers.deferHandler // stubCapture; diff --git a/templates/ci/modules/internal-api/fx-handlers.nix b/templates/ci/modules/internal-api/fx-handlers.nix index f326e1770..59369307c 100644 --- a/templates/ci/modules/internal-api/fx-handlers.nix +++ b/templates/ci/modules/internal-api/fx-handlers.nix @@ -353,6 +353,9 @@ let fx = den.lib.fx; handlers = den.lib.aspects.fx.handlers; + # Non-entity args: defer's loud guard throws on entity-kind args (those + # are classified in bind, never deferred). Accumulation mechanics are + # class-agnostic, so exercise with plain (pipe/enrichment-style) keys. comp = fx.bind (fx.send "defer" { @@ -360,11 +363,11 @@ name = "a"; __fn = _: { }; __args = { - host = false; + widget = false; }; }; - requiredKeys = [ "host" ]; - requiredArgs = [ "host" ]; + requiredKeys = [ "widget" ]; + requiredArgs = [ "widget" ]; }) ( _: @@ -373,11 +376,11 @@ name = "b"; __fn = _: { }; __args = { - user = false; + gizmo = false; }; }; - requiredKeys = [ "user" ]; - requiredArgs = [ "user" ]; + requiredKeys = [ "gizmo" ]; + requiredArgs = [ "gizmo" ]; } ); result = fx.handle { diff --git a/templates/ci/modules/internal-api/fx-scope-effects.nix b/templates/ci/modules/internal-api/fx-scope-effects.nix index fead3a3c3..604ad0ee9 100644 --- a/templates/ci/modules/internal-api/fx-scope-effects.nix +++ b/templates/ci/modules/internal-api/fx-scope-effects.nix @@ -65,7 +65,9 @@ } ); - # push-scope: re-entry appends parent deferred to child (fan-out on every entry). + # push-scope: deferred includes do NOT propagate to child scopes. Entity-arg + # deferral is gone; deferred entries stay where they were queued. Parent keeps + # its entry; the child gets none from inheritance. test-push-scope-reentry-deferred = denTest ( { den, ... }: let @@ -85,19 +87,14 @@ }; } ]; - # State where child scope already exists (re-entry scenario). baseState = pipeline.defaultState // { currentScope = parentScope; scopeContexts = _: { ${parentScope} = { host = "alpha"; }; - ${childScope} = scopedCtx; - }; - scopedDeferredIncludes = _: { - ${parentScope} = deferred; - ${childScope} = deferred; }; + scopedDeferredIncludes = _: { ${parentScope} = deferred; }; }; comp = fx.send "push-scope" { inherit scopedCtx parentScope; @@ -107,15 +104,23 @@ handlers = handlers.pushScopeHandler; state = baseState; } comp; + allDeferred = result.state.scopedDeferredIncludes null; in { - # On re-entry, parent deferred items are appended (matching old copyDeferredToScope behavior). - expr = builtins.length ((result.state.scopedDeferredIncludes null).${childScope} or [ ]); - expected = 2; + # Parent retains its 1 deferred entry; child inherits 0. + expr = [ + (builtins.length (allDeferred.${parentScope} or [ ])) + (builtins.length (allDeferred.${childScope} or [ ])) + ]; + expected = [ + 1 + 0 + ]; } ); - # push-scope: fans out parent deferred to new child scope. + # push-scope: parent deferred entries are NOT fanned out to the new child + # scope (carrier removed). The child scope sees an empty deferred list. test-push-scope-deferred-fanout = denTest ( { den, ... }: let @@ -160,7 +165,7 @@ in { expr = builtins.length ((result.state.scopedDeferredIncludes null).${childScope} or [ ]); - expected = 2; + expected = 0; } ); diff --git a/templates/ci/modules/public-api/user-host-mutual-config.nix b/templates/ci/modules/public-api/user-host-mutual-config.nix index 1a43ca677..e2de124cc 100644 --- a/templates/ci/modules/public-api/user-host-mutual-config.nix +++ b/templates/ci/modules/public-api/user-host-mutual-config.nix @@ -134,11 +134,14 @@ tuxHm.programs.direnv.enable pinguHm.programs.direnv.enable ]; - # { host, user } parametric includes resolve once per user - # when both args are available (fan-out via deferred drain). + # §6 rule-correct: { host, user } at host scope fans out over users, but + # the content is homeManager — a class the HOST scope does not resolve. + # It is class-local to the host (inert), so users' HM never receives it. + # homeManager from a host-scope aspect must reach users via to-users + # policy (test-host-parametric-mutual below), not a bare include. expected = [ - true - true + false + false ]; } ); From 643d805fa1467106eef96c9b1126aecdd3524e8e Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 12 Jun 2026 13:23:02 -0700 Subject: [PATCH 033/101] refactor(context)!: remove deprecated perCtx/perHost/perUser/perHome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The perCtx-based guards predated handler-based entity-arg resolution and relied on a now-removed cross-scope defer carrier (silent no-op when a deeper context key was present). Under the formal rule, an entity-kind arg destructured by a plain function binds once at the emitting scope if in-ctx, fans out class-locally over descendants, and is inert otherwise; bare static attrsets emit unconditionally at their scope. Migrate every reference to plain functions / bare attrsets and recompute test expectations against the rule (see §6 table in the plan doc). --- docs/src/content/docs/guides/debug.md | 9 +- modules/context/perHost-perUser.nix | 54 ------- ...luding-host-owned-and-included-statics.nix | 6 +- .../modules/deprecated/debug-fwd-perhost.nix | 16 +- .../ci/modules/deprecated/homes-perhome.nix | 6 +- .../ci/modules/deprecated/perUser-perHost.nix | 153 ++++++++++++------ .../ci/modules/internal-api/aspect-path.nix | 20 +-- .../modules/aspects/features/backup.nix | 13 +- .../modules/aspects/features/mail.nix | 10 +- .../modules/aspects/users/alice.nix | 9 +- .../example/modules/aspects/defaults.nix | 14 +- 11 files changed, 159 insertions(+), 151 deletions(-) delete mode 100644 modules/context/perHost-perUser.nix diff --git a/docs/src/content/docs/guides/debug.md b/docs/src/content/docs/guides/debug.md index 4a21ca42c..c55e76ae4 100644 --- a/docs/src/content/docs/guides/debug.md +++ b/docs/src/content/docs/guides/debug.md @@ -131,12 +131,13 @@ nix-repl> cfg.networking.hostName from `den.default`, but parametric functions in `den.default.includes` run at every context stage. The pipeline handles dispatch automatically based on function argument shape — write a bare function with the context -args you need. (`den.lib.perHost` is deprecated.) +args you need. The `den.lib.perHost`, `perUser`, and `perHome` shims have +been removed; use plain parametric aspects instead: ```nix -# Deprecated: den.lib.perHost ({ host }: { nixos.x = 1; }) -# Modern — bare function; only runs in host contexts: -({ host }: { nixos.x = 1; }) +# Old shims (removed): den.lib.perHost, den.lib.perUser, den.lib.perHome +# Migration — plain parametric aspect; only runs in host contexts: +({ host, ... }: { nixos.x = 1; }) ``` **Missing attribute**: The context does not have the expected parameter. diff --git a/modules/context/perHost-perUser.nix b/modules/context/perHost-perUser.nix deleted file mode 100644 index 299b6a297..000000000 --- a/modules/context/perHost-perUser.nix +++ /dev/null @@ -1,54 +0,0 @@ -# DEPRECATED: scheduled for removal after first stable release post-fx-pipeline merge. -# Migration: use den.schema.host / den.schema.user directly. -# Deprecated context-level guards. -# Under handler-based resolution, bind.fn resolves each arg independently. -# Optional args with no handler are skipped (nix-effects c7931d7), so -# the function body can detect context level by checking which keys -# were resolved. -{ lib, ... }: -let - # Known context keys. Keys not in the required set are declared as - # optional — if their handlers exist (deeper level), the function - # detects the extras and returns {} (no-op). - allContextKeys = [ - "host" - "user" - "home" - ]; - - perCtx = - requiredKeys: aspect: - let - reqKeysSorted = builtins.sort builtins.lessThan requiredKeys; - extraKeys = builtins.filter (k: !(builtins.elem k reqKeysSorted)) allContextKeys; - # Required keys as required (false), extra keys as optional (true) - funcArgs = lib.genAttrs reqKeysSorted (_: false) // lib.genAttrs extraKeys (_: true); - in - lib.warn - "den.lib.perCtx [${lib.concatStringsSep "," reqKeysSorted}] is deprecated — use a plain function ({ ${lib.concatStringsSep ", " reqKeysSorted}, ... }: ...) instead; handler-based resolution resolves context args automatically" - { - __fn = - resolvedArgs: - let - # If any extra key was resolved (handler exists), we're at a deeper level - hasExtras = builtins.any (k: resolvedArgs ? ${k}) extraKeys; - in - if hasExtras then - { } - else if lib.isFunction aspect && !builtins.isAttrs aspect then - aspect (lib.intersectAttrs (lib.genAttrs reqKeysSorted (_: null)) resolvedArgs) - else - aspect; - __args = funcArgs; - }; - - perHost = perCtx [ "host" ]; - perUser = perCtx [ - "host" - "user" - ]; - perHome = perCtx [ "home" ]; -in -{ - den.lib = { inherit perHome perUser perHost; }; -} diff --git a/templates/ci/modules/deadbugs/issue-297-mutual-not-including-host-owned-and-included-statics.nix b/templates/ci/modules/deadbugs/issue-297-mutual-not-including-host-owned-and-included-statics.nix index 8913ecfee..183f2a48c 100644 --- a/templates/ci/modules/deadbugs/issue-297-mutual-not-including-host-owned-and-included-statics.nix +++ b/templates/ci/modules/deadbugs/issue-297-mutual-not-including-host-owned-and-included-statics.nix @@ -100,11 +100,11 @@ { den.hosts.x86_64-linux.igloo.users.tux.classes = [ "homeManager" ]; - # NOTE: Under policies, use perHost for host-only options + # NOTE: host-owned static; nixos-class content emits at the host scope. den.aspects.igloo.includes = [ - (den.lib.perHost { + { nixos.options.foo = lib.mkOption { default = "foo"; }; - }) + } ]; expr = igloo.foo; diff --git a/templates/ci/modules/deprecated/debug-fwd-perhost.nix b/templates/ci/modules/deprecated/debug-fwd-perhost.nix index 15a48f41a..14203d16c 100644 --- a/templates/ci/modules/deprecated/debug-fwd-perhost.nix +++ b/templates/ci/modules/deprecated/debug-fwd-perhost.nix @@ -1,9 +1,9 @@ -# Forward custom class to leaf option using evalConfig (deprecated parametric/perHost variant). +# Forward custom class to leaf option using evalConfig (parametric children variant). { denTest, ... }: { flake.tests.fwd-leaf-option = { - # perHost parametric children with evalConfig. + # parametric children with evalConfig. test-fwd-perHost-variables = denTest ( { den, @@ -34,8 +34,16 @@ ) ]; } - { den.aspects.foo._.sub1 = den.lib.perHost { variables.TEST = "test-var"; }; } - { den.aspects.foo._.sub2 = den.lib.perHost { variables.OTHER = "other-var"; }; } + { + den.aspects.foo._.sub1 = { + variables.TEST = "test-var"; + }; + } + { + den.aspects.foo._.sub2 = { + variables.OTHER = "other-var"; + }; + } ]; den.aspects.igloo.includes = [ den.aspects.foo ]; diff --git a/templates/ci/modules/deprecated/homes-perhome.nix b/templates/ci/modules/deprecated/homes-perhome.nix index d4d60b3dc..445b1eaef 100644 --- a/templates/ci/modules/deprecated/homes-perhome.nix +++ b/templates/ci/modules/deprecated/homes-perhome.nix @@ -24,12 +24,12 @@ lib.optional (home.hostName == "igloo") (include { homeManager.home.keyboard.layout = "enthium"; includes = [ - (den.lib.perHome ( - { home }: + ( + { home, ... }: { homeManager.home.keyboard.variant = home.name; } - )) + ) ]; }); den.aspects.tux.includes = [ diff --git a/templates/ci/modules/deprecated/perUser-perHost.nix b/templates/ci/modules/deprecated/perUser-perHost.nix index f8aab1930..0e192d035 100644 --- a/templates/ci/modules/deprecated/perUser-perHost.nix +++ b/templates/ci/modules/deprecated/perUser-perHost.nix @@ -26,51 +26,65 @@ # directly resolved." Both see {host, user} without home. # Test updated to reflect post-ctx drain semantics. den.aspects.igloo.includes = [ - (den.lib.perHost { nixos.funny = [ "atHost perHost static" ]; }) - (den.lib.perHost ( - { host }: + { nixos.funny = [ "atHost perHost static" ]; } + ( + { host, ... }: { nixos.funny = [ "atHost perHost ${host.name} fun" ]; } - )) - (den.lib.perUser { nixos.funny = [ "atHost perUser static" ]; }) - (den.lib.perUser ( - { user, host }: + ) + { nixos.funny = [ "atHost perUser static" ]; } + ( + { user, host, ... }: { nixos.funny = [ "atHost perUser ${user.name}@${host.name} fun" ]; } - )) + ) ]; den.aspects.tux.includes = [ - (den.lib.perHost { nixos.funny = [ "atUser perHost static" ]; }) - (den.lib.perHost ( - { host }: + { nixos.funny = [ "atUser perHost static" ]; } + ( + { host, ... }: { nixos.funny = [ "atUser perHost ${host.name} fun" ]; } - )) - (den.lib.perUser { nixos.funny = [ "atUser perUser static" ]; }) - (den.lib.perUser ( - { user, host }: + ) + { nixos.funny = [ "atUser perUser static" ]; } + ( + { user, host, ... }: { nixos.funny = [ "atUser perUser ${user.name}@${host.name} fun" ]; } - )) + ) ]; expr = lib.sort lib.lessThan igloo.funny; - # Fan-out: perUser includes on host aspect drain once per user - # scope. Static variants produce identical output at each scope - # (appearing twice in the merged list). Fun variants produce - # per-user distinct output. + # §6: host-aspect (igloo) includes resolve at host scope (ctx={host}); + # user-aspect (tux) includes resolve at user scope (ctx={host,user}). + # { host } fn → in-ctx at its scope → binds once. { host, user } fn → + # user is a descendant at host scope → fan-out per user; both in-ctx at + # user scope → binds once. Bare static attrset → unconditional, emits + # once at its scope (NO fan-out, no destructure to defer on). + # + # FLIPS vs perCtx shim: + # - "atHost perUser static" x2 → x1: rule-correct. The old shim + # genuinely made the static parametric-on-user (intended ×2 fan-out); + # under the migration the per-user fan-out of identical static content + # RELOCATED to `relationship-fanout.test-identical-static-appears-per-user`. + # The bare static here has no destructure → emits ONCE at host scope. + # - + "atUser perHost igloo fun", + "atUser perHost static": rule-correct. + # { host } / static on a USER aspect now BIND host from ctx at the user + # scope and emit there; the old perHost shim silently no-op'd (saw user + # extra). nixos content folds into the host config → NEW entries. expected = [ "atHost perHost igloo fun" "atHost perHost static" "atHost perUser pingu@igloo fun" "atHost perUser static" - "atHost perUser static" "atHost perUser tux@igloo fun" + "atUser perHost igloo fun" + "atUser perHost static" "atUser perUser static" "atUser perUser tux@igloo fun" ]; @@ -103,48 +117,69 @@ [ (include { includes = [ - (den.lib.perHost { nixos.funny = [ (throw "atHost perHost static") ]; }) - (den.lib.perHost ( - { host }: + { nixos.funny = [ "atHost perHost static" ]; } + ( + { host, ... }: { - nixos.funny = [ (throw "atHost perHost ${host.name} fun") ]; + nixos.funny = [ "atHost perHost ${host.name} fun" ]; } - )) - (den.lib.perUser { nixos.funny = [ "atHost perUser static" ]; }) - (den.lib.perUser ( - { user, host }: + ) + { nixos.funny = [ "atHost perUser static" ]; } + ( + { user, host, ... }: { nixos.funny = [ "atHost perUser ${user.name}@${host.name} fun" ]; } - )) + ) ]; }) ]; den.aspects.igloo.includes = [ den.aspects.igloo.policies.to-users ]; den.aspects.tux.includes = [ - (den.lib.perHost { nixos.funny = [ "atUser ignored perHost static" ]; }) - (den.lib.perHost ( - { host }: + { nixos.funny = [ "atUser perHost static" ]; } + ( + { host, ... }: { - nixos.funny = [ "atUser ignored perHost ${host.name} fun" ]; + nixos.funny = [ "atUser perHost ${host.name} fun" ]; } - )) - (den.lib.perUser { nixos.funny = [ "atUser perUser static" ]; }) - (den.lib.perUser ( - { user, host }: + ) + { nixos.funny = [ "atUser perUser static" ]; } + ( + { user, host, ... }: { nixos.funny = [ "atUser perUser ${user.name}@${host.name} fun" ]; } - )) + ) ]; expr = lib.sort lib.lessThan igloo.funny; + # §6: the to-users policy on host-aspect igloo fans out per user + # (ctx={host,user}); its include block runs once per user (tux, pingu). + # Inside that ctx, { host } fn and bare statics BIND/emit (host in-ctx) + # → x2 each (one per user). { host, user } fn → both in-ctx → per user. + # + # FLIPS vs perCtx shim: + # - + "atHost perHost igloo fun" x2, + "atHost perHost static" x2: + # rule-correct. Inside to-users ctx={host,user}, host is in-ctx, so the + # perHost entries now bind and emit (per-user fan-out) instead of the + # shim's silent skip (it saw the user extra). The originals threw to + # assert non-emission; under the rule they DO emit, so the throws were + # replaced with plain strings. + # - + "atUser perHost igloo fun", + "atUser perHost static": + # rule-correct. On user-aspect tux, host binds from ctx and emits at the + # user scope (formerly "atUser ignored …", skipped by the shim). expected = [ + "atHost perHost igloo fun" + "atHost perHost igloo fun" + "atHost perHost static" + "atHost perHost static" "atHost perUser pingu@igloo fun" "atHost perUser static" "atHost perUser static" "atHost perUser tux@igloo fun" + "atUser perHost igloo fun" + "atUser perHost static" "atUser perUser static" "atUser perUser tux@igloo fun" ]; @@ -168,33 +203,49 @@ }; den.aspects.tux.includes = [ - (den.lib.perHost { homeManager.funny = [ "atHome IGNORED perHost static" ]; }) - (den.lib.perHost ( - { host }: + # Bare static attrsets are unconditional → emit at the home scope. + { homeManager.funny = [ "atHome perHost static" ]; } + # { host } fn: host is neither in-ctx nor a descendant of a standalone + # home → misplaced → inert (IGNORED). + ( + { host, ... }: { homeManager.funny = [ "atHome IGNORED perHost ${host.name} fun" ]; } - )) - (den.lib.perUser { homeManager.funny = [ "atHome IGNORED perUser static" ]; }) - (den.lib.perUser ( - { user, host }: + ) + { homeManager.funny = [ "atHome perUser static" ]; } + # { user, host } fn: neither in-ctx nor descendant → inert (IGNORED). + ( + { user, host, ... }: { homeManager.funny = [ "atHome IGNORED perUser ${user.name}@${host.name} fun" ]; } - )) - (den.lib.perHome { homeManager.funny = [ "atHome perHome static" ]; }) - (den.lib.perHome ( - { home }: + ) + { homeManager.funny = [ "atHome perHome static" ]; } + ( + { home, ... }: { homeManager.funny = [ "atHome perHome ${home.name} fun" ]; } - )) + ) ]; expr = lib.sort lib.lessThan config.flake.homeConfigurations.tux.config.funny; + # §6: standalone home, ctx={home}. { host }/{ user,host } fns are + # misplaced (no host/user in ctx or as descendants) → inert. { home } fn + # binds in-ctx. Bare static attrsets are unconditional → emit. + # + # FLIPS vs perCtx shim: + # - + "atHome perHost static", + "atHome perUser static": rule-correct. + # These are bare static attrsets (no entity destructure) → unconditional + # emission at the home scope. The shim no-op'd them (it saw the home + # extra). Labels lost the "IGNORED" prefix since they now emit; the + # parametric { host }/{ user,host } fns remain genuinely inert. expected = [ "atHome perHome static" "atHome perHome tux fun" + "atHome perHost static" + "atHome perUser static" ]; } ); diff --git a/templates/ci/modules/internal-api/aspect-path.nix b/templates/ci/modules/internal-api/aspect-path.nix index a8addd983..d31944b75 100644 --- a/templates/ci/modules/internal-api/aspect-path.nix +++ b/templates/ci/modules/internal-api/aspect-path.nix @@ -228,23 +228,23 @@ param ]; den.aspects.leaf.nixos = { }; - den.aspects.param = den.lib.perHost ( - { host }: + den.aspects.param = + { host, ... }: { nixos = { }; - } - ); + }; expr = trace "nixos" den.aspects.role; - # §6 rule-correct: the trace pipeline runs at the ROOT scope (ctx = {}), - # which has no entity kind. A perHost ({ host }) aspect there destructures - # an entity kind that is neither in-ctx nor a descendant → misplaced → - # inert (no deferred stub emitted). `param` drops from the trace entirely. - # (Previously it deferred and showed a childless stub via the cross-scope - # carrier, now removed.) + # §6: the trace pipeline runs at the ROOT scope (ctx = {}). A plain + # { host, ... } aspect there destructures an entity kind that is neither + # in-ctx nor a descendant → misplaced → inert (emits no nixos content). + # It still appears in the trace by NAME as a childless node `["param"]`: + # trace visibility tracks the include graph, not emission. (The prior + # cross-scope deferral carrier is gone, but structural visibility remains.) expected.trace = [ "role" [ "leaf" ] + [ "param" ] ]; } ); diff --git a/templates/diagram-demo/modules/aspects/features/backup.nix b/templates/diagram-demo/modules/aspects/features/backup.nix index 11b733d80..877a73df2 100644 --- a/templates/diagram-demo/modules/aspects/features/backup.nix +++ b/templates/diagram-demo/modules/aspects/features/backup.nix @@ -1,12 +1,12 @@ # Parametric aspect: backup configuration adapts to the host it runs on. # -# den.lib.perHost wraps a function that receives { host } from the -# pipeline context. The host entity is provided by the host-to-users -# policy chain — this aspect consumes it to derive per-host paths. +# A plain function receiving { host } from the pipeline context. The host +# entity is bound at the host scope by handler-based resolution — this aspect +# consumes it to derive per-host paths. { den, ... }: { - den.aspects.backup = den.lib.perHost ( - { host }: + den.aspects.backup = + { host, ... }: { nixos.services.restic.backups.system = { repository = "s3:backup.example.com/${host.hostName}"; @@ -17,6 +17,5 @@ "/home" ]; }; - } - ); + }; } diff --git a/templates/diagram-demo/modules/aspects/features/mail.nix b/templates/diagram-demo/modules/aspects/features/mail.nix index d7f0a9b94..9d940b771 100644 --- a/templates/diagram-demo/modules/aspects/features/mail.nix +++ b/templates/diagram-demo/modules/aspects/features/mail.nix @@ -1,14 +1,14 @@ # Parametric aspect: mail relay configured per-host. -# den.lib.perHost provides { host } from the pipeline context. +# A plain function receiving { host } from the pipeline context; bound at the +# host scope by handler-based resolution. { den, ... }: { - den.aspects.mail = den.lib.perHost ( - { host }: + den.aspects.mail = + { host, ... }: { nixos.services.postfix = { enable = true; hostname = host.hostName; }; - } - ); + }; } diff --git a/templates/diagram-demo/modules/aspects/users/alice.nix b/templates/diagram-demo/modules/aspects/users/alice.nix index cdbb151d6..c31d714dc 100644 --- a/templates/diagram-demo/modules/aspects/users/alice.nix +++ b/templates/diagram-demo/modules/aspects/users/alice.nix @@ -74,10 +74,11 @@ den.aspects.demo-shell den.aspects.hyprland den.aspects.dev-tools - # Home-level roles: only materialize in home contexts. - (den.lib.perHome den.aspects.home-dev) - (den.lib.perHome den.aspects.home-productivity) - (den.lib.perHome den.aspects.alice-dotfiles) + # Home-level roles: these aspects carry homeManager-class content; + # inert at host/user scopes, they materialize in the home class. + den.aspects.home-dev + den.aspects.home-productivity + den.aspects.alice-dotfiles ]; nixos = { ... }: diff --git a/templates/example/modules/aspects/defaults.nix b/templates/example/modules/aspects/defaults.nix index 9e32343b2..dcfef2a74 100644 --- a/templates/example/modules/aspects/defaults.nix +++ b/templates/example/modules/aspects/defaults.nix @@ -32,11 +32,13 @@ # # This will append 42 into foo option for the {host} and for EVERY {host,user} # ({ host, ... }: { nixos.foo = [ 42 ]; }) # DO-NOT-DO-THIS. # - # # Instead try to be explicit if a function is intended for ONLY { host } - # den.lib.perHost ({ host }: { nixos.foo = [ 42 ]; }) - # # Or for { host, user } ONLY: - # den.lib.perUser ({ host, user }: { nixos.foo = [ 42 ]; }) - # # Or for standalone homes ({ home }) ONLY: - # den.lib.perHome ({ home }: { homeManager.foo = [ 42 ]; }) + # # A plain function destructuring { host } binds host once at the host + # # scope (nixos-class content emits there): + # ({ host, ... }: { nixos.foo = [ 42 ]; }) + # # Destructuring { host, user } fans out per user, emitting at each + # # user scope: + # ({ host, user, ... }: { nixos.foo = [ 42 ]; }) + # # Destructuring { home } binds home at standalone-home scope: + # ({ home, ... }: { homeManager.foo = [ 42 ]; }) ]; } From fa53f1991d29eb1f7a9727abcb5c31455b58e156 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 12 Jun 2026 13:32:58 -0700 Subject: [PATCH 034/101] refactor(fx): kind-generic spawn materialization (host-aspects decoupled) --- nix/lib/aspects/fx/resolve.nix | 37 ++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/nix/lib/aspects/fx/resolve.nix b/nix/lib/aspects/fx/resolve.nix index fc4bdc974..7d7742224 100644 --- a/nix/lib/aspects/fx/resolve.nix +++ b/nix/lib/aspects/fx/resolve.nix @@ -697,29 +697,40 @@ let ) accImports newEntries ) scopedClassImportsRaw (builtins.attrNames allDeferred); - # Materialize deferred node spawn markers (policy.spawn) over - # the parent scope-tree state. Each marker lives at a user scope; the - # home class is re-walked from that user's host aspect with `user` - # bound, threaded with host + sibling state so fleet-collected pipes - # resolve to data and collectAll sees every peer. The result is folded - # into the user scope's class buckets so BOTH phase1 and the phase4 - # per-host re-walk (over drainedClassImportsRaw) deliver it. + # Materialize deferred node spawn markers (policy.spawn) over the + # parent scope-tree state, kind-generically. Each marker lives at some + # spawned-FOR scope (the OWN entity, of kind `ownKind`); the spawned + # class is re-walked from the projected ASPECT carried on the PARENT + # scope's own entity record, with the own entity bound under its kind. + # The walk is threaded with parent + sibling state so fleet-collected + # pipes resolve to data and collectAll sees every peer. The result is + # folded into the own scope's class buckets so BOTH phase1 and the + # phase4 per-host re-walk (over drainedClassImportsRaw) deliver it. + # + # The aspect is read from the PARENT scope's own ctx (record under + # parentKind) — the same record the old code reached via the child + # 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 lib.foldl' ( acc: scopeId: let sctx = scopeContexts.${scopeId} or { }; - host = sctx.host or null; - user = sctx.user or null; + 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 = user.classes or [ "homeManager" ]; + 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 host == null || from == null then + if parentRecord == null || ownRecord == null then acc else acc @@ -732,9 +743,9 @@ let ++ (spawnNode { inherit from; class = cls; - aspect = host.aspect; + aspect = parentRecord.aspect; bindings = { - inherit user; + ${ownKind} = ownRecord; }; }).imports ); From ae4085642621b595443d363eebfbf47652b02db4 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 12 Jun 2026 13:44:28 -0700 Subject: [PATCH 035/101] refactor(fx): generic root-owner lookup for projected hasAspect --- nix/lib/aspects/fx/policy/schema.nix | 20 +++++- .../internal-api/hasaspect-ancestor-scope.nix | 70 +++++++++++++++++++ 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/nix/lib/aspects/fx/policy/schema.nix b/nix/lib/aspects/fx/policy/schema.nix index 67393c670..34134a0c4 100644 --- a/nix/lib/aspects/fx/policy/schema.nix +++ b/nix/lib/aspects/fx/policy/schema.nix @@ -72,9 +72,23 @@ let overrideKinds = builtins.filter ( k: schemaEntityKindsSet ? ${k} && builtins.isAttrs (rawScopedCtx.${k} or null) ) (builtins.attrNames rawScopedCtx); - # Host run buckets every descendant scope; a host-less entity uses its own. - ownerPathSet = - rawScopedCtx.host.__pathSetByScope or rawScopedCtx.${targetKind}.__pathSetByScope or { }; + # Owner = topmost ancestor along targetKind's parent chain whose ctx binding + # carries a production bucket; falls back to the target itself. (Generic form + # of "the host run buckets every descendant scope".) + ownerChain = + let + walk = + k: acc: + let + p = den.schema.${k}.parent or null; + in + if p == null || builtins.elem p acc then acc else walk p (acc ++ [ p ]); + in + walk targetKind [ targetKind ]; + ownerKind = lib.findFirst ( + k: builtins.isAttrs (rawScopedCtx.${k} or null) && rawScopedCtx.${k} ? __pathSetByScope + ) targetKind (lib.reverseList ownerChain); + ownerPathSet = rawScopedCtx.${ownerKind}.__pathSetByScope or { }; projectedFor = entity: den.lib.aspects.mkProjectedHasAspect { diff --git a/templates/ci/modules/internal-api/hasaspect-ancestor-scope.nix b/templates/ci/modules/internal-api/hasaspect-ancestor-scope.nix index 0c8795f37..8c5be6e72 100644 --- a/templates/ci/modules/internal-api/hasaspect-ancestor-scope.nix +++ b/templates/ci/modules/internal-api/hasaspect-ancestor-scope.nix @@ -71,5 +71,75 @@ expected = "true"; } ); + + # NON-host owner: the bucket-producing entity is a standalone `home` (NOT a + # host — `host` is ABSENT from ctx), and the consumer is a child entity kind + # `crew` (parent = home) the home fans out to. The home's standalone run + # buckets the crew scope, re-keyed by crew's id_hash, with `svc/feature` + # delivered into it. The crew include reads the PROJECTED crew.hasAspect, + # which must resolve against the OWNING home's bucket — reached by walking + # crew's parent chain (crew → home). + # + # The old hardcoded-`host`-literal lookup reads `rawScopedCtx.host` + # (ABSENT) → crew's own binding (no `__pathSetByScope`: crew has no entity + # submodule) → empty bucket → FALSE. The generic chain walk skips the + # bucket-less `host` literal, ascends crew → home, finds the home's bucket + # and the crew-keyed `svc/feature` → TRUE. This case is RED at HEAD before + # the refactor and GREEN after. + test-projected-hasaspect-non-host-owner = denTest ( + { den, config, ... }: + let + inherit (den.lib.policy) resolve; + in + { + den.default.homeManager.home.stateVersion = "25.11"; + den.default.includes = [ den.provides.define-user ]; + den.homes.x86_64-linux.tux = { }; + + # crew nests inside home: home → crew. home is an ANCESTOR of crew. + den.schema.crew.isEntity = true; + den.schema.crew.parent = "home"; + + # settings-bearing, namespaced aspect delivered to the CREW scope, so it + # lands in the home's production bucket keyed by crew's id_hash. + den.aspects.svc.feature = { + settings.opt = lib.mkOption { + type = lib.types.bool; + default = true; + }; + homeManager = { }; + }; + den.schema.crew.includes = [ + den.aspects.svc.feature + ( + { crew, ... }: + { + homeManager.home.sessionVariables.HAS_FEATURE = lib.boolToString ( + crew.hasAspect den.aspects.svc.feature + ); + } + ) + ]; + + # Home fans out to a crew child. crew carries an explicit id_hash so the + # bucket re-key (entities/_types.nix:pathSetByScopeOption) keys the crew + # scope's delivered set by crew identity — what the projected lookup + # reads — instead of a raw scope-string (crew has no instance registry). + den.policies.test-home-to-crew = + { home, ... }: + [ + (resolve.to "crew" { + crew = { + name = home.name or "tux"; + id_hash = "crew-tux"; + }; + }) + ]; + den.schema.home.includes = [ den.policies.test-home-to-crew ]; + + expr = config.flake.homeConfigurations.tux.config.home.sessionVariables.HAS_FEATURE or "MISSING"; + expected = "true"; + } + ); }; } From ee4299fc598a12f97e00b2e8fc2ecffd0ea975e6 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 12 Jun 2026 14:04:34 -0700 Subject: [PATCH 036/101] docs+test(fx): correct fan-out emission-scope doc; isolate shared-include suppression --- docs/src/content/docs/guides/home-manager.mdx | 13 +++++ .../modules/features/relationship-fanout.nix | 56 +++++++++++++++++++ .../example/modules/aspects/defaults.nix | 6 +- 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/docs/src/content/docs/guides/home-manager.mdx b/docs/src/content/docs/guides/home-manager.mdx index 5187aea61..9ca6d0c52 100644 --- a/docs/src/content/docs/guides/home-manager.mdx +++ b/docs/src/content/docs/guides/home-manager.mdx @@ -236,3 +236,16 @@ den.hosts.x86_64-linux.laptop = { Both `homeManager` and `hjem` configurations from `den.aspects.alice` will be forwarded to their respective targets. + +## Host-scope parametric aspects no longer deliver homeManager content to users + +Before the #609 fix, a `{ user, ... }` aspect at host scope leaked its +`homeManager` content to each of the host's users. A host-scope parametric +aspect now fans out over the users but emits class-locally **on the host**, +where `homeManager` is inert -- so that content never reaches the users' +Home Manager evaluation. This matches the resolver's emission rule: the bound +user is the arg source, not the output target. + +If you were relying on that leak to push home content from a host-scope aspect, +route it through an explicit `to-users` policy, `provides`, or the host-aspects +battery instead. diff --git a/templates/ci/modules/features/relationship-fanout.nix b/templates/ci/modules/features/relationship-fanout.nix index fc83bcd53..8dcc52cf0 100644 --- a/templates/ci/modules/features/relationship-fanout.nix +++ b/templates/ci/modules/features/relationship-fanout.nix @@ -279,5 +279,61 @@ } ); + # 8. Shared-include suppression: when the SAME source aspect is registered + # into BOTH den.schema.host.includes AND den.schema.user.includes (the + # den.default pattern), it reaches each user directly via the user scope's + # own resolution. Fanning it out at the host scope too would double-cover. + # `sharedWithDescendant` detects the shared provenance root (matching the + # include node's identity.key against schema.user.includes) and makes the + # aspect INERT at the host scope, so each user's contribution lands EXACTLY + # once. If the suppression were broken, the list would be DOUBLED + # (["shared pingu" "shared pingu" "shared tux" "shared tux"]). + test-shared-include-suppresses-fanout = denTest ( + { + den, + igloo, + lib, + ... + }: + { + den.hosts.x86_64-linux.igloo.users = { + tux = { }; + pingu = { }; + }; + + den.aspects.igloo.nixos.options.funny = lib.mkOption { + default = [ ]; + type = lib.types.listOf lib.types.str; + }; + + # A NAMED aspect carries a stable identity.key from any registration + # site (a bare lambda would anonymize differently per site). Register + # the SAME node into BOTH host and user includes (the den.default + # shape). + den.aspects.shared.includes = [ + ( + { user, ... }: + { + nixos.funny = [ "shared ${user.name}" ]; + } + ) + ]; + + # Same provenance root in both lists. At each user scope `user` is + # in-ctx and it binds once, landing the per-user contribution. At the + # host scope `user` is a descendant → it would fan out, but + # sharedWithDescendant detects the shared user-include root and makes it + # INERT here → no double-cover. Each contribution lands EXACTLY once. + den.schema.host.includes = [ den.aspects.shared ]; + den.schema.user.includes = [ den.aspects.shared ]; + + expr = lib.sort lib.lessThan igloo.funny; + expected = [ + "shared pingu" + "shared tux" + ]; + } + ); + }; } diff --git a/templates/example/modules/aspects/defaults.nix b/templates/example/modules/aspects/defaults.nix index dcfef2a74..2a0b2f004 100644 --- a/templates/example/modules/aspects/defaults.nix +++ b/templates/example/modules/aspects/defaults.nix @@ -35,8 +35,10 @@ # # A plain function destructuring { host } binds host once at the host # # scope (nixos-class content emits there): # ({ host, ... }: { nixos.foo = [ 42 ]; }) - # # Destructuring { host, user } fans out per user, emitting at each - # # user scope: + # # Destructuring { host, user } fans out over the host's users and emits + # # on the host (one nixos contribution per user); the bound user is the + # # arg source, not the output target. At user scope both args are in-ctx + # # and it binds once: # ({ host, user, ... }: { nixos.foo = [ 42 ]; }) # # Destructuring { home } binds home at standalone-home scope: # ({ home, ... }: { homeManager.foo = [ 42 ]; }) From ff49ac3188ee13ca66283364f8cd09653f99b089 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 12 Jun 2026 14:16:40 -0700 Subject: [PATCH 037/101] style: fix indentation from rebase auto-merge in home.nix --- nix/lib/entities/home.nix | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/lib/entities/home.nix b/nix/lib/entities/home.nix index 03b91b587..c9adcbdb0 100644 --- a/nix/lib/entities/home.nix +++ b/nix/lib/entities/home.nix @@ -76,7 +76,7 @@ let hostByName = if hostName != null then den.hosts.${system}.${hostName} or null else null; userByName = if hostByName != null then hostByName.users.${userName} or null else null; - # A home named `user@host` carries a host identity even when that host + # A home named `user@host` carries a host identity even when that host # isn't declared in `den.hosts`. Synthesize a minimal `{ name = ...; }` # so host-keyed provides/policies (which match on `host.name`) resolve # for an otherwise-standalone home — without instantiating a real host @@ -97,7 +97,7 @@ let else null; - homeManagerConfiguration = + homeManagerConfiguration = if nameWithHost && hostByName != null then { pkgs, modules }: inputs.home-manager.lib.homeManagerConfiguration { @@ -116,8 +116,8 @@ let config._module.args.host = hostCtx; config._module.args.user = userByName; options = { - - userName = strOpt "user account name" userName; + + userName = strOpt "user account name" userName; hostName = lib.mkOption { type = lib.types.nullOr lib.types.str; default = hostName; From 821ab6a1c2dc8025a2facf94f1a9663599c51d12 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 12 Jun 2026 15:30:17 -0700 Subject: [PATCH 038/101] feat(schema): resolve gen-schema with CI-lock fallback, project via den.lib MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit den forced a bare `inputs.gen-schema.lib`, so every consumer (and den's own non-CI templates) had to declare the input or hit a hard "attribute missing" eval error. Mirror nix-effects' fx.nix: prefer the consumer-provided input, fall back to the rev pinned in the CI lock. Project it as den.lib.schema and consume it from the entity types, matching the den.lib.fx pattern. options.nix keeps a direct import — it declares den.schema, and den.lib's map includes schema-util (reads den.schema._kindNames), so routing through den.lib at declaration time closes a cycle. --- modules/options.nix | 6 +++++- nix/lib/default.nix | 1 + nix/lib/entities/home.nix | 2 +- nix/lib/entities/host.nix | 2 +- nix/lib/schema.nix | 13 +++++++++++++ 5 files changed, 21 insertions(+), 3 deletions(-) create mode 100644 nix/lib/schema.nix diff --git a/modules/options.nix b/modules/options.nix index 2d1c0627a..822f73113 100644 --- a/modules/options.nix +++ b/modules/options.nix @@ -14,7 +14,11 @@ let config ; }; - schemaLib = inputs.gen-schema.lib; + # Imported directly, not via den.lib.schema: this declares options.den.schema, + # and den.lib's map includes schema-util which reads den.schema._kindNames — + # routing through den.lib here would close that cycle. Entity types consume it + # lazily at eval time, so they safely use den.lib.schema. + schemaLib = import ./../nix/lib/schema.nix { inherit inputs lib; }; classSchemaType = lib.types.submodule ( { ... }: diff --git a/nix/lib/default.nix b/nix/lib/default.nix index a1e28e828..9e88b6fa0 100644 --- a/nix/lib/default.nix +++ b/nix/lib/default.nix @@ -36,6 +36,7 @@ let schemaUtil = ./schema-util.nix; synthesizePolicies = ./synthesize-policies.nix; fx = ./fx.nix; + schema = ./schema.nix; }; in den-lib diff --git a/nix/lib/entities/home.nix b/nix/lib/entities/home.nix index c9adcbdb0..77b115a40 100644 --- a/nix/lib/entities/home.nix +++ b/nix/lib/entities/home.nix @@ -19,7 +19,7 @@ let # Entity instances are gen-schema instances: mkInstanceType injects name, # strict/freeform, _module.args., and schema-owned id_hash (identity). - schemaLib = inputs.gen-schema.lib; + schemaLib = den.lib.schema; # Recursive merge without forcing leaf values. # Unlike lib.types.anything, this does not inspect values deeply (no diff --git a/nix/lib/entities/host.nix b/nix/lib/entities/host.nix index 372ab6f2c..4824c4c93 100644 --- a/nix/lib/entities/host.nix +++ b/nix/lib/entities/host.nix @@ -19,7 +19,7 @@ let # Entity instances are gen-schema instances: mkInstanceType injects name, # strict/freeform, _module.args., and schema-owned id_hash (identity). - schemaLib = inputs.gen-schema.lib; + schemaLib = den.lib.schema; # Recursive merge without forcing leaf values. # Unlike lib.types.anything, this does not inspect values deeply (no diff --git a/nix/lib/schema.nix b/nix/lib/schema.nix new file mode 100644 index 000000000..862ce1edf --- /dev/null +++ b/nix/lib/schema.nix @@ -0,0 +1,13 @@ +# Resolve gen-schema the same way fx.nix resolves nix-effects: prefer the +# consumer-provided flake input, fall back to the rev pinned in the CI lock so +# den evaluates without forcing every consumer to declare the input. +{ inputs, lib, ... }: +let + lock = builtins.fromJSON (builtins.readFile ../../templates/ci/flake.lock); + locked = lock.nodes.gen-schema.locked; + gen-schema = builtins.fetchTarball { + url = "https://github.com/${locked.owner}/${locked.repo}/archive/${locked.rev}.zip"; + sha256 = locked.narHash; + }; +in +inputs.gen-schema.lib or (import gen-schema { inherit lib; }) From ac8a4799f2378ec64fc5776d07a5433fa3d91ec4 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 12 Jun 2026 16:44:21 -0700 Subject: [PATCH 039/101] feat(fx): edge-trace extractor v0 + delivery-edges suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read-only renderer of the pipeline end-state as a normalized, stably-sorted delivery-edge list (S, T, P, M) — the migration oracle for the delivery-edge unification port. v0 captures clean edges (default folds, simple routes, provides, spawns, instantiates) exactly; path-dependent decisions (route suppression, findHostScopeId root selection, complex-forward source choice, @system requalification) are recorded as annotations per spec 3a. - nix/lib/aspects/fx/edge-trace.nix: extractEdgeTrace over the end-state; id_hash-based entity-scope naming, sort key (T, P, S, M) per spec 8. - resolve.nix: surface edgeTrace as a lazy thunk on fxResolveFull's result (fxResolveWithPaths re-exports it). - route/apply.nix + route/default.nix: export dedupRoutes/findChildScopeKeys/ topoSortRoutes (additive) so the extractor reuses the ACTUAL route-suppression logic instead of reimplementing it. - templates/ci/modules/internal-api/edge-trace.nix: the delivery-edges suite — 8 topology fixtures (host+users, fleet+environment, isolated guest, standalone home, home-extraction, multi-system @system, darwin, fleet-pipe), rule- corollary tests (default-fold existence, isolation-as-edge-absence, verbatim mode, suppression annotation), and a cross-run stability test. Zero behavior change. just ci 960/960. --- nix/lib/aspects/fx/default.nix | 1 + nix/lib/aspects/fx/edge-trace.nix | 520 +++++++++++ nix/lib/aspects/fx/resolve.nix | 19 + nix/lib/aspects/fx/route/apply.nix | 11 +- nix/lib/aspects/fx/route/default.nix | 11 +- .../ci/modules/internal-api/edge-trace.nix | 834 ++++++++++++++++++ 6 files changed, 1394 insertions(+), 2 deletions(-) create mode 100644 nix/lib/aspects/fx/edge-trace.nix create mode 100644 templates/ci/modules/internal-api/edge-trace.nix diff --git a/nix/lib/aspects/fx/default.nix b/nix/lib/aspects/fx/default.nix index ab5aa6f7a..285c52cb3 100644 --- a/nix/lib/aspects/fx/default.nix +++ b/nix/lib/aspects/fx/default.nix @@ -15,4 +15,5 @@ wrapClasses = import ./wrap-classes.nix { inherit lib den; }; keyClassification = import ./key-classification.nix { inherit lib den; }; argClass = import ./arg-class.nix { inherit lib den; }; + edgeTrace = import ./edge-trace.nix { inherit lib den; }; } diff --git a/nix/lib/aspects/fx/edge-trace.nix b/nix/lib/aspects/fx/edge-trace.nix new file mode 100644 index 000000000..5f5d70485 --- /dev/null +++ b/nix/lib/aspects/fx/edge-trace.nix @@ -0,0 +1,520 @@ +# edge-trace.nix — read-only renderer of the current pipeline's delivery +# decisions as a normalized, stably-sorted edge list. This is the migration +# oracle for the delivery-edge unification port (spec +# 2026-06-12-delivery-edge-unification-design.md §3a): every Phase-2 mechanism +# port is gated by diffing its constructor's edges against the edges this +# extractor renders from the SAME end-state. +# +# v0 captures the clean edges exactly (default folds, simple routes, provides, +# spawns, instantiates). Path-dependent decisions (route suppression, +# findHostScopeId root selection, complex-forward source choice, @system +# requalification) are recorded as ANNOTATIONS (spec §3a, approximate-then- +# converge) rather than independently re-derived — re-deriving them would mean +# re-implementing the very logic the port deletes. Annotation fidelity converges +# to exact edge fields constructor-by-constructor in Phase 2. +# +# Edge record: { source; target; path; mode; annotations; } +# S (source) — collected(scopeName, class) | rewalk(aspect, bindings, class) +# | synthesize(forwardId, fromClass, intoClass) +# T (target) — { root = scopeName; class; } (instantiation root) +# | { output = attrpath; } (flake-output) +# P (path) — attrpath; [] = merge at root +# M (mode) — "merge" | "nest" | "nest-verbatim" +# +# Trace normalization (spec §8): sort key (T, P, S, M); entity scopes named by +# id_hash (parent-blind identity), non-entity scopes by their mkScopeId string; +# rewalk/synthesize edges record the identity triple, NOT resolved content. +{ lib, den }: +let + # Reuse the ACTUAL route suppression logic (not a reimplementation): the + # point of the extractor is to render the decisions the current code makes. + route = import ./route { inherit lib den; }; + inherit (route) dedupRoutes findChildScopeKeys; + + # --- scope naming ------------------------------------------------------- + + # Entity kind for a scope, if any. scopeEntityKind covers scopes created by + # `resolve.to`, but NOT the pipeline root (it is seeded from ctx, never + # `resolve.to`-created). For the root (and any ctx-seeded entity scope), scan + # the scope's own ctx for a kind-keyed record carrying an id_hash. + entityKindOf = + { scopeEntityKind, scopeContexts }: + sid: + let + viaKind = scopeEntityKind.${sid} or null; + ctx = scopeContexts.${sid} or { }; + # A ctx key whose value is an entity record (has id_hash). Sorted for + # determinism; first wins (a scope carries one own-entity record). + ctxKinds = lib.filter (k: builtins.isAttrs (ctx.${k} or null) && (ctx.${k} ? id_hash)) ( + lib.sort (a: b: a < b) (builtins.attrNames ctx) + ); + in + if viaKind != null then + viaKind + else if ctxKinds != [ ] then + builtins.head ctxKinds + else + null; + + # id_hash of the own-entity record at a scope, if the scope is an entity scope. + idHashOf = + args@{ scopeEntityKind, scopeContexts }: + sid: + let + kind = entityKindOf args sid; + erec = if kind == null then null else (scopeContexts.${sid} or { }).${kind} or null; + in + if erec == null then null else erec.id_hash or null; + + # Stable scope NAME for S/T. Entity scopes → ":" (parent-blind + # identity, stable across re-keying and same-name siblings collapse by design, + # spec §8). Non-entity scopes (system=…, root "") → the mkScopeId string. + scopeName = + args@{ scopeEntityKind, scopeContexts }: + sid: + let + kind = entityKindOf args sid; + idHash = idHashOf args sid; + in + if kind != null && idHash != null then + "${kind}:${idHash}" + else + (if sid == "" then "" else sid); + + # --- subtree walk (isolation-aware, matching extractSubtreeModules) ------ + + # Scope IDs in root's subtree: root always included; isolation gates crossing + # INTO a descendant (resolve.nix:extractSubtreeModules / collectFromSubtree). + subtreeScopesOf = + { + scopeParent, + scopeIsolated, + allScopeIds, + }: + root: + let + isIn = + sid: + sid == root + || ( + !(scopeIsolated.${sid} or false) + && ( + let + parent = scopeParent.${sid} or null; + in + parent != null && parent != sid && isIn parent + ) + ); + in + builtins.filter isIn allScopeIds; + + # --- edge record + sort ------------------------------------------------- + + mkEdge = + { + source, + target, + path ? [ ], + mode, + annotations ? { }, + }: + { + inherit + source + target + path + mode + annotations + ; + }; + + # Canonical string keys for stable sort (spec §8: T, P, S, M). + targetKey = + t: if t ? output then "out:${lib.concatStringsSep "." t.output}" else "root:${t.root}/${t.class}"; + pathKey = p: lib.concatStringsSep "/" p; + sourceKey = + s: + if s ? collected then + "collected:${s.collected.scope}/${s.collected.class}" + else if s ? rewalk then + "rewalk:${s.rewalk.aspect}/${lib.concatStringsSep "+" s.rewalk.bindings}/${s.rewalk.class}" + else if s ? synthesize then + "synthesize:${s.synthesize.forwardId}/${s.synthesize.fromClass}>${s.synthesize.intoClass}" + else + "empty"; + edgeSortKey = + e: + lib.concatStringsSep " | " [ + (targetKey e.target) + (pathKey e.path) + (sourceKey e.source) + e.mode + ]; + + sortEdges = edges: lib.sort (a: b: edgeSortKey a < edgeSortKey b) edges; + + # --- S/T constructors --------------------------------------------------- + + collected = scope: class: { collected = { inherit scope class; }; }; + rewalk = aspect: bindings: class: { rewalk = { inherit aspect bindings class; }; }; + synthesize = forwardId: fromClass: intoClass: { + synthesize = { inherit forwardId fromClass intoClass; }; + }; + rootTarget = root: class: { inherit root class; }; + outputTarget = output: { inherit output; }; +in +{ + # extractEdgeTrace: pipeline end-state → stably-sorted normalized edge list. + extractEdgeTrace = + { + scopeContexts, + scopeParent, + scopeIsolated, + scopeEntityKind, + scopedClassImports, + scopedRoutes, + scopedProvides, + scopedSpawns, + scopedInstantiates, + rootScopeId, + }: + let + nameArgs = { inherit scopeEntityKind scopeContexts; }; + name = scopeName nameArgs; + kindOf = entityKindOf nameArgs; + + allScopeIds = builtins.attrNames scopeContexts; + + # Entity-root scopes: the pipeline root + every isolated scope (an isolated + # entity is its OWN collection root — isolation = edge-absence into its + # parent, spec §2). Each is a default-fold T. + entityRootScopes = lib.unique ( + [ rootScopeId ] ++ builtins.filter (sid: scopeIsolated.${sid} or false) allScopeIds + ); + + # ===== default fold edges ========================================== + # One per entity-root scope per class with content: + # collected(subtree minus isolated, class) → (root, class), P=[], M=merge. + # Source content is collected from the isolation-aware subtree; the edge + # records the source as the subtree's ROOT scope name + class (the + # collection is keyed by root, not enumerated per-scope — spec §8 records + # the collected(scope,class) identity, not content). + defaultFoldEdges = builtins.concatLists ( + map ( + rootSid: + let + subtree = subtreeScopesOf { + inherit scopeParent scopeIsolated allScopeIds; + } rootSid; + # Classes with any content anywhere in the subtree. + classesWithContent = lib.unique ( + builtins.concatLists (map (sid: builtins.attrNames (scopedClassImports.${sid} or { })) subtree) + ); + hasContent = cls: builtins.any (sid: (scopedClassImports.${sid} or { }) ? ${cls}) subtree; + in + map ( + cls: + mkEdge { + source = collected (name rootSid) cls; + target = rootTarget (name rootSid) cls; + path = [ ]; + mode = "merge"; + annotations = { }; + } + ) (builtins.filter hasContent classesWithContent) + ) entityRootScopes + ); + + # ===== provides edges (two-edge decomposition, §B Decision 1) ====== + # A provides spec → a nest edge into the SOURCE scope's bucket + # (setAttrByPath path module). The merge half is the default fold edge + # already emitted above (the perScope append is subtree-collectible) — we + # render only the nest edge, annotated providesPolicyName, with a note that + # the merge half is the default fold. Dedup key = (policyName, class, path) + # — the SAME composite key applyProvides/dedupProvides uses (NOT scope- + # keyed): two provides from one policy into one class+path collapse to one + # edge regardless of registering scope. + allProvides = builtins.concatLists (lib.attrValues scopedProvides); + dedupedProvides = + let + go = + seen: specs: + if specs == [ ] then + [ ] + else + let + s = builtins.head specs; + rest = builtins.tail specs; + pn = s.__providePolicyName or null; + key = if pn != null then "${pn}/${s.class}/${lib.concatStringsSep "/" (s.path or [ ])}" else null; + in + if key != null && seen ? ${key} then + go seen rest + else + [ s ] ++ go (if key != null then seen // { ${key} = true; } else seen) rest; + in + go { } allProvides; + providesEdges = map ( + spec: + let + path = spec.path or [ ]; + sid = spec.sourceScopeId; + in + mkEdge { + # Source is the provided module placed at P (nest construction); + # rendered as a collected source into the source scope's class bucket. + source = collected (name sid) spec.class; + target = rootTarget (name sid) spec.class; + inherit path; + # P=[] degenerates to a plain merge contribution (no nesting); P!=[] + # is the setAttrByPath nest construction. + mode = if path == [ ] then "merge" else "nest"; + annotations = { + providesPolicyName = spec.__providePolicyName or null; + # The merge half (delivery to the entity root) is the default fold + # edge above; this nest edge only constructs the placed module. + mergeHalf = "default-fold"; + }; + } + ) dedupedProvides; + + # ===== route edges ================================================= + # From scopedRoutes specs (post the ACTUAL dedupRoutes — reused, not + # reimplemented). Simple routes and complex (synthesize) forwards. + rawRoutes = builtins.concatLists (lib.attrValues scopedRoutes); + # The suppression decisions: which adapterKey@scope routes dedupRoutes + # keeps, and which child keys shadow root-scope adapter routes. Reuse the + # real functions over the SAME rootScopeId the pipeline used. + keptRoutes = dedupRoutes rootScopeId rawRoutes; + childKeys = findChildScopeKeys rootScopeId rawRoutes; + # Per-position suppression verdicts. A rawRoute is suppressed iff + # dedupRoutes (the ACTUAL logic, reused — not reimplemented) dropped it, + # i.e. it is not the kept instance of its identity. keptRoutes preserves + # original order and keeps the FIRST instance per identity, so a verdict is + # derived by consuming keptRoutes positionally as rawRoutes are walked: the + # head of keptRoutes is the kept route until matched, then advances. + # `byChild` (redundant-root shadow, §B rule 2) is recomputed from the same + # findChildScopeKeys output dedupRoutes consumes. + suppressVerdicts = + let + go = + kept: routes: + if routes == [ ] then + [ ] + else + let + r = builtins.head routes; + rest = builtins.tail routes; + isKept = kept != [ ] && builtins.head kept == r; + ak = r.adapterKey or null; + # Redundant-root shadow: an adapter route AT the root scope whose + # adapterKey also exists at a child scope (findChildScopeKeys). + byChild = ak != null && rootScopeId != null && r.sourceScopeId == rootScopeId && childKeys ? ${ak}; + verdict = { + suppressed = !isKept; + inherit byChild; + }; + in + [ verdict ] ++ go (if isKept then builtins.tail kept else kept) rest; + in + go keptRoutes rawRoutes; + + # A forward identity triple component (§B Decision 2): adapterKey if + # present (the dynamic-P adapter arm, cell 6), else a structural composite. + forwardId = + spec: + spec.adapterKey or "${spec.fromClass}>${spec.intoClass}@${spec.sourceScopeId}/${ + lib.concatStringsSep "/" (spec.staticIntoPath or spec.path or [ ]) + }"; + + routeEdge = + verdict: spec: + let + sid = spec.sourceScopeId; + isComplex = spec.__complexForward or false; + path = spec.path or spec.staticIntoPath or [ ]; + appendToParent = spec.appendToParent or false; + appendSid = if appendToParent then scopeParent.${sid} or sid else sid; + adapterKey = spec.adapterKey or null; + reinstantiate = spec.reinstantiate or false; + # Suppression verdict for this position (path-dependent — depends on + # the SET of routes present, §B path-dependent suppression rule). + # Recorded as an annotation until the route port (spec §3a). + isSuppressed = verdict.suppressed; + suppressedByChild = verdict.byChild; + + baseAnnotations = + lib.optionalAttrs (spec.adaptArgs or null != null) { adaptArgs = true; } + // lib.optionalAttrs (spec.guard or null != null) { guard = true; } + // lib.optionalAttrs (spec.collectSubtree or false) { collectSubtree = true; } + // lib.optionalAttrs ((spec.intoClass or null) == "flake") { isFlakeRoute = true; } + // lib.optionalAttrs ((spec.instantiate or null) != null) { instantiate = true; } + // lib.optionalAttrs appendToParent { appendToParent = true; } + // lib.optionalAttrs ( + # §B cell 5: ensureEntry placeholder (empty target path materialized). + !isComplex && (spec.intoClass or null) != "flake" && (spec.adaptArgs or null) != null && path != [ ] + ) { ensureTargetPath = true; } + // lib.optionalAttrs isSuppressed { suppressed = true; } + // lib.optionalAttrs suppressedByChild { suppressedByChildKey = adapterKey; }; + in + if isComplex then + # Complex forward → synthesize edge. Identity triple only (no content, + # spec §8). sourceVia is path-dependent (getCollectedSource's collected- + # else-rewalk branch depends on the assembled perScope, which v0 does + # not reconstruct) — recorded as the approximate annotation + # "unresolved" per the Task-3 brief. + mkEdge { + source = synthesize (forwardId spec) spec.fromClass spec.intoClass; + target = rootTarget (name appendSid) spec.intoClass; + inherit path; + mode = "nest"; + annotations = baseAnnotations // { + complexForward = true; + sourceVia = "unresolved"; + }; + } + else + mkEdge { + source = collected (name sid) spec.fromClass; + target = rootTarget (name appendSid) spec.intoClass; + inherit path; + # §B Decision 4: reinstantiate ⇒ nest-verbatim; P=[] ⇒ merge; + # else nest. Adapter routes (cell 6) carry dynamic P — annotated. + mode = + if reinstantiate then + "nest-verbatim" + else if path == [ ] then + "merge" + else + "nest"; + annotations = + baseAnnotations + // lib.optionalAttrs (adapterKey != null) { + adapterKey = adapterKey; + # §B cell 6: adapter arm resolves P dynamically at evalModules + # time via intoPathFn — P is not a static edge field. + dynamicPath = true; + }; + }; + routeEdges = lib.imap0 (i: spec: routeEdge (builtins.elemAt suppressVerdicts i) spec) rawRoutes; + + # ===== spawn (rewalk) edges ======================================== + # scopedSpawns: each marker lives at an OWN entity scope (ownKind); the + # spawned class is re-walked from the PARENT scope's own entity aspect, + # with the own entity bound under its kind → delivered to the own scope + # root. Identity triple: (parent aspect identity, bound kinds, class). + # Content is NOT recorded (spec §8 rewalk determinism). + spawnEdges = builtins.concatLists ( + lib.mapAttrsToList ( + ownSid: specs: + let + ownKind = kindOf ownSid; + from = scopeParent.${ownSid} or null; + parentKind = if from == null then null else kindOf from; + parentRec = + if parentKind == null then null else (scopeContexts.${from} or { }).${parentKind} or null; + ownRec = if ownKind == null then null else (scopeContexts.${ownSid} or { }).${ownKind} or null; + # Parent aspect identity: prefer the record's id_hash, else its name. + aspectId = + if parentRec == null then "" else parentRec.id_hash or parentRec.name or ""; + defaultClasses = if ownRec == null then [ ] else ownRec.classes or [ ]; + classes = lib.unique ( + builtins.concatLists (map (s: if s.classes != null then s.classes else defaultClasses) specs) + ); + in + map ( + cls: + mkEdge { + source = rewalk aspectId [ ownKind ] cls; + target = rootTarget (name ownSid) cls; + path = [ ]; + mode = "merge"; + annotations = { + spawnFrom = name from; + }; + } + ) classes + ) scopedSpawns + ); + + # ===== instantiate edges (flake-output T-arm) ====================== + # scopedInstantiates → flake-output edges. T = [ "flake" ] ++ intoAttr. + # @system disambiguation: when the SAME output path is targeted by specs + # on DIFFERENT systems, each is qualified @. We render the + # disambiguation by reusing the grouping INPUTS (path + system metadata + # only — never spec.instantiate, matching disambiguated's contract) and + # annotate collisions (disambiguatedTo). resolvedRootVia annotation = + # "name-infix" with the hostScopeId findHostScopeId currently returns + # (the heuristic to dissolve in Task 11). + allInstantiates = builtins.concatLists (lib.attrValues scopedInstantiates); + # Spec descriptors with output, mirroring applyInstantiates:specDescriptors. + instDescriptors = builtins.concatLists ( + map ( + spec: + let + hasOutput = (spec.intoAttr or [ ]) != [ ]; + in + if !hasOutput then + [ ] + else + [ + { + path = [ "flake" ] ++ spec.intoAttr; + system = spec.system or null; + inherit spec; + } + ] + ) allInstantiates + ); + # Group by output path (the disambiguated grouping inputs). + instGrouped = builtins.foldl' ( + acc: entry: + let + k = lib.concatStringsSep "." entry.path; + in + acc // { ${k} = (acc.${k} or [ ]) ++ [ entry ]; } + ) { } instDescriptors; + instantiateEdges = builtins.concatLists ( + lib.mapAttrsToList ( + _: entries: + let + systems = lib.unique (map (e: e.system or null) entries); + isMultiSystem = builtins.length entries > 1 && builtins.length systems > 1; + in + map ( + entry: + let + spec = entry.spec; + # findHostScopeId is a let-binding inside resolve.nix (not + # exported); we record the resolution VIA, not the heuristic. The + # spec carries sourceScopeId; the host scope it resolves to is a + # child of that by name-infix. We annotate resolvedRootVia only. + outPath = + if isMultiSystem then + lib.init entry.path ++ [ "${lib.last entry.path}@${entry.system}" ] + else + entry.path; + in + mkEdge { + # Source content comes from the host subtree (collected); we record + # the source as the spec's source scope + class. + source = collected (name (spec.sourceScopeId or rootScopeId)) (spec.class or "nixos"); + target = outputTarget outPath; + path = [ ]; + mode = "merge"; + annotations = { + resolvedRootVia = "name-infix"; + inherit (entry) system; + } + // lib.optionalAttrs isMultiSystem { + disambiguatedTo = lib.concatStringsSep "." outPath; + }; + } + ) entries + ) instGrouped + ); + + allEdges = defaultFoldEdges ++ providesEdges ++ routeEdges ++ spawnEdges ++ instantiateEdges; + in + sortEdges allEdges; +} diff --git a/nix/lib/aspects/fx/resolve.nix b/nix/lib/aspects/fx/resolve.nix index 7d7742224..998b0b868 100644 --- a/nix/lib/aspects/fx/resolve.nix +++ b/nix/lib/aspects/fx/resolve.nix @@ -10,6 +10,7 @@ let inherit (import ./assemble-pipes.nix { inherit lib den; }) assemblePipes; inherit (import ./spawn-node.nix { inherit lib den; }) mkSpawnNode; route = import ./route { inherit lib den; }; + inherit (import ./edge-trace.nix { inherit lib den; }) extractEdgeTrace; handlers = den.lib.aspects.fx.handlers; # Check if `ancestor` is an ancestor of `descendant` in the scopeParent tree. @@ -783,6 +784,24 @@ let # set from scope-string to entity identity (id_hash) for projected # hasAspect (see entities/_types.nix:pathSetByScopeOption). inherit scopeContexts scopeEntityKind; + # Read-only delivery-edge trace over the pipeline end-state (the migration + # oracle for the delivery-edge unification port, edge-trace.nix). Nix + # attrs are lazy, so this is a thunk — never forced by normal resolve + # consumers, only by the delivery-edges suite / debug inspection. + edgeTrace = extractEdgeTrace { + inherit + scopeContexts + scopeParent + scopeIsolated + scopeEntityKind + scopedProvides + scopedRoutes + ; + scopedClassImports = scopedClassImportsRaw; + scopedSpawns = (result.state.scopedSpawns or (_: { })) null; + scopedInstantiates = (result.state.scopedInstantiates or (_: { })) null; + rootScopeId = result.state.rootScopeId; + }; }; # Back-compatible projection: imports only. Protects deferredModule consumers diff --git a/nix/lib/aspects/fx/route/apply.nix b/nix/lib/aspects/fx/route/apply.nix index 178b5e1ae..dba78add4 100644 --- a/nix/lib/aspects/fx/route/apply.nix +++ b/nix/lib/aspects/fx/route/apply.nix @@ -379,5 +379,14 @@ let allRoutes; in { - inherit applyRoutes; + inherit + applyRoutes + # Exported for the read-only edge-trace extractor (edge-trace.nix), which + # renders the CURRENT route suppression decisions as `suppressedBy` + # annotations by reusing this exact logic rather than reimplementing it. + # Additive only — no behavior change. + dedupRoutes + findChildScopeKeys + topoSortRoutes + ; } diff --git a/nix/lib/aspects/fx/route/default.nix b/nix/lib/aspects/fx/route/default.nix index ca6229f61..32b30e389 100644 --- a/nix/lib/aspects/fx/route/default.nix +++ b/nix/lib/aspects/fx/route/default.nix @@ -16,8 +16,17 @@ let ; }) applyRoutes + dedupRoutes + findChildScopeKeys + topoSortRoutes ; in { - inherit wrapRouteModules applyRoutes; + inherit + wrapRouteModules + applyRoutes + dedupRoutes + findChildScopeKeys + topoSortRoutes + ; } diff --git a/templates/ci/modules/internal-api/edge-trace.nix b/templates/ci/modules/internal-api/edge-trace.nix new file mode 100644 index 000000000..dbe452e63 --- /dev/null +++ b/templates/ci/modules/internal-api/edge-trace.nix @@ -0,0 +1,834 @@ +# delivery-edges suite — snapshot fixtures + rule-corollary tests for the +# read-only edge-trace extractor (nix/lib/aspects/fx/edge-trace.nix), the +# migration oracle for the delivery-edge unification port (spec +# 2026-06-12-delivery-edge-unification-design.md §5.3). +# +# Each fixture resolves a minimal topology and asserts its normalized delivery +# edge list. Scope id_hashes are normalized to ":" so the +# expected lists stay readable and stable (the raw trace uses parent-blind +# id_hash identity, spec §8). Where a topology's full list is large but the +# mechanism under test is a small subset, the test asserts the exact subset for +# that mechanism plus the total edge count (noted inline). +# +# `just ci delivery-edges` runs this suite; `just ci delivery-edges.` one +# test with traces. +{ denTest, lib, ... }: +let + # Replace ":" scope strings in an edge trace with + # ":". id_hash → name is recovered from the resolve result's + # scopeContexts (each entity scope's own record carries name + id_hash). + normalizeTrace = + r: + let + sc = r.scopeContexts or { }; + hashToName = lib.foldl' ( + acc: sid: + let + ctx = sc.${sid} or { }; + # Map EVERY entity record present in this scope's ctx (a child scope + # carries its own + ancestor records, e.g. a user scope has both `user` + # and `host`), so id_hash → name is built for all kinds, not just the + # scope's own. + entityKeys = lib.filter (k: builtins.isAttrs (ctx.${k} or null) && (ctx.${k} ? id_hash)) ( + builtins.attrNames ctx + ); + in + acc + // lib.listToAttrs ( + map (k: lib.nameValuePair "${k}:${ctx.${k}.id_hash}" "${k}:${ctx.${k}.name or "?"}") entityKeys + ) + ) { } (builtins.attrNames sc); + ren = s: hashToName.${s} or s; + renSource = + src: + if src ? collected then + { + collected = src.collected // { + scope = ren src.collected.scope; + }; + } + else + src; + renTarget = t: if t ? root then t // { root = ren t.root; } else t; + in + map ( + e: + e + // { + source = renSource e.source; + target = renTarget e.target; + } + ) r.edgeTrace; + + # Resolve a host entity to a normalized edge trace. + hostTrace = + den: cls: host: + normalizeTrace ( + den.lib.aspects.resolveWithPaths cls (den.lib.resolveEntity "host" { inherit host; }) + ); + + # Resolve the flake root to a normalized edge trace. + flakeTrace = + den: cls: normalizeTrace (den.lib.aspects.resolveWithPaths cls (den.lib.resolveEntity "flake" { })); + + # Edge constructors mirroring edge-trace.nix's record shape (for readable + # expected lists). + collected = scope: class: { collected = { inherit scope class; }; }; + synthesize = forwardId: fromClass: intoClass: { + synthesize = { inherit forwardId fromClass intoClass; }; + }; + rootT = root: class: { inherit root class; }; + outT = output: { inherit output; }; + edge = + { + source, + target, + path ? [ ], + mode, + annotations ? { }, + }: + { + inherit + source + target + path + mode + annotations + ; + }; + + # The host-default-user homeManager→nixos forward (every host with a user gets + # one) plus its dedup-suppressed twin and the user-class ensureEntry route — + # shared tail of the host+user fixtures. Parameterized by the host/user names + # and the os class (nixos | darwin). + userForwardTail = + { + user, + os, + }: + [ + (edge { + source = synthesize "homeManager/${os}/home-manager/users/${user}" "homeManager" os; + target = rootT "user:${user}" os; + path = [ + "home-manager" + "users" + user + ]; + mode = "nest"; + annotations = { + complexForward = true; + sourceVia = "unresolved"; + }; + }) + (edge { + source = synthesize "homeManager/${os}/home-manager/users/${user}" "homeManager" os; + target = rootT "user:${user}" os; + path = [ + "home-manager" + "users" + user + ]; + mode = "nest"; + annotations = { + complexForward = true; + sourceVia = "unresolved"; + suppressed = true; + }; + }) + (edge { + source = collected "user:${user}" "user"; + target = rootT "user:${user}" os; + path = [ + "users" + "users" + user + ]; + mode = "nest"; + annotations = { + adaptArgs = true; + ensureTargetPath = true; + }; + }) + ]; +in +{ + flake.tests.delivery-edges = { + + # ===== (1) host + single user ===================================== + # Full edge list. Default folds (host homeManager/nixos, host+user os→nixos), + # the user-forward tail (complex forward + dedup-suppressed twin + user + # ensureEntry route). os→nixos is the os-class delivery route. + test-topology-host-users = denTest ( + { den, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.aspects.igloo.nixos.networking.hostName = "igloo"; + + expr = hostTrace den "nixos" den.hosts.x86_64-linux.igloo; + expected = [ + (edge { + source = collected "host:igloo" "homeManager"; + target = rootT "host:igloo" "homeManager"; + mode = "merge"; + }) + (edge { + source = collected "host:igloo" "nixos"; + target = rootT "host:igloo" "nixos"; + mode = "merge"; + }) + (edge { + source = collected "host:igloo" "os"; + target = rootT "host:igloo" "nixos"; + mode = "merge"; + }) + (edge { + source = collected "user:tux" "os"; + target = rootT "user:tux" "nixos"; + mode = "merge"; + }) + ] + ++ userForwardTail { + user = "tux"; + os = "nixos"; + }; + } + ); + + # ===== (2) fleet with environment ancestor ======================== + # Flake-level resolve through a fleet ancestor. The instantiate edge is + # SOURCED from the fleet ancestor scope (resolve.to "host" registered the + # instantiate at the fleet scope). Full list. + test-topology-fleet-environment = denTest ( + { den, lib, ... }: + { + den.quirks.host-addrs.description = "addrs"; + den.policies.to-fleet = _: [ + (den.lib.policy.resolve.to "fleet" { + fleet = { + name = "fleet"; + }; + }) + ]; + den.policies.fleet-to-hosts = + { fleet, ... }: + lib.concatMap ( + system: + lib.concatMap ( + hostName: + let + host = den.hosts.${system}.${hostName}; + in + [ + (den.lib.policy.resolve.to "host" { inherit host; }) + (den.lib.policy.instantiate host) + ] + ) (builtins.attrNames (den.hosts.${system} or { })) + ) (builtins.attrNames (den.hosts or { })); + den.schema.flake.includes = [ den.policies.to-fleet ]; + den.schema.fleet.includes = [ den.policies.fleet-to-hosts ]; + den.schema.flake-system.excludes = [ + den.policies.system-to-os-outputs + den.policies.system-to-hm-outputs + ]; + den.hosts.x86_64-linux.igloo.users = { }; + den.aspects.igloo.nixos.networking.hostName = "igloo"; + + expr = flakeTrace den "flake"; + expected = [ + (edge { + source = collected "fleet=fleet" "nixos"; + target = outT [ + "flake" + "nixosConfigurations" + "igloo" + ]; + mode = "merge"; + annotations = { + resolvedRootVia = "name-infix"; + system = "x86_64-linux"; + }; + }) + (edge { + source = collected "" "homeManager"; + target = rootT "" "homeManager"; + mode = "merge"; + }) + (edge { + source = collected "" "nixos"; + target = rootT "" "nixos"; + mode = "merge"; + }) + (edge { + source = collected "host:igloo" "os"; + target = rootT "host:igloo" "nixos"; + mode = "merge"; + }) + ]; + } + ); + + # ===== (3) microvm-guest-style isolated kind + verbatim route ===== + # Full list. The isolated guest gets its OWN default-fold edge (it is an + # entity-root because isolated) — isolation = edge-absence: the guest's nixos + # does NOT fold into the host root. The delivery route (reinstantiate=true, + # appendToParent, collectSubtree) is nest-verbatim into the host root. + test-topology-isolated-guest = denTest ( + { den, lib, ... }: + let + guestEntity = { + name = "guest"; + system = "x86_64-linux"; + class = "nixos"; + intoAttr = [ ]; + users = { }; + aspect = den.aspects.guest-aspect; + }; + deliverPolicy = den.lib.policy.mkPolicy "deliver-iso" ( + { ... }@args: + lib.optionals (!(args ? user) && !(args ? home)) [ + (den.lib.policy.route { + fromClass = "nixos"; + intoClass = "nixos"; + collectSubtree = true; + appendToParent = true; + reinstantiate = true; + path = [ + "microvm" + "vms" + "guest" + ]; + }) + ] + ); + in + { + den.hosts.x86_64-linux.igloo.users = { }; + den.schema.iso-kind = { + isEntity = true; + parent = "host"; + isolated = true; + }; + den.policies.resolve-iso-child = + { host, ... }: + lib.optionals (host.name == "igloo") [ + (den.lib.policy.resolve.to.withIncludes "iso-kind" [ deliverPolicy ] { iso-kind = guestEntity; }) + ]; + den.schema.host.includes = [ den.policies.resolve-iso-child ]; + den.aspects.guest-aspect.nixos.boot.kernelModules = [ "g" ]; + den.aspects.igloo.nixos.networking.hostName = "igloo"; + + expr = hostTrace den "nixos" den.hosts.x86_64-linux.igloo; + expected = [ + (edge { + source = collected "host:igloo" "homeManager"; + target = rootT "host:igloo" "homeManager"; + mode = "merge"; + }) + (edge { + source = collected "host:igloo" "nixos"; + target = rootT "host:igloo" "nixos"; + mode = "merge"; + }) + (edge { + source = collected "host:igloo" "os"; + target = rootT "host:igloo" "nixos"; + mode = "merge"; + }) + # Verbatim delivery route from the isolated guest subtree into the host. + (edge { + source = collected "host=igloo,iso-kind=guest" "nixos"; + target = rootT "host:igloo" "nixos"; + path = [ + "microvm" + "vms" + "guest" + ]; + mode = "nest-verbatim"; + annotations = { + appendToParent = true; + collectSubtree = true; + }; + }) + # The isolated guest's OWN default fold (it is its own entity-root). + (edge { + source = collected "host=igloo,iso-kind=guest" "nixos"; + target = rootT "host=igloo,iso-kind=guest" "nixos"; + mode = "merge"; + }) + ]; + } + ); + + # ===== (4) standalone home (#605 synthetic host) ================== + # Flake-level resolve of a standalone home → a homeConfigurations output + # edge sourced from the system scope, plus the empty flake-root default + # folds. Full list. + test-topology-standalone-home = denTest ( + { den, ... }: + { + den.homes.x86_64-linux.solo.homeManager.home.username = "solo"; + + expr = flakeTrace den "flake"; + expected = [ + (edge { + source = collected "system=x86_64-linux" "homeManager"; + target = outT [ + "flake" + "homeConfigurations" + "solo" + ]; + mode = "merge"; + annotations = { + resolvedRootVia = "name-infix"; + system = "x86_64-linux"; + }; + }) + (edge { + source = collected "" "homeManager"; + target = rootT "" "homeManager"; + mode = "merge"; + }) + (edge { + source = collected "" "nixos"; + target = rootT "" "nixos"; + mode = "merge"; + }) + ]; + } + ); + + # ===== (5) home-extraction (host-projected HM onto a user) ======== + # A host aspect projects homeManager content onto the user; the host-aspects + # battery spawns the user home node. Subset+count assertion: this topology's + # full list includes the user-forward tail and several default folds; the + # mechanism under test is the host's homeManager default fold reaching the + # user via the forward. We assert the user-forward synthesize edges are + # present (the extraction edge) plus the total count. + test-topology-home-extraction = denTest ( + { den, lib, ... }: + let + trace = hostTrace den "nixos" den.hosts.x86_64-linux.igloo; + synthEdges = lib.filter (e: e.source ? synthesize) trace; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.aspects.igloo.homeManager.home.sessionVariables.X = "y"; + den.aspects.tux.includes = [ den.batteries.host-aspects ]; + + expr = { + # The HM-into-user forward (the extraction edge) is present, twice + # (raw + dedup-suppressed twin), targeting the user root. + synth = synthEdges; + # Stable total edge count for this topology. + count = builtins.length trace; + }; + expected = { + synth = [ + (edge { + source = synthesize "homeManager/nixos/home-manager/users/tux" "homeManager" "nixos"; + target = rootT "user:tux" "nixos"; + path = [ + "home-manager" + "users" + "tux" + ]; + mode = "nest"; + annotations = { + complexForward = true; + sourceVia = "unresolved"; + }; + }) + (edge { + source = synthesize "homeManager/nixos/home-manager/users/tux" "homeManager" "nixos"; + target = rootT "user:tux" "nixos"; + path = [ + "home-manager" + "users" + "tux" + ]; + mode = "nest"; + annotations = { + complexForward = true; + sourceVia = "unresolved"; + suppressed = true; + }; + }) + ]; + count = 8; + }; + } + ); + + # ===== (6) multi-system same-name (the @system arm) =============== + # Two homes named `ben` on different systems → two homeConfigurations output + # edges, each disambiguated to `ben@`. Full list. + test-topology-multi-system = denTest ( + { den, ... }: + { + den.homes.x86_64-linux.ben.homeManager.home.username = "ben"; + den.homes.aarch64-linux.ben.homeManager.home.username = "ben"; + + expr = flakeTrace den "flake"; + expected = [ + (edge { + source = collected "system=aarch64-linux" "homeManager"; + target = outT [ + "flake" + "homeConfigurations" + "ben@aarch64-linux" + ]; + mode = "merge"; + annotations = { + disambiguatedTo = "flake.homeConfigurations.ben@aarch64-linux"; + resolvedRootVia = "name-infix"; + system = "aarch64-linux"; + }; + }) + (edge { + source = collected "system=x86_64-linux" "homeManager"; + target = outT [ + "flake" + "homeConfigurations" + "ben@x86_64-linux" + ]; + mode = "merge"; + annotations = { + disambiguatedTo = "flake.homeConfigurations.ben@x86_64-linux"; + resolvedRootVia = "name-infix"; + system = "x86_64-linux"; + }; + }) + (edge { + source = collected "" "homeManager"; + target = rootT "" "homeManager"; + mode = "merge"; + }) + (edge { + source = collected "" "nixos"; + target = rootT "" "nixos"; + mode = "merge"; + }) + ]; + } + ); + + # ===== (7) darwin host (different class set) ====================== + # The default-fold class set is darwin (not nixos); the os→darwin route and + # the homeManager→darwin user forward carry the darwin class. Full list. + test-topology-darwin = denTest ( + { den, ... }: + { + den.hosts.aarch64-darwin.apple = { + users.tux = { }; + }; + den.aspects.apple.nixos.networking.hostName = "apple"; + + expr = hostTrace den "nixos" den.hosts.aarch64-darwin.apple; + expected = [ + (edge { + source = collected "host:apple" "darwin"; + target = rootT "host:apple" "darwin"; + mode = "merge"; + }) + (edge { + source = collected "host:apple" "os"; + target = rootT "host:apple" "darwin"; + mode = "merge"; + }) + (edge { + source = collected "host:apple" "homeManager"; + target = rootT "host:apple" "homeManager"; + mode = "merge"; + }) + (edge { + source = collected "host:apple" "nixos"; + target = rootT "host:apple" "nixos"; + mode = "merge"; + }) + (edge { + source = collected "user:tux" "os"; + target = rootT "user:tux" "darwin"; + mode = "merge"; + }) + ] + ++ userForwardTail { + user = "tux"; + os = "darwin"; + }; + } + ); + + # ===== (8) fleet pipe value flowing through a delivery edge ======= + # A fleet-collected pipe (host-addrs) feeds a host nixos consumer, and the + # host instantiate edge carries that into the flake output. Subset+count: + # we assert the instantiate output edge (the delivery edge the pipe value + # flows through) is present + the total count. The pipe VALUE itself is + # config-level (not an edge property); this fixture pins that the delivery + # topology is unchanged by pipe flow. + test-topology-fleet-pipe = denTest ( + { den, lib, ... }: + let + trace = flakeTrace den "flake"; + outEdges = lib.filter (e: e.target ? output) trace; + in + { + den.quirks.host-addrs.description = "addrs"; + den.policies.to-fleet = _: [ + (den.lib.policy.resolve.to "fleet" { + fleet = { + name = "fleet"; + }; + }) + ]; + den.policies.fleet-to-hosts = + { fleet, ... }: + lib.concatMap ( + system: + lib.concatMap ( + hostName: + let + host = den.hosts.${system}.${hostName}; + in + [ + (den.lib.policy.resolve.to "host" { inherit host; }) + (den.lib.policy.instantiate host) + ] + ) (builtins.attrNames (den.hosts.${system} or { })) + ) (builtins.attrNames (den.hosts or { })); + den.policies.collect-addrs = _: [ + (den.lib.policy.pipe.from "host-addrs" [ (den.lib.policy.pipe.collectAll ({ host, ... }: true)) ]) + ]; + den.schema.flake.includes = [ den.policies.to-fleet ]; + den.schema.fleet.includes = [ den.policies.fleet-to-hosts ]; + den.schema.host.includes = [ den.policies.collect-addrs ]; + den.schema.flake-system.excludes = [ + den.policies.system-to-os-outputs + den.policies.system-to-hm-outputs + ]; + den.hosts.x86_64-linux.igloo.users = { }; + den.aspects.igloo.host-addrs = + { host, ... }: + { + hostname = host.name; + }; + den.aspects.igloo.nixos = + { host-addrs, lib, ... }: + { + networking.extraHosts = lib.concatStringsSep "," (map (e: e.hostname) host-addrs); + }; + + expr = { + outputs = outEdges; + count = builtins.length trace; + }; + expected = { + # The host's flake-output delivery edge (pipe value flows through it). + outputs = [ + (edge { + source = collected "fleet=fleet" "nixos"; + target = outT [ + "flake" + "nixosConfigurations" + "igloo" + ]; + mode = "merge"; + annotations = { + resolvedRootVia = "name-infix"; + system = "x86_64-linux"; + }; + }) + ]; + count = 5; + }; + } + ); + + # ===== rule-corollary tests (spec §5.3) =========================== + + # Default-fold-edge existence: every entity-root scope with class content + # has a merge edge collected(root, class) → (root, class), P=[]. + test-corollary-default-fold-exists = denTest ( + { den, lib, ... }: + let + trace = hostTrace den "nixos" den.hosts.x86_64-linux.igloo; + hostFold = lib.filter ( + e: + e.mode == "merge" + && e.path == [ ] + && e.source ? collected + && e.source.collected.scope == "host:igloo" + && e.source.collected.class == "nixos" + && e.target ? root + && e.target.root == "host:igloo" + && e.target.class == "nixos" + ) trace; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.aspects.igloo.nixos.networking.hostName = "igloo"; + expr = builtins.length hostFold; + expected = 1; + } + ); + + # Isolation-as-edge-absence: an isolated child contributes NO default fold + # edge INTO the parent root — the parent's nixos default fold's source is the + # parent scope, and the isolated child has its OWN separate fold target. + test-corollary-isolation-edge-absence = denTest ( + { den, lib, ... }: + let + guestEntity = { + name = "guest"; + system = "x86_64-linux"; + class = "nixos"; + intoAttr = [ ]; + users = { }; + aspect = den.aspects.guest-aspect; + }; + trace = hostTrace den "nixos" den.hosts.x86_64-linux.igloo; + # A default fold (merge, P=[]) TARGETING the host root whose SOURCE scope + # is the isolated guest would be an isolation leak — there must be none. + leakFolds = lib.filter ( + e: + e.mode == "merge" + && e.path == [ ] + && e.target ? root + && e.target.root == "host:igloo" + && e.source ? collected + && e.source.collected.scope == "host=igloo,iso-kind=guest" + ) trace; + # The isolated guest DOES have its own fold target (own entity-root). + guestFolds = lib.filter ( + e: + e.mode == "merge" + && e.path == [ ] + && e.target ? root + && e.target.root == "host=igloo,iso-kind=guest" + ) trace; + in + { + den.hosts.x86_64-linux.igloo.users = { }; + den.schema.iso-kind = { + isEntity = true; + parent = "host"; + isolated = true; + }; + den.policies.resolve-iso-child = + { host, ... }: + lib.optionals (host.name == "igloo") [ + (den.lib.policy.resolve.to "iso-kind" { iso-kind = guestEntity; }) + ]; + den.schema.host.includes = [ den.policies.resolve-iso-child ]; + den.aspects.guest-aspect.nixos.boot.kernelModules = [ "g" ]; + den.aspects.igloo.nixos.networking.hostName = "igloo"; + + expr = { + leakIntoParent = builtins.length leakFolds; + guestOwnFold = builtins.length guestFolds >= 1; + }; + expected = { + leakIntoParent = 0; + guestOwnFold = true; + }; + } + ); + + # Verbatim mode on a reinstantiate route. + test-corollary-verbatim-mode = denTest ( + { den, lib, ... }: + let + guestEntity = { + name = "guest"; + system = "x86_64-linux"; + class = "nixos"; + intoAttr = [ ]; + users = { }; + aspect = den.aspects.guest-aspect; + }; + deliverPolicy = den.lib.policy.mkPolicy "deliver-iso" ( + { ... }@args: + lib.optionals (!(args ? user) && !(args ? home)) [ + (den.lib.policy.route { + fromClass = "nixos"; + intoClass = "nixos"; + collectSubtree = true; + appendToParent = true; + reinstantiate = true; + path = [ + "microvm" + "vms" + "guest" + ]; + }) + ] + ); + trace = hostTrace den "nixos" den.hosts.x86_64-linux.igloo; + verbatim = lib.filter (e: e.mode == "nest-verbatim") trace; + in + { + den.hosts.x86_64-linux.igloo.users = { }; + den.schema.iso-kind = { + isEntity = true; + parent = "host"; + isolated = true; + }; + den.policies.resolve-iso-child = + { host, ... }: + lib.optionals (host.name == "igloo") [ + (den.lib.policy.resolve.to.withIncludes "iso-kind" [ deliverPolicy ] { iso-kind = guestEntity; }) + ]; + den.schema.host.includes = [ den.policies.resolve-iso-child ]; + den.aspects.guest-aspect.nixos.boot.kernelModules = [ "g" ]; + den.aspects.igloo.nixos.networking.hostName = "igloo"; + + expr = { + count = builtins.length verbatim; + path = (builtins.head verbatim).path; + }; + expected = { + count = 1; + path = [ + "microvm" + "vms" + "guest" + ]; + }; + } + ); + + # Route-ordering/suppression annotation present: the dedup-suppressed twin of + # the user-forward carries `suppressed = true` (route/apply.nix dedupRoutes + # decision, reused by the extractor). + test-corollary-suppression-annotation = denTest ( + { den, lib, ... }: + let + trace = hostTrace den "nixos" den.hosts.x86_64-linux.igloo; + suppressed = lib.filter (e: e.annotations.suppressed or false) trace; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.aspects.igloo.nixos.networking.hostName = "igloo"; + expr = builtins.length suppressed; + expected = 1; + } + ); + + # ===== stability: identical trace across two resolve calls ========= + test-stability-identical-across-runs = denTest ( + { den, ... }: + let + a = hostTrace den "nixos" den.hosts.x86_64-linux.igloo; + b = hostTrace den "nixos" den.hosts.x86_64-linux.igloo; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.aspects.igloo.nixos.networking.hostName = "igloo"; + # Two independent resolves of the same config produce byte-identical + # traces (sort applied; extraction pure). + expr = a == b; + expected = true; + } + ); + }; +} From ccdd8eb3395045053df9b65b9e70e138a3657a87 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 12 Jun 2026 16:50:32 -0700 Subject: [PATCH 040/101] refactor(fx): edge-trace reuses exported dedupProvides --- nix/lib/aspects/fx/edge-trace.nix | 21 ++------------------- nix/lib/aspects/fx/resolve.nix | 2 ++ 2 files changed, 4 insertions(+), 19 deletions(-) diff --git a/nix/lib/aspects/fx/edge-trace.nix b/nix/lib/aspects/fx/edge-trace.nix index 5f5d70485..b4591ef12 100644 --- a/nix/lib/aspects/fx/edge-trace.nix +++ b/nix/lib/aspects/fx/edge-trace.nix @@ -177,6 +177,7 @@ in scopedSpawns, scopedInstantiates, rootScopeId, + dedupProvides, }: let nameArgs = { inherit scopeEntityKind scopeContexts; }; @@ -235,25 +236,7 @@ in # keyed): two provides from one policy into one class+path collapse to one # edge regardless of registering scope. allProvides = builtins.concatLists (lib.attrValues scopedProvides); - dedupedProvides = - let - go = - seen: specs: - if specs == [ ] then - [ ] - else - let - s = builtins.head specs; - rest = builtins.tail specs; - pn = s.__providePolicyName or null; - key = if pn != null then "${pn}/${s.class}/${lib.concatStringsSep "/" (s.path or [ ])}" else null; - in - if key != null && seen ? ${key} then - go seen rest - else - [ s ] ++ go (if key != null then seen // { ${key} = true; } else seen) rest; - in - go { } allProvides; + dedupedProvides = dedupProvides allProvides; providesEdges = map ( spec: let diff --git a/nix/lib/aspects/fx/resolve.nix b/nix/lib/aspects/fx/resolve.nix index 998b0b868..692d84f80 100644 --- a/nix/lib/aspects/fx/resolve.nix +++ b/nix/lib/aspects/fx/resolve.nix @@ -796,6 +796,7 @@ let scopeEntityKind scopedProvides scopedRoutes + dedupProvides ; scopedClassImports = scopedClassImportsRaw; scopedSpawns = (result.state.scopedSpawns or (_: { })) null; @@ -879,5 +880,6 @@ in fxResolveWithPaths fxResolveImports wrapCollectedClasses + dedupProvides ; } From 339bce2952caa2df4c0ca70ca8460a1293bc002a Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 12 Jun 2026 17:05:06 -0700 Subject: [PATCH 041/101] test(fx): rewalk edge fixture; harden isolation corollary; edge-trace cleanups --- nix/lib/aspects/fx/edge-trace.nix | 22 +- nix/lib/aspects/fx/route/apply.nix | 9 +- nix/lib/aspects/fx/route/default.nix | 2 - .../ci/modules/internal-api/edge-trace.nix | 202 ++++++++++++++++-- 4 files changed, 213 insertions(+), 22 deletions(-) diff --git a/nix/lib/aspects/fx/edge-trace.nix b/nix/lib/aspects/fx/edge-trace.nix index b4591ef12..539ada2aa 100644 --- a/nix/lib/aspects/fx/edge-trace.nix +++ b/nix/lib/aspects/fx/edge-trace.nix @@ -212,6 +212,12 @@ in builtins.concatLists (map (sid: builtins.attrNames (scopedClassImports.${sid} or { })) subtree) ); hasContent = cls: builtins.any (sid: (scopedClassImports.${sid} or { }) ? ${cls}) subtree; + # The normalized names of every scope this fold collects from — the + # isolation-aware subtree (an isolated descendant is its OWN root, so + # it is ABSENT here). Surfaced as an annotation so the isolation-as- + # edge-absence corollary can assert with teeth: an isolated child's + # scope name must NOT appear in its parent fold's collectedScopes. + collectedScopes = lib.sort (a: b: a < b) (lib.unique (map name subtree)); in map ( cls: @@ -220,7 +226,9 @@ in target = rootTarget (name rootSid) cls; path = [ ]; mode = "merge"; - annotations = { }; + annotations = { + inherit collectedScopes; + }; } ) (builtins.filter hasContent classesWithContent) ) entityRootScopes @@ -288,6 +296,12 @@ in let r = builtins.head routes; rest = builtins.tail routes; + # Identity assumption: dedupRoutes preserves original order and + # returns the SAME route records (by reference) it kept, so the + # head-of-kept structural `==` here is really reference identity — + # two distinct rawRoutes are never structurally equal in practice + # (each carries a distinct sourceScopeId/path). If dedupRoutes ever + # rebuilds records, switch this to a stable adapterKey@scope match. isKept = kept != [ ] && builtins.head kept == r; ak = r.adapterKey or null; # Redundant-root shadow: an adapter route AT the root scope whose @@ -335,6 +349,8 @@ in // lib.optionalAttrs appendToParent { appendToParent = true; } // lib.optionalAttrs ( # §B cell 5: ensureEntry placeholder (empty target path materialized). + # (content-blind approx; real ensureEntry also requires empty module + # set — converges Phase 2) !isComplex && (spec.intoClass or null) != "flake" && (spec.adaptArgs or null) != null && path != [ ] ) { ensureTargetPath = true; } // lib.optionalAttrs isSuppressed { suppressed = true; } @@ -373,7 +389,7 @@ in annotations = baseAnnotations // lib.optionalAttrs (adapterKey != null) { - adapterKey = adapterKey; + inherit adapterKey; # §B cell 6: adapter arm resolves P dynamically at evalModules # time via intoPathFn — P is not a static edge field. dynamicPath = true; @@ -413,7 +429,7 @@ in path = [ ]; mode = "merge"; annotations = { - spawnFrom = name from; + spawnFrom = if from == null then null else name from; }; } ) classes diff --git a/nix/lib/aspects/fx/route/apply.nix b/nix/lib/aspects/fx/route/apply.nix index dba78add4..4cac49236 100644 --- a/nix/lib/aspects/fx/route/apply.nix +++ b/nix/lib/aspects/fx/route/apply.nix @@ -382,11 +382,12 @@ in inherit applyRoutes # Exported for the read-only edge-trace extractor (edge-trace.nix), which - # renders the CURRENT route suppression decisions as `suppressedBy` - # annotations by reusing this exact logic rather than reimplementing it. - # Additive only — no behavior change. + # renders the CURRENT route suppression decisions as `suppressed` + + # `suppressedByChildKey` annotations by reusing this exact logic rather than + # reimplementing it. Additive only — no behavior change. (topoSortRoutes is + # NOT exported: the extractor renders unordered edges and has no consumer for + # the toposort; it stays internal to applyRoutes.) dedupRoutes findChildScopeKeys - topoSortRoutes ; } diff --git a/nix/lib/aspects/fx/route/default.nix b/nix/lib/aspects/fx/route/default.nix index 32b30e389..5f8af1de6 100644 --- a/nix/lib/aspects/fx/route/default.nix +++ b/nix/lib/aspects/fx/route/default.nix @@ -18,7 +18,6 @@ let applyRoutes dedupRoutes findChildScopeKeys - topoSortRoutes ; in { @@ -27,6 +26,5 @@ in applyRoutes dedupRoutes findChildScopeKeys - topoSortRoutes ; } diff --git a/templates/ci/modules/internal-api/edge-trace.nix b/templates/ci/modules/internal-api/edge-trace.nix index dbe452e63..fcc6d9585 100644 --- a/templates/ci/modules/internal-api/edge-trace.nix +++ b/templates/ci/modules/internal-api/edge-trace.nix @@ -39,6 +39,12 @@ let ) ) { } (builtins.attrNames sc); ren = s: hashToName.${s} or s; + # rewalk aspect ids are BARE id_hashes (no ":" prefix), so build a + # bare-hash → ":" map alongside the prefixed one above. + bareHashToName = lib.mapAttrs' ( + k: v: lib.nameValuePair (lib.last (lib.splitString ":" k)) v + ) hashToName; + renAspect = s: bareHashToName.${s} or (hashToName.${s} or s); renSource = src: if src ? collected then @@ -47,9 +53,26 @@ let scope = ren src.collected.scope; }; } + else if src ? rewalk then + { + rewalk = src.rewalk // { + aspect = renAspect src.rewalk.aspect; + }; + } else src; renTarget = t: if t ? root then t // { root = ren t.root; } else t; + # Rename scope names inside the collectedScopes / spawnFrom annotations so + # default-fold and spawn edges stay readable in expected lists. + renAnnotations = + a: + a + // lib.optionalAttrs (a ? collectedScopes) { + collectedScopes = map ren a.collectedScopes; + } + // lib.optionalAttrs (a ? spawnFrom && a.spawnFrom != null) { + spawnFrom = ren a.spawnFrom; + }; in map ( e: @@ -57,6 +80,7 @@ let // { source = renSource e.source; target = renTarget e.target; + annotations = renAnnotations e.annotations; } ) r.edgeTrace; @@ -74,6 +98,7 @@ let # Edge constructors mirroring edge-trace.nix's record shape (for readable # expected lists). collected = scope: class: { collected = { inherit scope class; }; }; + rewalk = aspect: bindings: class: { rewalk = { inherit aspect bindings class; }; }; synthesize = forwardId: fromClass: intoClass: { synthesize = { inherit forwardId fromClass intoClass; }; }; @@ -171,11 +196,23 @@ in source = collected "host:igloo" "homeManager"; target = rootT "host:igloo" "homeManager"; mode = "merge"; + annotations = { + collectedScopes = [ + "host:igloo" + "user:tux" + ]; + }; }) (edge { source = collected "host:igloo" "nixos"; target = rootT "host:igloo" "nixos"; mode = "merge"; + annotations = { + collectedScopes = [ + "host:igloo" + "user:tux" + ]; + }; }) (edge { source = collected "host:igloo" "os"; @@ -253,11 +290,27 @@ in source = collected "" "homeManager"; target = rootT "" "homeManager"; mode = "merge"; + annotations = { + collectedScopes = [ + "" + "fleet=fleet" + "host:igloo" + "system=x86_64-linux" + ]; + }; }) (edge { source = collected "" "nixos"; target = rootT "" "nixos"; mode = "merge"; + annotations = { + collectedScopes = [ + "" + "fleet=fleet" + "host:igloo" + "system=x86_64-linux" + ]; + }; }) (edge { source = collected "host:igloo" "os"; @@ -324,11 +377,19 @@ in source = collected "host:igloo" "homeManager"; target = rootT "host:igloo" "homeManager"; mode = "merge"; + # The isolated guest scope is ABSENT from the host fold's collected + # subtree — isolation as edge-absence (spec §2). + annotations = { + collectedScopes = [ "host:igloo" ]; + }; }) (edge { source = collected "host:igloo" "nixos"; target = rootT "host:igloo" "nixos"; mode = "merge"; + annotations = { + collectedScopes = [ "host:igloo" ]; + }; }) (edge { source = collected "host:igloo" "os"; @@ -355,6 +416,9 @@ in source = collected "host=igloo,iso-kind=guest" "nixos"; target = rootT "host=igloo,iso-kind=guest" "nixos"; mode = "merge"; + annotations = { + collectedScopes = [ "host=igloo,iso-kind=guest" ]; + }; }) ]; } @@ -388,11 +452,25 @@ in source = collected "" "homeManager"; target = rootT "" "homeManager"; mode = "merge"; + annotations = { + collectedScopes = [ + "" + "home:solo" + "system=x86_64-linux" + ]; + }; }) (edge { source = collected "" "nixos"; target = rootT "" "nixos"; mode = "merge"; + annotations = { + collectedScopes = [ + "" + "home:solo" + "system=x86_64-linux" + ]; + }; }) ]; } @@ -503,11 +581,31 @@ in source = collected "" "homeManager"; target = rootT "" "homeManager"; mode = "merge"; + annotations = { + # Two distinct `ben` entities (different systems, distinct id_hashes + # collapsing to the same readable name) both fold into the flake root. + collectedScopes = [ + "" + "home:ben" + "home:ben" + "system=aarch64-linux" + "system=x86_64-linux" + ]; + }; }) (edge { source = collected "" "nixos"; target = rootT "" "nixos"; mode = "merge"; + annotations = { + collectedScopes = [ + "" + "home:ben" + "home:ben" + "system=aarch64-linux" + "system=x86_64-linux" + ]; + }; }) ]; } @@ -530,6 +628,12 @@ in source = collected "host:apple" "darwin"; target = rootT "host:apple" "darwin"; mode = "merge"; + annotations = { + collectedScopes = [ + "host:apple" + "user:tux" + ]; + }; }) (edge { source = collected "host:apple" "os"; @@ -540,11 +644,23 @@ in source = collected "host:apple" "homeManager"; target = rootT "host:apple" "homeManager"; mode = "merge"; + annotations = { + collectedScopes = [ + "host:apple" + "user:tux" + ]; + }; }) (edge { source = collected "host:apple" "nixos"; target = rootT "host:apple" "nixos"; mode = "merge"; + annotations = { + collectedScopes = [ + "host:apple" + "user:tux" + ]; + }; }) (edge { source = collected "user:tux" "os"; @@ -559,6 +675,46 @@ in } ); + # ===== (7b) spawn (rewalk) edge — host-aspects projection ========== + # The host-aspects battery on a user emits a deferred policy.spawn marker at + # the user's OWN entity scope; the extractor renders it as a REWALK edge: the + # spawned class is re-walked from the PARENT (host) aspect identity with the + # own entity (user) bound under its kind, delivered to the user root. Identity + # triple only — content is NOT recorded (spec §8 rewalk determinism). We + # assert the full rewalk edge by value (subset + count: this topology's full + # list also carries the host folds + user-forward tail, large and asserted + # elsewhere; the mechanism under test is the single rewalk edge). + test-topology-host-aspects-spawn = denTest ( + { den, lib, ... }: + let + trace = hostTrace den "nixos" den.hosts.x86_64-linux.igloo; + spawnEdges = lib.filter (e: e.source ? rewalk) trace; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + # A host aspect projecting homeManager content (gives the spawned class + # content; the spawn marker fires regardless of content). + den.aspects.igloo.homeManager.home.sessionVariables.X = "y"; + den.aspects.tux.includes = [ den.batteries.host-aspects ]; + den.aspects.igloo.nixos.networking.hostName = "igloo"; + + # The rewalk spawn edge, by value: source carries the PARENT (host) + # aspect identity, the bound kind (user), and the spawned class; target is + # the user root; spawnFrom names the host scope. + expr = spawnEdges; + expected = [ + (edge { + source = rewalk "host:igloo" [ "user" ] "homeManager"; + target = rootT "user:tux" "homeManager"; + mode = "merge"; + annotations = { + spawnFrom = "host:igloo"; + }; + }) + ]; + } + ); + # ===== (8) fleet pipe value flowing through a delivery edge ======= # A fleet-collected pipe (host-addrs) feeds a host nixos consumer, and the # host instantiate edge carries that into the flake output. Subset+count: @@ -672,9 +828,15 @@ in } ); - # Isolation-as-edge-absence: an isolated child contributes NO default fold - # edge INTO the parent root — the parent's nixos default fold's source is the - # parent scope, and the isolated child has its OWN separate fold target. + # Isolation-as-edge-absence: an isolated child contributes NO content to the + # parent root's default fold. The host root fold COLLECTS from a subtree + # (annotations.collectedScopes); isolation means the guest scope is ABSENT + # from that set — the strongest available assertion (a default fold always + # SOURCES from the root scope name, so a source-scope filter alone is + # structurally vacuous; the collectedScopes membership has real teeth: if + # subtreeScopesOf stopped honoring isolation, the guest scope would appear + # here and the test goes red — verified by scratch-flipping the isolation + # gate in subtreeScopesOf). test-corollary-isolation-edge-absence = denTest ( { den, lib, ... }: let @@ -686,25 +848,31 @@ in users = { }; aspect = den.aspects.guest-aspect; }; + guestScope = "host=igloo,iso-kind=guest"; trace = hostTrace den "nixos" den.hosts.x86_64-linux.igloo; - # A default fold (merge, P=[]) TARGETING the host root whose SOURCE scope - # is the isolated guest would be an isolation leak — there must be none. - leakFolds = lib.filter ( + # The host root's nixos default fold (merge, P=[], sources+targets the + # host root). Its collectedScopes is the isolation-aware subtree. + hostNixosFold = lib.filter ( e: e.mode == "merge" && e.path == [ ] && e.target ? root && e.target.root == "host:igloo" + && e.target.class == "nixos" && e.source ? collected - && e.source.collected.scope == "host=igloo,iso-kind=guest" + && e.source.collected.scope == "host:igloo" + && e.source.collected.class == "nixos" ) trace; - # The isolated guest DOES have its own fold target (own entity-root). - guestFolds = lib.filter ( + hostFoldScopes = (builtins.head hostNixosFold).annotations.collectedScopes; + # The isolated guest's OWN fold (own entity-root), and its collected set. + guestFold = lib.filter ( e: e.mode == "merge" && e.path == [ ] && e.target ? root - && e.target.root == "host=igloo,iso-kind=guest" + && e.target.root == guestScope + && e.source ? collected + && e.source.collected.scope == guestScope ) trace; in { @@ -724,12 +892,20 @@ in den.aspects.igloo.nixos.networking.hostName = "igloo"; expr = { - leakIntoParent = builtins.length leakFolds; - guestOwnFold = builtins.length guestFolds >= 1; + # Exactly one host nixos fold, and the guest scope is NOT in its + # collected subtree (the isolation edge-absence). + hostFoldExists = builtins.length hostNixosFold == 1; + guestInHostFold = builtins.elem guestScope hostFoldScopes; + # The guest's OWN fold exists and collects from ITSELF (it IS an + # entity-root, so its subtree contains its own scope). + guestOwnFold = builtins.length guestFold == 1; + guestInOwnFold = builtins.elem guestScope (builtins.head guestFold).annotations.collectedScopes; }; expected = { - leakIntoParent = 0; + hostFoldExists = true; + guestInHostFold = false; guestOwnFold = true; + guestInOwnFold = true; }; } ); From 1988b55976e86fee6996436073317c731343e668 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 12 Jun 2026 17:10:49 -0700 Subject: [PATCH 042/101] refactor(fx): delete write-only delivery state --- nix/lib/aspects/fx/handlers/constraint.nix | 3 +-- nix/lib/aspects/fx/handlers/policy.nix | 12 +++--------- nix/lib/aspects/fx/pipeline.nix | 3 --- 3 files changed, 4 insertions(+), 14 deletions(-) diff --git a/nix/lib/aspects/fx/handlers/constraint.nix b/nix/lib/aspects/fx/handlers/constraint.nix index e5f70dd4f..52e8f4673 100644 --- a/nix/lib/aspects/fx/handlers/constraint.nix +++ b/nix/lib/aspects/fx/handlers/constraint.nix @@ -7,7 +7,6 @@ ... }: let - inherit (import ./state-util.nix) scopedAppend; lookupEntries = registry: nodeIdentity: @@ -67,7 +66,7 @@ let in { resume = null; - state = (scopedAppend state "scopedConstraintFilters" currentScope filterEntry) // { + state = state // { flatConstraintFilters = (state.flatConstraintFilters or [ ]) ++ [ filterEntry ]; }; } diff --git a/nix/lib/aspects/fx/handlers/policy.nix b/nix/lib/aspects/fx/handlers/policy.nix index 3d65dddbe..26b007c81 100644 --- a/nix/lib/aspects/fx/handlers/policy.nix +++ b/nix/lib/aspects/fx/handlers/policy.nix @@ -14,15 +14,9 @@ let in { resume = null; - state = - (scopedMerge state "scopedAspectPolicies" state.currentScope { - ${param.name} = entry; - }) - // { - flatAspectPolicies = (state.flatAspectPolicies or { }) // { - ${param.name} = entry; - }; - }; + state = scopedMerge state "scopedAspectPolicies" state.currentScope { + ${param.name} = entry; + }; }; }; in diff --git a/nix/lib/aspects/fx/pipeline.nix b/nix/lib/aspects/fx/pipeline.nix index abd35a724..ded49faee 100644 --- a/nix/lib/aspects/fx/pipeline.nix +++ b/nix/lib/aspects/fx/pipeline.nix @@ -145,13 +145,10 @@ let # --- Scope-partitioned output state (handlers write here) --- scopedClassImports = _: { }; scopedAspectPolicies = _: { }; - # Pre-merged flat view (avoid O(S) rebuild per installPolicies call). - flatAspectPolicies = { }; scopedDeferredIncludes = _: { }; scopedDeferredConditionals = _: { }; scopedIncludesChain = _: { }; scopedConstraintRegistry = _: { }; - scopedConstraintFilters = _: { }; # Pre-merged flat views (avoid O(S) rebuild per check-constraint call). flatConstraintRegistry = { }; flatConstraintFilters = [ ]; From 955f3505ece8ff386c0aff14ec00bcb4cef1e7a8 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 12 Jun 2026 17:14:38 -0700 Subject: [PATCH 043/101] test(fx): drop dead scopedConstraintFilters seeds from fixtures --- templates/ci/modules/internal-api/fx-aspect.nix | 1 - templates/ci/modules/internal-api/fx-constraints.nix | 8 -------- .../ci/modules/internal-api/fx-effectful-resolve.nix | 1 - templates/ci/modules/internal-api/narrow-effects.nix | 1 - 4 files changed, 11 deletions(-) diff --git a/templates/ci/modules/internal-api/fx-aspect.nix b/templates/ci/modules/internal-api/fx-aspect.nix index 3f7041e56..99f32900d 100644 --- a/templates/ci/modules/internal-api/fx-aspect.nix +++ b/templates/ci/modules/internal-api/fx-aspect.nix @@ -65,7 +65,6 @@ let currentScope = "__test"; scopedIncludesChain = _: { }; scopedConstraintRegistry = _: { }; - scopedConstraintFilters = _: { }; paths = [ ]; }; in diff --git a/templates/ci/modules/internal-api/fx-constraints.nix b/templates/ci/modules/internal-api/fx-constraints.nix index 81006529f..89851c923 100644 --- a/templates/ci/modules/internal-api/fx-constraints.nix +++ b/templates/ci/modules/internal-api/fx-constraints.nix @@ -83,7 +83,6 @@ state = { currentScope = "__test"; scopedConstraintRegistry = _: { }; - scopedConstraintFilters = _: { }; scopedIncludesChain = _: { }; }; } comp; @@ -104,7 +103,6 @@ state = { currentScope = "__test"; scopedConstraintRegistry = _: { }; - scopedConstraintFilters = _: { }; scopedIncludesChain = _: { }; }; } comp; @@ -141,7 +139,6 @@ state = { currentScope = "__test"; scopedConstraintRegistry = _: { }; - scopedConstraintFilters = _: { }; scopedIncludesChain = _: { }; }; } comp; @@ -390,7 +387,6 @@ currentScope = "__test"; scopedIncludesChain = _: { }; scopedConstraintRegistry = _: { }; - scopedConstraintFilters = _: { }; }; } comp; in @@ -432,7 +428,6 @@ currentScope = "__test"; scopedIncludesChain = _: { }; scopedConstraintRegistry = _: { }; - scopedConstraintFilters = _: { }; }; } comp; in @@ -474,7 +469,6 @@ currentScope = "__test"; scopedIncludesChain = _: { }; scopedConstraintRegistry = _: { }; - scopedConstraintFilters = _: { }; }; } comp; in @@ -510,7 +504,6 @@ currentScope = "__test"; scopedIncludesChain = _: { }; scopedConstraintRegistry = _: { }; - scopedConstraintFilters = _: { }; }; } comp; in @@ -552,7 +545,6 @@ currentScope = "__test"; scopedIncludesChain = _: { }; scopedConstraintRegistry = _: { }; - scopedConstraintFilters = _: { }; }; } comp; in diff --git a/templates/ci/modules/internal-api/fx-effectful-resolve.nix b/templates/ci/modules/internal-api/fx-effectful-resolve.nix index a0eb64967..8e8ebda9d 100644 --- a/templates/ci/modules/internal-api/fx-effectful-resolve.nix +++ b/templates/ci/modules/internal-api/fx-effectful-resolve.nix @@ -81,7 +81,6 @@ let currentScope = "__test"; scopedIncludesChain = _: { }; scopedConstraintRegistry = _: { }; - scopedConstraintFilters = _: { }; paths = [ ]; }; in diff --git a/templates/ci/modules/internal-api/narrow-effects.nix b/templates/ci/modules/internal-api/narrow-effects.nix index 2bd9f5345..ac9ef54c2 100644 --- a/templates/ci/modules/internal-api/narrow-effects.nix +++ b/templates/ci/modules/internal-api/narrow-effects.nix @@ -66,7 +66,6 @@ let rootScopeId = "__test"; scopedIncludesChain = _: { }; scopedConstraintRegistry = _: { }; - scopedConstraintFilters = _: { }; paths = [ ]; }; in From 1c989c2e3cdfeabbd64c11854d9fc0c68e10106e Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 12 Jun 2026 17:22:56 -0700 Subject: [PATCH 044/101] refactor(fx): unify scope-subtree walks + key-dedup One scope-walk.nix backs every subtree collection (default fold, route subtree collect, per-host re-walk, spawn extraction) and the edge-trace oracle, so production and oracle share ONE walk. `isolated` is a required arg with no default: the blind/aware split (census #6/#10) is deliberate, and the two blind callers (per-host sub-phase collect, spawn final extraction) are distinct call sites that must not collapse. dedupByKey folds three hand-rolled first-occurrence-wins go-loops (dedupProvides, extractSubtreeModules, wrapPerScope's per-class merge). dedupRoutes is left intact: its redundant-root suppression is interleaved with the key-dedup and does not separate cleanly. --- nix/lib/aspects/fx/edge-trace.nix | 22 ++--- nix/lib/aspects/fx/resolve.nix | 144 ++++++++--------------------- nix/lib/aspects/fx/route/apply.nix | 24 ++--- nix/lib/aspects/fx/scope-walk.nix | 60 ++++++++++++ nix/lib/aspects/fx/spawn-node.nix | 38 ++++---- 5 files changed, 134 insertions(+), 154 deletions(-) create mode 100644 nix/lib/aspects/fx/scope-walk.nix diff --git a/nix/lib/aspects/fx/edge-trace.nix b/nix/lib/aspects/fx/edge-trace.nix index 539ada2aa..2cf79977f 100644 --- a/nix/lib/aspects/fx/edge-trace.nix +++ b/nix/lib/aspects/fx/edge-trace.nix @@ -30,6 +30,9 @@ let # point of the extractor is to render the decisions the current code makes. route = import ./route { inherit lib den; }; inherit (route) dedupRoutes findChildScopeKeys; + # Share the ONE subtree walk with production (resolve.nix / route / spawn) so + # the oracle and the real pipeline can never diverge on subtree membership. + inherit (import ./scope-walk.nix { inherit lib; }) subtreeScopes; # --- scope naming ------------------------------------------------------- @@ -92,21 +95,10 @@ let allScopeIds, }: root: - let - isIn = - sid: - sid == root - || ( - !(scopeIsolated.${sid} or false) - && ( - let - parent = scopeParent.${sid} or null; - in - parent != null && parent != sid && isIn parent - ) - ); - in - builtins.filter isIn allScopeIds; + subtreeScopes { + inherit scopeParent allScopeIds root; + isolated = scopeIsolated; + }; # --- edge record + sort ------------------------------------------------- diff --git a/nix/lib/aspects/fx/resolve.nix b/nix/lib/aspects/fx/resolve.nix index 692d84f80..f312f2663 100644 --- a/nix/lib/aspects/fx/resolve.nix +++ b/nix/lib/aspects/fx/resolve.nix @@ -11,6 +11,7 @@ let inherit (import ./spawn-node.nix { inherit lib den; }) mkSpawnNode; route = import ./route { inherit lib den; }; inherit (import ./edge-trace.nix { inherit lib den; }) extractEdgeTrace; + inherit (import ./scope-walk.nix { inherit lib; }) subtreeScopes dedupByKey; handlers = den.lib.aspects.fx.handlers; # Check if `ancestor` is an ancestor of `descendant` in the scopeParent tree. @@ -25,26 +26,13 @@ let parent == ancestor || isAncestorOf scopeParent ancestor parent; # Dedup provides by composite key (policyName/class/path). - dedupProvides = - raw: + dedupProvides = dedupByKey ( + s: let - go = - seen: specs: - if specs == [ ] then - [ ] - else - let - s = builtins.head specs; - rest = builtins.tail specs; - pn = s.__providePolicyName or null; - key = if pn != null then "${pn}/${s.class}/${lib.concatStringsSep "/" (s.path or [ ])}" else null; - in - if key != null && seen ? ${key} then - go seen rest - else - [ s ] ++ go (if key != null then seen // { ${key} = true; } else seen) rest; + pn = s.__providePolicyName or null; in - go { } raw; + if pn != null then "${pn}/${s.class}/${lib.concatStringsSep "/" (s.path or [ ])}" else null + ); # Phase 1: Wrap collected class imports per-scope. # Deduplicates modules with identical keys across scopes: when a shared @@ -57,50 +45,14 @@ let wrappedPerScope = lib.mapAttrs ( scopeId: scopeClasses: wrapCollectedClasses (scopeContexts.${scopeId} or ctx) scopeClasses ) scopedClassImportsRaw; - # Fold scopes, deduplicating keyed modules (first occurrence wins). - merged = - let - go = - acc: scopeData: - let - allClasses = lib.unique (builtins.attrNames acc.classes ++ builtins.attrNames scopeData); - in - builtins.foldl' ( - a: cls: - let - existing = a.classes.${cls} or [ ]; - seenKeys = a.keys.${cls} or { }; - newMods = scopeData.${cls} or [ ]; - filtered = builtins.filter ( - m: - let - k = m.key or null; - in - k == null || !(seenKeys ? ${k}) - ) newMods; - addedKeys = builtins.foldl' ( - ks: m: - let - k = m.key or null; - in - if k == null then ks else ks // { ${k} = true; } - ) seenKeys filtered; - in - { - classes = a.classes // { - ${cls} = existing ++ filtered; - }; - keys = a.keys // { - ${cls} = addedKeys; - }; - } - ) acc allClasses; - final = builtins.foldl' go { - classes = { }; - keys = { }; - } (builtins.attrValues wrappedPerScope); - in - final.classes; + # Per class, concatenate every scope's modules (scope attr-name order) + # and dedup by key first-occurrence-wins. Equivalent to the old per-class + # cross-scope seenKeys fold; null-keyed (anon) modules are never deduped. + scopeData = builtins.attrValues wrappedPerScope; + allClasses = lib.unique (builtins.concatMap builtins.attrNames scopeData); + merged = lib.genAttrs allClasses ( + cls: dedupByKey (m: m.key or null) (builtins.concatMap (sd: sd.${cls} or [ ]) scopeData) + ); in { classImports = merged; @@ -217,46 +169,21 @@ let extractSubtreeModules = perScope: scopeParent: scopeIsolated: rootScopeId: targetClass: let - allScopeIds = builtins.attrNames perScope; - # Collect descendant scope IDs by walking scopeParent — skipping isolated - # descendants (and everything below them). The collection root is always - # included: isolation gates crossing INTO an entity, not collecting AT it. - isInSubtree = - sid: - sid == rootScopeId - || ( - !(scopeIsolated.${sid} or false) - && ( - let - parent = scopeParent.${sid} or null; - in - parent != null && parent != sid && isInSubtree parent - ) - ); - subtreeScopes = builtins.filter isInSubtree allScopeIds; + # Isolation-AWARE walk: skip isolated descendants (and everything below + # them). The collection root is always included: isolation gates crossing + # INTO an entity, not collecting AT it. + scopes = subtreeScopes { + inherit scopeParent; + isolated = scopeIsolated; + root = rootScopeId; + allScopeIds = builtins.attrNames perScope; + }; # Collect modules from all subtree scopes, deduplicating by key. # Same aspect included at multiple scope levels (host default + user default) # produces identical static modules; first occurrence wins. # Named modules carry `key`; anon modules carry `_file` from setDefaultModuleLocation. - raw = lib.concatMap (sid: perScope.${sid}.${targetClass} or [ ]) subtreeScopes; - deduped = - let - go = - seen: mods: - if mods == [ ] then - [ ] - else - let - m = builtins.head mods; - rest = builtins.tail mods; - k = m.key or null; - in - if k != null && seen ? ${k} then - go seen rest - else - [ m ] ++ go (if k != null then seen // { ${k} = true; } else seen) rest; - in - go { } raw; + raw = lib.concatMap (sid: perScope.${sid}.${targetClass} or [ ]) scopes; + deduped = dedupByKey (m: m.key or null) raw; in if deduped == [ ] then null else deduped; @@ -283,15 +210,17 @@ let preWalkedModules = if hostScopeId != null then let - isInSubtree = - sid: - sid == hostScopeId - || ( - let - parent = scopeParent.${sid} or null; - in - parent != null && parent != sid && isInSubtree parent - ); + # Isolation-BLIND collect (census #10): the per-host re-walk collects + # sub-phases over the blind set, then extractSubtreeModules extracts + # over the isolation-AWARE set below. Pass `isolated = {}` explicitly + # — defaulting it would collapse this deliberate blind/aware split. + subtreeScopeIds = subtreeScopes { + inherit scopeParent allScopeIds; + isolated = { }; + root = hostScopeId; + }; + subtreeSet = lib.genAttrs subtreeScopeIds (_: true); + isInSubtree = sid: subtreeSet ? ${sid}; isAncestor = sid: let @@ -299,7 +228,6 @@ let in sid == parent || (parent != null && parent != hostScopeId && isAncestorOf scopeParent sid parent); isRelevant = sid: isInSubtree sid || isAncestor sid; - subtreeScopeIds = builtins.filter isInSubtree allScopeIds; relevantScopeIds = builtins.filter isRelevant allScopeIds; scopeEntityClassMap = scopeEntityClass null; subtreeContexts = lib.genAttrs subtreeScopeIds ( diff --git a/nix/lib/aspects/fx/route/apply.nix b/nix/lib/aspects/fx/route/apply.nix index 4cac49236..69e824909 100644 --- a/nix/lib/aspects/fx/route/apply.nix +++ b/nix/lib/aspects/fx/route/apply.nix @@ -7,6 +7,7 @@ collectClassMods, }: let + inherit (import ../scope-walk.nix { inherit lib; }) subtreeScopes; # Root-scope `fromClass` content a child-scope forward may pull in. When # `fromClass` is a class some entity in the chain owns, root content under it # is that entity's own declaration, not aggregation fodder — restrict to @@ -150,22 +151,15 @@ let collectFromSubtree = wrappedPerScope: scopeParent: scopeIsolated: rootScopeId: fromClass: let - allScopeIds = builtins.attrNames wrappedPerScope; - isInSubtree = - sid: - sid == rootScopeId - || ( - !(scopeIsolated.${sid} or false) - && ( - let - parent = scopeParent.${sid} or null; - in - parent != null && parent != sid && isInSubtree parent - ) - ); - subtreeScopes = builtins.filter isInSubtree allScopeIds; + # Isolation-AWARE walk. + scopes = subtreeScopes { + inherit scopeParent; + isolated = scopeIsolated; + root = rootScopeId; + allScopeIds = builtins.attrNames wrappedPerScope; + }; in - lib.concatMap (sid: wrappedPerScope.${sid}.${fromClass} or [ ]) subtreeScopes; + lib.concatMap (sid: wrappedPerScope.${sid}.${fromClass} or [ ]) scopes; applySimpleRoute = acc: diff --git a/nix/lib/aspects/fx/scope-walk.nix b/nix/lib/aspects/fx/scope-walk.nix new file mode 100644 index 000000000..182640358 --- /dev/null +++ b/nix/lib/aspects/fx/scope-walk.nix @@ -0,0 +1,60 @@ +# Shared subtree walk + key-dedup for the delivery half. +# +# One walk implementation backs every "scope + descendants" collection in the +# pipeline (default fold, route subtree collect, per-host re-walk, spawn final +# extraction) and the edge-trace oracle, so production and oracle can never +# diverge. The `isolated` argument is REQUIRED at every call site: there is no +# default. Two live callers need the isolation-BLIND variant (`isolated = {}`) +# and they are NOT the same call site (census #6/#10): the per-host re-walk's +# sub-phase collect, and spawn-node's final extraction. Defaulting `isolated` +# would silently collapse the blind/aware split the census proved deliberate. +{ lib, ... }: +{ + # Scope IDs in `root`'s subtree. `root` is ALWAYS included: isolation gates + # crossing INTO a descendant, not collecting AT the root. `isolated` is a + # `{ = bool; }` map; `isolated = {}` gives the isolation-blind variant. + subtreeScopes = + { + scopeParent, + isolated, + root, + allScopeIds, + }: + let + isIn = + sid: + sid == root + || ( + !(isolated.${sid} or false) + && ( + let + parent = scopeParent.${sid} or null; + in + parent != null && parent != sid && isIn parent + ) + ); + in + builtins.filter isIn allScopeIds; + + # First-occurrence-wins dedup of `list` by the key `getKey` extracts from each + # element. Elements whose key is null are always kept (never deduped). + dedupByKey = + getKey: list: + let + go = + seen: items: + if items == [ ] then + [ ] + else + let + x = builtins.head items; + rest = builtins.tail items; + k = getKey x; + in + if k != null && seen ? ${k} then + go seen rest + else + [ x ] ++ go (if k != null then seen // { ${k} = true; } else seen) rest; + in + go { } list; +} diff --git a/nix/lib/aspects/fx/spawn-node.nix b/nix/lib/aspects/fx/spawn-node.nix index b81e57021..615251229 100644 --- a/nix/lib/aspects/fx/spawn-node.nix +++ b/nix/lib/aspects/fx/spawn-node.nix @@ -9,6 +9,7 @@ { lib, den }: let inherit (import ./assemble-pipes.nix { inherit lib den; }) assemblePipes; + inherit (import ./scope-walk.nix { inherit lib; }) subtreeScopes; inherit (import ./handlers/route.nix { inherit lib; }) routeKey; pipeNamesSet = lib.genAttrs (builtins.attrNames (den.quirks or { })) (_: true); in @@ -125,8 +126,22 @@ in # can register in both pipelines, and a duplicated path != [] simple # route would re-nest content in fresh keyless wrappers and conflict # at the target. + # Isolation-BLIND subtree membership rooted at spawnRoot, over the merged + # parent DAG. `isolated = {}` is passed EXPLICITLY (census #6 documented + # invariant: isolated entities resolve via resolve.to in the host pipeline, + # never through spawnNode, so no isolated descendant can appear under + # spawnRoot). Walked over mergedScopeParent + route-scope keys (NOT + # phase3.perScope) to avoid a cycle: phase3 depends on parentSubtreeRoutes. + subtreeSet = lib.genAttrs (subtreeScopes { + scopeParent = mergedScopeParent; + isolated = { }; + root = spawnRoot; + allScopeIds = lib.unique ( + builtins.attrNames mergedScopeParent ++ builtins.attrNames parentState.scopedRoutes + ); + }) (_: true); spawnRoutes = result.state.scopedRoutes null; - parentSubtreeRoutes = lib.filterAttrs (sid: _: isInSubtree sid) parentState.scopedRoutes; + parentSubtreeRoutes = lib.filterAttrs (sid: _: subtreeSet ? ${sid}) parentState.scopedRoutes; mergedSpawnRoutes = spawnRoutes // lib.mapAttrs ( @@ -150,22 +165,13 @@ in # on the same host) — so reading it directly would leak a peer user's # homeManager content into this node. The fleet pipe values still resolve # correctly because assemblePipes ran over the full merged state; only the - # final per-scope class buckets are subtree-restricted here. - # Isolation-blind by design: isolated entities resolve via resolve.to in - # the host pipeline, never through spawnNode, so no isolated descendant - # can appear under spawnRoot. Revisit if that invariant ever changes. - isInSubtree = - sid: - sid == spawnRoot - || ( - let - parent = mergedScopeParent.${sid} or null; - in - parent != null && parent != sid && isInSubtree parent - ); - subtreeScopes = builtins.filter isInSubtree (builtins.attrNames phase3.perScope); + # final per-scope class buckets are subtree-restricted here (subtreeSet, + # the isolation-blind membership defined above with the parentSubtreeRoutes + # filter — both consumers share one blind walk). in { - imports = lib.concatMap (sid: phase3.perScope.${sid}.${class} or [ ]) subtreeScopes; + imports = lib.concatMap (sid: phase3.perScope.${sid}.${class} or [ ]) ( + builtins.filter (sid: subtreeSet ? ${sid}) (builtins.attrNames phase3.perScope) + ); }; } From 8c6c0a11d922e2dff9d4d61de8259203c6222f11 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 12 Jun 2026 17:33:11 -0700 Subject: [PATCH 045/101] refactor(fx): single stage interpreter, provenance as a value functor --- nix/lib/aspects/fx/assemble-pipes.nix | 242 +++++++++----------------- 1 file changed, 78 insertions(+), 164 deletions(-) diff --git a/nix/lib/aspects/fx/assemble-pipes.nix b/nix/lib/aspects/fx/assemble-pipes.nix index fc9a5ac7e..d59fdd74b 100644 --- a/nix/lib/aspects/fx/assemble-pipes.nix +++ b/nix/lib/aspects/fx/assemble-pipes.nix @@ -129,26 +129,64 @@ let hostConfigs: scopeContexts: scopeId: values: builtins.concatMap (resolveEntry hostConfigs scopeContexts scopeId) values; - # Apply a single transform stage to a value list. - # Config thunk markers (__configThunk) pass through filter/transform unchanged. - applyStage = - values: stage: + # Value functor: lets ONE stage interpreter run over either bare values (the + # plain path) or provenance-tagged values ({ __pv = value; __ps = scopeId; }). + # Each functor supplies: + # unwrap wrapped -> raw (read the underlying value) + # rewrap wrapped -> raw -> wrapped (replace value, keep tag — transform) + # seed scopeId -> raw -> wrapped (tag a fresh value at a given scope — + # fold/append/for re-tag, collect tags + # each value with its SOURCE scope) + # passthrough wrapped -> bool (skip filter/transform — the plain + # path passes __configThunk markers + # through unchanged; provenance never) + idFunctor = { + unwrap = v: v; + rewrap = _old: raw: raw; + seed = _scope: raw: raw; + passthrough = v: v ? __configThunk; + }; + pvFunctor = { + unwrap = v: v.__pv; + rewrap = old: raw: old // { __pv = raw; }; + seed = scope: raw: { + __pv = raw; + __ps = scope; + }; + passthrough = _v: false; + }; + + # Apply a single filter/transform/fold/append/for stage to a value list, + # interpreted through `functor`. `currentScopeId` is the scope new values + # (fold/append/for results) are re-tagged to. + applyStageWith = + functor: currentScopeId: values: stage: let t = stage.__pipeStage or ""; + inherit (functor) + unwrap + rewrap + passthrough + ; + seed = functor.seed currentScopeId; in if t == "filter" then - builtins.filter (v: v ? __configThunk || stage.fn v) values + builtins.filter (v: passthrough v || stage.fn (unwrap v)) values else if t == "transform" then - map (v: if v ? __configThunk then v else stage.fn v) values + map (v: if passthrough v then v else rewrap v (stage.fn (unwrap v))) values else if t == "fold" then - [ (builtins.foldl' stage.fn stage.init values) ] + [ (seed (builtins.foldl' (acc: v: stage.fn acc (unwrap v)) stage.init values)) ] else if t == "append" then - values ++ [ stage.value ] + values ++ [ (seed stage.value) ] else if t == "for" then - stage.fn values + map seed (stage.fn (map unwrap values)) else values; + # Plain-path single-stage application (identity functor, no provenance tag). + # Config thunk markers (__configThunk) pass through filter/transform unchanged. + applyStage = applyStageWith idFunctor null; + # Apply all transform stages from a pipe effect. applyTransformStages = values: stages: @@ -235,70 +273,16 @@ let in builtins.filter predicateMatches candidates; - # Collect quirks from all scopes matching a predicate (no parent constraint). - collectFromAll = - { - scopeContexts, - scopeEntityKind ? { }, - scopedClassImports, - currentScopeId, - pipeName, - hostConfigs ? null, - }: - predicate: - let - matchingScopes = findMatchingAll { - inherit - scopeContexts - scopeEntityKind - currentScopeId - ; - } predicate; - in - lib.concatMap ( - sid: - let - entries = (scopedClassImports.${sid} or { }).${pipeName} or [ ]; - values = flattenAndExtract entries; - in - resolveThunks hostConfigs scopeContexts sid values - ) matchingScopes; - - # Collect quirks from sibling scopes matching a predicate. - collectFromPeers = - { - scopeContexts, - scopeParent, - scopeEntityKind ? { }, - scopedClassImports, - currentScopeId, - pipeName, - hostConfigs ? null, - }: - predicate: - let - matchingScopes = findMatchingSiblings { - inherit - scopeContexts - scopeParent - scopeEntityKind - currentScopeId - ; - } predicate; - in - lib.concatMap ( - sid: - let - entries = (scopedClassImports.${sid} or { }).${pipeName} or [ ]; - values = flattenAndExtract entries; - in - resolveThunks hostConfigs scopeContexts sid values - ) matchingScopes; - # Process stages sequentially, including collect and withProvenance stages. # When withProvenance is present, values are internally tagged with source # scope IDs: { __pv = value; __ps = scopeId; }. The withProvenance stage # converts these to user-visible { value; source; } format. + # + # One interpreter, lifted over a value functor (idFunctor for the plain path, + # pvFunctor when withProvenance is present). filter/transform/fold/append/for + # go through applyStageWith; collect/collectAll resolve matching scopes and + # tag each collected value with its SOURCE scope (seed sid), which is the + # identity for the plain path and the provenance tag for the provenance path. processStagesWithCollect = { scopeContexts, @@ -312,6 +296,7 @@ let initialValues: stages: let hasProvenance = builtins.any (s: (s.__pipeStage or "") == "withProvenance") stages; + functor = if hasProvenance then pvFunctor else idFunctor; relevantStages = builtins.filter ( s: builtins.elem (s.__pipeStage or "") [ @@ -325,14 +310,21 @@ let "withProvenance" ] ) stages; - taggedInitial = - if hasProvenance then - map (v: { - __pv = v; - __ps = currentScopeId; - }) initialValues - else - initialValues; + # Tag initial values at the current scope (identity for the plain path). + taggedInitial = map (functor.seed currentScopeId) initialValues; + # Resolve a list of matching scopes into collected values, each tagged with + # its SOURCE scope id (not currentScopeId). + collectTagged = + matchingScopes: + lib.concatMap ( + sid: + let + entries = (scopedClassImports.${sid} or { }).${pipeName} or [ ]; + rawValues = flattenAndExtract entries; + resolved = resolveThunks hostConfigs scopeContexts sid rawValues; + in + map (functor.seed sid) resolved + ) matchingScopes; in builtins.foldl' ( values: stage: @@ -340,113 +332,35 @@ let t = stage.__pipeStage or ""; in if t == "collect" then - if hasProvenance then - let - matchingScopes = findMatchingSiblings { - inherit - scopeContexts - scopeParent - scopeEntityKind - currentScopeId - ; - } stage.fn; - collected = lib.concatMap ( - sid: - let - entries = (scopedClassImports.${sid} or { }).${pipeName} or [ ]; - rawValues = flattenAndExtract entries; - resolved = resolveThunks hostConfigs scopeContexts sid rawValues; - in - map (v: { - __pv = v; - __ps = sid; - }) resolved - ) matchingScopes; - in - values ++ collected - else - values - ++ collectFromPeers { + values + ++ collectTagged ( + findMatchingSiblings { inherit scopeContexts scopeParent scopeEntityKind - scopedClassImports currentScopeId - pipeName - hostConfigs ; } stage.fn + ) else if t == "collectAll" then - if hasProvenance then - let - matchingScopes = findMatchingAll { - inherit - scopeContexts - scopeEntityKind - currentScopeId - ; - } stage.fn; - collected = lib.concatMap ( - sid: - let - entries = (scopedClassImports.${sid} or { }).${pipeName} or [ ]; - rawValues = flattenAndExtract entries; - resolved = resolveThunks hostConfigs scopeContexts sid rawValues; - in - map (v: { - __pv = v; - __ps = sid; - }) resolved - ) matchingScopes; - in - values ++ collected - else - values - ++ collectFromAll { + values + ++ collectTagged ( + findMatchingAll { inherit scopeContexts scopeEntityKind - scopedClassImports currentScopeId - pipeName - hostConfigs ; } stage.fn + ) else if t == "withProvenance" then map (v: { value = v.__pv; source = scopeContexts.${v.__ps}; }) values - else if hasProvenance then - if t == "filter" then - builtins.filter (v: stage.fn v.__pv) values - else if t == "transform" then - map (v: v // { __pv = stage.fn v.__pv; }) values - else if t == "fold" then - [ - { - __pv = builtins.foldl' (acc: v: stage.fn acc v.__pv) stage.init values; - __ps = currentScopeId; - } - ] - else if t == "append" then - values - ++ [ - { - __pv = stage.value; - __ps = currentScopeId; - } - ] - else if t == "for" then - map (v: { - __pv = v; - __ps = currentScopeId; - }) (stage.fn (map (v: v.__pv) values)) - else - values else - applyStage values stage + applyStageWith functor currentScopeId values stage ) taggedInitial relevantStages; # Check whether a pipe effect has a pipe.to routing stage. From 52b6df22b8dc07b510dac799d02661a26f8dd208 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 12 Jun 2026 17:38:07 -0700 Subject: [PATCH 046/101] docs(fx): note bug-for-bug fidelity of provenance passthrough --- nix/lib/aspects/fx/assemble-pipes.nix | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/nix/lib/aspects/fx/assemble-pipes.nix b/nix/lib/aspects/fx/assemble-pipes.nix index d59fdd74b..0de6b8dcc 100644 --- a/nix/lib/aspects/fx/assemble-pipes.nix +++ b/nix/lib/aspects/fx/assemble-pipes.nix @@ -139,7 +139,10 @@ let # each value with its SOURCE scope) # passthrough wrapped -> bool (skip filter/transform — the plain # path passes __configThunk markers - # through unchanged; provenance never) + # through unchanged; provenance never: + # the legacy provenance interpreter had + # no __configThunk guard, preserved + # bug-for-bug — do not "fix") idFunctor = { unwrap = v: v; rewrap = _old: raw: raw; From 888cd262e741bdf945507178c0407a922e250915 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 12 Jun 2026 17:47:15 -0700 Subject: [PATCH 047/101] feat(fx): edge materializer + default-fold port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Land the architectural core of the delivery-edge unification: the first mechanism (default class-fold) ported onto the edge algebra. - edges/edge.nix: the shared delivery-edge record (constructor, (T,P,S,M) sort key, id_hash scope naming, S/T constructors), EXTRACTED from edge-trace.nix so the read-only oracle and production constructors share ONE edge definition (spec §3a convergence). - edges/default.nix: the default-fold constructor — one merge edge per entity-root scope per class with content; isolation consumed at construction (subtree boundary), never as a mid-walk filter (corollary 2). - edges/materialize.nix: materialize (the ONLY mode switch, no mechanism names, no isolation reads) + assembleSubtree. merge mode = subtreeScopes + dedupByKey (the wrapPerScope/extractSubtreeModules semantics). nest arms throw explicit not-yet-ported markers (Task 8). Π(root) record per §A with per-field census provenance. - resolve.nix: mkInstantiateArgs' final per-host extraction (census variant B) routes through assembleSubtree with an EXPLICIT pi record; the old extractSubtreeModules is deleted (merge semantics moved to materialize). - edge-trace.nix: inline default-fold + mkEdge/sort/naming deleted in favor of the shared edge.nix + default.nix constructors. Pragmatic scope (§D): only mkInstantiateArgs' extraction is routed this task. Top-level (A) extraction is the wrapPerScope merge (no subtree extract to route); spawn-node (C) extraction is isolation-blind + dedup-free over a route-augmented scope set — left for Task 10 to avoid a trace move. B′ baseDrain ACCIDENT untouched (Task 11). delivery-edges 14/14 byte-stable; full CI 961/961; entity-isolation green. --- nix/lib/aspects/fx/edge-trace.nix | 199 ++++------------------- nix/lib/aspects/fx/edges/default.nix | 84 ++++++++++ nix/lib/aspects/fx/edges/edge.nix | 140 ++++++++++++++++ nix/lib/aspects/fx/edges/materialize.nix | 154 ++++++++++++++++++ nix/lib/aspects/fx/resolve.nix | 56 ++++--- 5 files changed, 445 insertions(+), 188 deletions(-) create mode 100644 nix/lib/aspects/fx/edges/default.nix create mode 100644 nix/lib/aspects/fx/edges/edge.nix create mode 100644 nix/lib/aspects/fx/edges/materialize.nix diff --git a/nix/lib/aspects/fx/edge-trace.nix b/nix/lib/aspects/fx/edge-trace.nix index 2cf79977f..7f80a38d8 100644 --- a/nix/lib/aspects/fx/edge-trace.nix +++ b/nix/lib/aspects/fx/edge-trace.nix @@ -30,130 +30,25 @@ let # point of the extractor is to render the decisions the current code makes. route = import ./route { inherit lib den; }; inherit (route) dedupRoutes findChildScopeKeys; - # Share the ONE subtree walk with production (resolve.nix / route / spawn) so - # the oracle and the real pipeline can never diverge on subtree membership. - inherit (import ./scope-walk.nix { inherit lib; }) subtreeScopes; - - # --- scope naming ------------------------------------------------------- - - # Entity kind for a scope, if any. scopeEntityKind covers scopes created by - # `resolve.to`, but NOT the pipeline root (it is seeded from ctx, never - # `resolve.to`-created). For the root (and any ctx-seeded entity scope), scan - # the scope's own ctx for a kind-keyed record carrying an id_hash. - entityKindOf = - { scopeEntityKind, scopeContexts }: - sid: - let - viaKind = scopeEntityKind.${sid} or null; - ctx = scopeContexts.${sid} or { }; - # A ctx key whose value is an entity record (has id_hash). Sorted for - # determinism; first wins (a scope carries one own-entity record). - ctxKinds = lib.filter (k: builtins.isAttrs (ctx.${k} or null) && (ctx.${k} ? id_hash)) ( - lib.sort (a: b: a < b) (builtins.attrNames ctx) - ); - in - if viaKind != null then - viaKind - else if ctxKinds != [ ] then - builtins.head ctxKinds - else - null; - - # id_hash of the own-entity record at a scope, if the scope is an entity scope. - idHashOf = - args@{ scopeEntityKind, scopeContexts }: - sid: - let - kind = entityKindOf args sid; - erec = if kind == null then null else (scopeContexts.${sid} or { }).${kind} or null; - in - if erec == null then null else erec.id_hash or null; - - # Stable scope NAME for S/T. Entity scopes → ":" (parent-blind - # identity, stable across re-keying and same-name siblings collapse by design, - # spec §8). Non-entity scopes (system=…, root "") → the mkScopeId string. - scopeName = - args@{ scopeEntityKind, scopeContexts }: - sid: - let - kind = entityKindOf args sid; - idHash = idHashOf args sid; - in - if kind != null && idHash != null then - "${kind}:${idHash}" - else - (if sid == "" then "" else sid); - - # --- subtree walk (isolation-aware, matching extractSubtreeModules) ------ - - # Scope IDs in root's subtree: root always included; isolation gates crossing - # INTO a descendant (resolve.nix:extractSubtreeModules / collectFromSubtree). - subtreeScopesOf = - { - scopeParent, - scopeIsolated, - allScopeIds, - }: - root: - subtreeScopes { - inherit scopeParent allScopeIds root; - isolated = scopeIsolated; - }; - - # --- edge record + sort ------------------------------------------------- - - mkEdge = - { - source, - target, - path ? [ ], - mode, - annotations ? { }, - }: - { - inherit - source - target - path - mode - annotations - ; - }; - - # Canonical string keys for stable sort (spec §8: T, P, S, M). - targetKey = - t: if t ? output then "out:${lib.concatStringsSep "." t.output}" else "root:${t.root}/${t.class}"; - pathKey = p: lib.concatStringsSep "/" p; - sourceKey = - s: - if s ? collected then - "collected:${s.collected.scope}/${s.collected.class}" - else if s ? rewalk then - "rewalk:${s.rewalk.aspect}/${lib.concatStringsSep "+" s.rewalk.bindings}/${s.rewalk.class}" - else if s ? synthesize then - "synthesize:${s.synthesize.forwardId}/${s.synthesize.fromClass}>${s.synthesize.intoClass}" - else - "empty"; - edgeSortKey = - e: - lib.concatStringsSep " | " [ - (targetKey e.target) - (pathKey e.path) - (sourceKey e.source) - e.mode - ]; - - sortEdges = edges: lib.sort (a: b: edgeSortKey a < edgeSortKey b) edges; - - # --- S/T constructors --------------------------------------------------- - - collected = scope: class: { collected = { inherit scope class; }; }; - rewalk = aspect: bindings: class: { rewalk = { inherit aspect bindings class; }; }; - synthesize = forwardId: fromClass: intoClass: { - synthesize = { inherit forwardId fromClass intoClass; }; - }; - rootTarget = root: class: { inherit root class; }; - outputTarget = output: { inherit output; }; + # The shared edge record, sort key, scope-naming, and S/T constructors — the + # ONE edge definition production (edges/default.nix) and this oracle share, so + # they can never diverge (spec §3a convergence). EXTRACTED to edges/edge.nix. + inherit (import ./edges/edge.nix { inherit lib; }) + entityKindOf + scopeName + mkEdge + sortEdges + collected + rewalk + synthesize + rootTarget + outputTarget + ; + # The default-fold edge constructor — the SAME constructor production resolves + # the per-host extraction through (edges/materialize.nix). v0's inline default- + # fold arm is REPLACED by this import so extractor and production converge on + # one constructor (spec §3a). + inherit (import ./edges/default.nix { inherit lib; }) defaultFoldEdges; in { # extractEdgeTrace: pipeline end-state → stably-sorted normalized edge list. @@ -186,45 +81,21 @@ in ); # ===== default fold edges ========================================== - # One per entity-root scope per class with content: - # collected(subtree minus isolated, class) → (root, class), P=[], M=merge. - # Source content is collected from the isolation-aware subtree; the edge - # records the source as the subtree's ROOT scope name + class (the - # collection is keyed by root, not enumerated per-scope — spec §8 records - # the collected(scope,class) identity, not content). - defaultFoldEdges = builtins.concatLists ( - map ( - rootSid: - let - subtree = subtreeScopesOf { - inherit scopeParent scopeIsolated allScopeIds; - } rootSid; - # Classes with any content anywhere in the subtree. - classesWithContent = lib.unique ( - builtins.concatLists (map (sid: builtins.attrNames (scopedClassImports.${sid} or { })) subtree) - ); - hasContent = cls: builtins.any (sid: (scopedClassImports.${sid} or { }) ? ${cls}) subtree; - # The normalized names of every scope this fold collects from — the - # isolation-aware subtree (an isolated descendant is its OWN root, so - # it is ABSENT here). Surfaced as an annotation so the isolation-as- - # edge-absence corollary can assert with teeth: an isolated child's - # scope name must NOT appear in its parent fold's collectedScopes. - collectedScopes = lib.sort (a: b: a < b) (lib.unique (map name subtree)); - in - map ( - cls: - mkEdge { - source = collected (name rootSid) cls; - target = rootTarget (name rootSid) cls; - path = [ ]; - mode = "merge"; - annotations = { - inherit collectedScopes; - }; - } - ) (builtins.filter hasContent classesWithContent) - ) entityRootScopes - ); + # The SAME constructor production routes the per-host extraction through + # (edges/default.nix defaultFoldEdges → edges/materialize.nix). v0's inline + # arm is gone; the oracle and production share one constructor (spec §3a). + # classContentAt = the per-scope class buckets (only `? class` membership is + # read for content presence). + defaultFold = defaultFoldEdges { + inherit + name + scopeParent + scopeIsolated + allScopeIds + entityRootScopes + ; + classContentAt = scopedClassImports; + }; # ===== provides edges (two-edge decomposition, §B Decision 1) ====== # A provides spec → a nest edge into the SOURCE scope's bucket @@ -505,7 +376,7 @@ in ) instGrouped ); - allEdges = defaultFoldEdges ++ providesEdges ++ routeEdges ++ spawnEdges ++ instantiateEdges; + allEdges = defaultFold ++ providesEdges ++ routeEdges ++ spawnEdges ++ instantiateEdges; in sortEdges allEdges; } diff --git a/nix/lib/aspects/fx/edges/default.nix b/nix/lib/aspects/fx/edges/default.nix new file mode 100644 index 000000000..5d6b7771d --- /dev/null +++ b/nix/lib/aspects/fx/edges/default.nix @@ -0,0 +1,84 @@ +# default.nix — edge constructors. The constructor functions that turn recorded +# pipeline state into delivery edges (spec §3c "edge collection"). Both the +# read-only oracle (edge-trace.nix) and the production materializer +# (materialize.nix → resolve.nix) source their edges from HERE, so extractor and +# production can never disagree on an edge's shape (spec §3a convergence). +# +# This task (Task 7) lands the DEFAULT-FOLD constructor only. Routes, provides, +# spawn, and instantiate constructors are added by Tasks 8–11; until then those +# mechanisms' edges are still rendered by edge-trace.nix's own (soon-superseded) +# inline arms. +{ lib, ... }: +let + inherit (import ./edge.nix { inherit lib; }) mkEdge collected rootTarget; + inherit (import ../scope-walk.nix { inherit lib; }) subtreeScopes; +in +{ + # ===== default fold edges ============================================= + # Corollary 1 (spec §2): every entity-root scope contributes one merge edge + # per class with content — + # collected(subtree minus isolated, class) → (root, class), P=[], M=merge. + # `isolated` is consumed HERE, at edge construction (the subtree boundary via + # subtreeScopes), NOT inside the materializer — corollary 2: isolation is + # edge-absence, never a mid-walk filter. An isolated descendant is its OWN + # entity-root, so it is ABSENT from its parent's collected subtree and emits + # its own fold edge instead. + # + # The edge records the source as the subtree's ROOT scope name + class (spec + # §8 records the collected(scope,class) IDENTITY, not enumerated content). The + # collectedScopes annotation surfaces the isolation-aware subtree membership so + # the isolation-as-edge-absence corollary can assert with teeth (an isolated + # child's scope name must NOT appear in its parent fold's collectedScopes). + # + # Inputs are already-projected pipeline end-state: + # name — sid → stable scope name (edge.nix scopeName, bound to the + # pipeline's scopeEntityKind+scopeContexts). + # scopeParent — the parent DAG (for the subtree walk). + # scopeIsolated — { sid → bool } isolation marks (subtree boundary). + # classContentAt — sid → { class → bool|content } presence map (any scope's + # class buckets); only `? class` membership is read. + # allScopeIds — every scope id in the projection. + # entityRootScopes — the roots to emit folds for (pipeline root + isolated + # scopes, each its own root per corollary 2). + defaultFoldEdges = + { + name, + scopeParent, + scopeIsolated, + classContentAt, + allScopeIds, + entityRootScopes, + }: + builtins.concatLists ( + map ( + rootSid: + let + subtree = subtreeScopes { + inherit scopeParent allScopeIds; + isolated = scopeIsolated; + root = rootSid; + }; + # Classes with any content anywhere in the isolation-aware subtree. + classesWithContent = lib.unique ( + builtins.concatLists (map (sid: builtins.attrNames (classContentAt.${sid} or { })) subtree) + ); + hasContent = cls: builtins.any (sid: (classContentAt.${sid} or { }) ? ${cls}) subtree; + # Normalized names of every scope this fold collects from. An isolated + # descendant is its OWN root, so it is ABSENT here (corollary 2). + collectedScopes = lib.sort (a: b: a < b) (lib.unique (map name subtree)); + in + map ( + cls: + mkEdge { + source = collected (name rootSid) cls; + target = rootTarget (name rootSid) cls; + path = [ ]; + mode = "merge"; + annotations = { + inherit collectedScopes; + }; + } + ) (builtins.filter hasContent classesWithContent) + ) entityRootScopes + ); +} diff --git a/nix/lib/aspects/fx/edges/edge.nix b/nix/lib/aspects/fx/edges/edge.nix new file mode 100644 index 000000000..46db3ce50 --- /dev/null +++ b/nix/lib/aspects/fx/edges/edge.nix @@ -0,0 +1,140 @@ +# edge.nix — the shared delivery-edge record: constructor, the (T,P,S,M) sort +# key, and the id_hash-based scope-naming helpers. EXTRACTED from edge-trace.nix +# (Task 3) so that the read-only oracle (edge-trace.nix) and the production edge +# constructors (edges/default.nix) share ONE edge definition and can never +# diverge on record shape or normalization (spec §3a: extractor and constructors +# converge on one edge). +# +# Edge record: { source; target; path; mode; annotations; } +# S (source) — collected(scopeName, class) | rewalk(aspect, bindings, class) +# | synthesize(forwardId, fromClass, intoClass) +# T (target) — { root = scopeName; class; } (instantiation root) +# | { output = attrpath; } (flake-output) +# P (path) — attrpath; [] = merge at root +# M (mode) — "merge" | "nest" | "nest-verbatim" +# +# Trace normalization (spec §8): sort key (T, P, S, M); entity scopes named by +# id_hash (parent-blind identity), non-entity scopes by their mkScopeId string; +# rewalk/synthesize edges record the identity triple, NOT resolved content. +{ lib, ... }: +let + # --- scope naming ------------------------------------------------------- + + # Entity kind for a scope, if any. scopeEntityKind covers scopes created by + # `resolve.to`, but NOT the pipeline root (it is seeded from ctx, never + # `resolve.to`-created). For the root (and any ctx-seeded entity scope), scan + # the scope's own ctx for a kind-keyed record carrying an id_hash. + entityKindOf = + { scopeEntityKind, scopeContexts }: + sid: + let + viaKind = scopeEntityKind.${sid} or null; + ctx = scopeContexts.${sid} or { }; + # A ctx key whose value is an entity record (has id_hash). Sorted for + # determinism; first wins (a scope carries one own-entity record). + ctxKinds = lib.filter (k: builtins.isAttrs (ctx.${k} or null) && (ctx.${k} ? id_hash)) ( + lib.sort (a: b: a < b) (builtins.attrNames ctx) + ); + in + if viaKind != null then + viaKind + else if ctxKinds != [ ] then + builtins.head ctxKinds + else + null; + + # id_hash of the own-entity record at a scope, if the scope is an entity scope. + idHashOf = + args@{ scopeEntityKind, scopeContexts }: + sid: + let + kind = entityKindOf args sid; + erec = if kind == null then null else (scopeContexts.${sid} or { }).${kind} or null; + in + if erec == null then null else erec.id_hash or null; + + # Stable scope NAME for S/T. Entity scopes → ":" (parent-blind + # identity, stable across re-keying and same-name siblings collapse by design, + # spec §8). Non-entity scopes (system=…, root "") → the mkScopeId string. + scopeName = + args@{ scopeEntityKind, scopeContexts }: + sid: + let + kind = entityKindOf args sid; + idHash = idHashOf args sid; + in + if kind != null && idHash != null then + "${kind}:${idHash}" + else + (if sid == "" then "" else sid); + + # --- edge record + sort ------------------------------------------------- + + mkEdge = + { + source, + target, + path ? [ ], + mode, + annotations ? { }, + }: + { + inherit + source + target + path + mode + annotations + ; + }; + + # Canonical string keys for stable sort (spec §8: T, P, S, M). + targetKey = + t: if t ? output then "out:${lib.concatStringsSep "." t.output}" else "root:${t.root}/${t.class}"; + pathKey = p: lib.concatStringsSep "/" p; + sourceKey = + s: + if s ? collected then + "collected:${s.collected.scope}/${s.collected.class}" + else if s ? rewalk then + "rewalk:${s.rewalk.aspect}/${lib.concatStringsSep "+" s.rewalk.bindings}/${s.rewalk.class}" + else if s ? synthesize then + "synthesize:${s.synthesize.forwardId}/${s.synthesize.fromClass}>${s.synthesize.intoClass}" + else + "empty"; + edgeSortKey = + e: + lib.concatStringsSep " | " [ + (targetKey e.target) + (pathKey e.path) + (sourceKey e.source) + e.mode + ]; + + sortEdges = edges: lib.sort (a: b: edgeSortKey a < edgeSortKey b) edges; + + # --- S/T constructors --------------------------------------------------- + + collected = scope: class: { collected = { inherit scope class; }; }; + rewalk = aspect: bindings: class: { rewalk = { inherit aspect bindings class; }; }; + synthesize = forwardId: fromClass: intoClass: { + synthesize = { inherit forwardId fromClass intoClass; }; + }; + rootTarget = root: class: { inherit root class; }; + outputTarget = output: { inherit output; }; +in +{ + inherit + entityKindOf + idHashOf + scopeName + mkEdge + edgeSortKey + sortEdges + collected + rewalk + synthesize + rootTarget + outputTarget + ; +} diff --git a/nix/lib/aspects/fx/edges/materialize.nix b/nix/lib/aspects/fx/edges/materialize.nix new file mode 100644 index 000000000..92e94b8d2 --- /dev/null +++ b/nix/lib/aspects/fx/edges/materialize.nix @@ -0,0 +1,154 @@ +# materialize.nix — the edge materializer (spec §3c "materialization"). Given a +# context projection Π and the edges targeting a root, produce the root's +# per-class content. This is the single mechanism that replaces the phase-fold +# re-entries' final extraction; phase ordering becomes edge toposort (corollary +# 5) as later mechanisms are ported. +# +# This task (Task 7) exercises the `merge` mode only — the default-fold port. The +# `nest`/`nest-verbatim` arms throw an explicit "not yet ported" marker +# (Task 8); an explicit unreachable beats a silent wrong materialization. +# +# DESIGN INVARIANTS (spec §2 corollaries; enforced by the entity-isolation suite +# and the delivery-edges fixtures): +# - `materialize` contains the ONLY mode switch (merge | nest | nest-verbatim). +# - It carries NO mechanism vocabulary (no route/provides/spawn/instantiate +# names): mechanisms are dissolved into edges before they reach here. +# - It performs NO isolation-flag reads: isolation is consumed at edge +# CONSTRUCTION (corollary 2 — isolation is edge-absence). `assembleSubtree` +# resolves the subtree boundary (via scope-walk.subtreeScopes, governed by +# Π's EXPLICIT isolationMode) BEFORE handing merge edges to the switch, so +# the switch only walks an already-bounded scope list. +{ lib, ... }: +let + inherit (import ../scope-walk.nix { inherit lib; }) subtreeScopes dedupByKey; +in +rec { + # The Π(root) record shape (§A, Task 1 census). Per-field provenance cites the + # census verdict that constrains it; fields not yet consumed by THIS task's + # default-fold port still belong in the record because Tasks 8–11 consume them + # (the variants become visible data instead of implicit state-threading). + # + # Π(root) = { + # scopeContexts; # §9 subtree-only context slice. NOT consumed by the + # # default-fold merge (which reads perScope buckets + # # directly); routes/provides/synthesize materialize + # # against it (Tasks 8–9). + # contextsAreAugmented; # §8 DELIBERATE (cycle-forced) — B′ gets raw contexts. + # # Carried so a unified assembleSubtree knows which it + # # got. Not consumed this task. + # classImports; # §2 the collected class buckets, per-scope (perScope). + # # The default-fold merge SOURCE. TARGET semantics = + # # drained (Task 11 owns the B′ baseDrain ACCIDENT); + # # the Π builder must NOT enshrine raw as B′'s contract. + # provides; # §9 subtree+ancestors; §3 spawn's own suffices. + # # Not consumed this task (provides port = Task 9). + # routes; # §9 subtree+ancestors; §4 parent-subtree routes merge + # # into a spawn. Not consumed this task (route = Task 8). + # rootScopeId; # §5 DELIBERATE — the subtree root (pipeline root | + # # hostScopeId | spawnRoot). The merge target's root. + # scopeParent; # the parent DAG slice (subtree/ancestor walks). + # scopeIsolated; # §6/§10 — the isolation marks. Consulted at EXTRACTION + # # via subtreeScopes, governed by isolationMode; never + # # read inside the mode switch. + # isolationMode; # §6 `aware` (default) | `blind` (spawn final extraction + # # invariant). EXPLICIT — never defaulted. + # classInject ? null; # §1 the resolved entity class to inject into context + # # args; no observable witness — defensive projection, + # # default off. Not consumed this task. + # } + + # Resolve Π's isolation marks into the `isolated` set the subtree walk takes, + # governed by the EXPLICIT isolationMode (§A: "pass it EXPLICITLY, never by + # defaulting"). blind ⇒ {} (the spawn final-extraction invariant); aware ⇒ the + # scope isolation marks. This is the ONLY place an isolation mark is consulted, + # and it happens at CONSTRUCTION (assembleSubtree), not inside the switch. + isolatedSetOf = + pi: + if pi.isolationMode == "blind" then + { } + else if pi.isolationMode == "aware" then + pi.scopeIsolated + else + throw "den materialize: isolationMode must be \"aware\" | \"blind\", got ${builtins.toJSON pi.isolationMode}"; + + # Collect the merge source for a (root, class) target: the class bucket of the + # already-bounded subtree, key-deduped first-occurrence-wins. This is exactly + # the wrapPerScope cross-scope dedup + extractSubtreeModules semantics + # (resolve.nix), now expressed as the merge-mode materialization rule. + # perScope — sid → { class → [ modules ] } (the wrapped buckets). + # subtreeScopeIds — the resolved, isolation-bounded scope list. + collectMerge = + perScope: subtreeScopeIds: cls: + let + raw = lib.concatMap (sid: perScope.${sid}.${cls} or [ ]) subtreeScopeIds; + in + dedupByKey (m: m.key or null) raw; + + # materialize: Π + an edge list → { class → [ modules ] }. The ONLY mode + # switch. This task exercises `merge`; nest arms are explicit unreachables + # (Task 8). `perScope` and the resolved subtree are passed via the closure + # `ctx` so the switch stays a pure per-edge dispatch. + materialize = + pi: ctx: edges: + let + step = + acc: edge: + let + cls = edge.target.class; + in + if edge.mode == "merge" then + # merge: key-deduped module-list union of the bounded subtree's bucket. + acc + // { + ${cls} = (acc.${cls} or [ ]) ++ collectMerge ctx.perScope ctx.subtreeScopeIds cls; + } + else if edge.mode == "nest" then + throw "den materialize: mode \"nest\" not yet ported (TODO(Task 8))" + else if edge.mode == "nest-verbatim" then + throw "den materialize: mode \"nest-verbatim\" not yet ported (TODO(Task 8))" + else + throw "den materialize: unknown mode ${builtins.toJSON edge.mode}"; + in + builtins.foldl' step { } edges; + + # assembleSubtree: materialize all edges targeting `root`. For the default-fold + # port this is the per-root final extraction that replaces extractSubtreeModules + # — the merge-mode materialization of the root's own default-fold edges. + # + # The subtree boundary (isolation) is resolved HERE (construction time, + # corollary 2), producing the bounded scope list the merge switch walks. The + # mode switch never sees an isolation flag. + # + # root — the root scope id whose content is being assembled. + # pi — the Π(root) projection (shape above). + # Returns { class → [ modules ] }; a class with no content is absent (callers + # treat absence as null, matching extractSubtreeModules' `== [] then null`). + assembleSubtree = + { root, pi }: + let + allScopeIds = builtins.attrNames pi.perScope; + subtreeScopeIds = subtreeScopes { + inherit (pi) scopeParent; + isolated = isolatedSetOf pi; + inherit root allScopeIds; + }; + # Default-fold edges for THIS root: one merge edge per class with content + # in the bounded subtree (corollary 1). Constructed inline against the + # bounded scope list — the same edge edges/default.nix and edge-trace.nix + # describe, reduced to what the merge materialization consumes (T.class). + classesWithContent = lib.unique ( + builtins.concatMap (sid: builtins.attrNames (pi.perScope.${sid} or { })) subtreeScopeIds + ); + edges = map (cls: { + target = { + inherit root; + class = cls; + }; + mode = "merge"; + }) classesWithContent; + in + materialize pi { + inherit (pi) perScope; + inherit subtreeScopeIds; + } edges; +} diff --git a/nix/lib/aspects/fx/resolve.nix b/nix/lib/aspects/fx/resolve.nix index f312f2663..70a0ed7e0 100644 --- a/nix/lib/aspects/fx/resolve.nix +++ b/nix/lib/aspects/fx/resolve.nix @@ -12,6 +12,7 @@ let route = import ./route { inherit lib den; }; inherit (import ./edge-trace.nix { inherit lib den; }) extractEdgeTrace; inherit (import ./scope-walk.nix { inherit lib; }) subtreeScopes dedupByKey; + inherit (import ./edges/materialize.nix { inherit lib; }) assembleSubtree; handlers = den.lib.aspects.fx.handlers; # Check if `ancestor` is an ancestor of `descendant` in the scopeParent tree. @@ -163,29 +164,10 @@ let else null; - # Extract merged modules for a scope subtree (the scope + all descendants). - # This produces the complete module set for a host: host-scope modules, - # user-scope modules, and route-delivered modules — all in one list. - extractSubtreeModules = - perScope: scopeParent: scopeIsolated: rootScopeId: targetClass: - let - # Isolation-AWARE walk: skip isolated descendants (and everything below - # them). The collection root is always included: isolation gates crossing - # INTO an entity, not collecting AT it. - scopes = subtreeScopes { - inherit scopeParent; - isolated = scopeIsolated; - root = rootScopeId; - allScopeIds = builtins.attrNames perScope; - }; - # Collect modules from all subtree scopes, deduplicating by key. - # Same aspect included at multiple scope levels (host default + user default) - # produces identical static modules; first occurrence wins. - # Named modules carry `key`; anon modules carry `_file` from setDefaultModuleLocation. - raw = lib.concatMap (sid: perScope.${sid}.${targetClass} or [ ]) scopes; - deduped = dedupByKey (m: m.key or null) raw; - in - if deduped == [ ] then null else deduped; + # The per-host subtree extraction that produced the complete module set for a + # host (host-scope + user-scope + route-delivered modules, key-deduped) now + # routes through the edge materializer's merge mode (edges/materialize.nix + # assembleSubtree) — the default-fold port (Task 7). See mkInstantiateArgs. # Build instantiateArgs for a spec without calling spec.instantiate. # Factored out so both applyInstantiates and hostConfigs can reuse it. @@ -252,8 +234,34 @@ let subtreePhase3 = applyRoutes spawnNodeFn ctx relevantContexts hostScopeId scopeParent scopeIsolated subtreeRoutes subtreePhase2; + # Default-fold port (Task 7): the per-host final extraction routes + # through the edge materializer. This re-entry (census variant B) + # constructs an EXPLICIT Π(root) record — the variant becomes visible + # data instead of implicit state-threading. assembleSubtree resolves + # the isolation-AWARE subtree boundary (isolationMode = "aware") and + # merge-materializes the host class bucket (the wrapPerScope/ + # extractSubtreeModules merge semantics). Fields not consumed by the + # default-fold merge (contexts/provides/routes) are carried for the + # per-port absorption of this re-entry (Tasks 8–11). + pi = { + perScope = subtreePhase3.perScope; + classImports = subtreePhase3.classImports; + scopeContexts = relevantContexts; + contextsAreAugmented = true; + provides = subtreeProvides; + routes = subtreeRoutes; + rootScopeId = hostScopeId; + inherit scopeParent scopeIsolated; + isolationMode = "aware"; + classInject = null; + }; + assembled = assembleSubtree { + root = hostScopeId; + inherit pi; + }; + hostModules = assembled.${hostClass} or [ ]; in - extractSubtreeModules subtreePhase3.perScope scopeParent scopeIsolated hostScopeId hostClass + if hostModules == [ ] then null else hostModules else null; modules = From c222774d95715e6c0d347c1e742645e5ee6924e1 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 12 Jun 2026 17:55:10 -0700 Subject: [PATCH 048/101] fix(fx): assembleSubtree consumes shared default-fold constructor; pi.scopeContexts subtree-only --- nix/lib/aspects/fx/edges/materialize.nix | 43 +++++++++++++++--------- nix/lib/aspects/fx/resolve.nix | 7 +++- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/nix/lib/aspects/fx/edges/materialize.nix b/nix/lib/aspects/fx/edges/materialize.nix index 92e94b8d2..b66fa5040 100644 --- a/nix/lib/aspects/fx/edges/materialize.nix +++ b/nix/lib/aspects/fx/edges/materialize.nix @@ -21,6 +21,7 @@ { lib, ... }: let inherit (import ../scope-walk.nix { inherit lib; }) subtreeScopes dedupByKey; + inherit (import ./default.nix { inherit lib; }) defaultFoldEdges; in rec { # The Π(root) record shape (§A, Task 1 census). Per-field provenance cites the @@ -127,25 +128,35 @@ rec { { root, pi }: let allScopeIds = builtins.attrNames pi.perScope; + # The isolation set the subtree boundary uses, governed by pi.isolationMode. + # Computed ONCE here so the merge-collection walk and the edge constructor's + # own internal subtree walk agree on the boundary. + isolated = isolatedSetOf pi; subtreeScopeIds = subtreeScopes { inherit (pi) scopeParent; - isolated = isolatedSetOf pi; - inherit root allScopeIds; + inherit isolated root allScopeIds; + }; + # Default-fold edges for THIS root, built by the SHARED constructor + # (edges/default.nix defaultFoldEdges) — the same function the read-only + # oracle (edge-trace.nix) consumes, so production and oracle agree on the + # CONSTRUCTOR, not merely the primitives (spec §3a convergence). + # Adaptation to assembleSubtree's per-root, isolationMode-governed call: + # - entityRootScopes = [ root ] (this single root; isolated descendants + # are their own roots, materialized by their own assembleSubtree). + # - scopeIsolated = `isolated` (already resolved through isolationMode, so + # the constructor's internal subtree walk matches subtreeScopeIds). + # - classContentAt = pi.perScope (only `? class` membership is read). + # - name = identity: the merge switch reads ONLY edge.target.class, so the + # T.root naming used by the oracle is irrelevant here; identity keeps the + # emitted target.root = the raw sid, unchanged from the prior inline form. + edges = defaultFoldEdges { + name = sid: sid; + inherit (pi) scopeParent; + scopeIsolated = isolated; + classContentAt = pi.perScope; + inherit allScopeIds; + entityRootScopes = [ root ]; }; - # Default-fold edges for THIS root: one merge edge per class with content - # in the bounded subtree (corollary 1). Constructed inline against the - # bounded scope list — the same edge edges/default.nix and edge-trace.nix - # describe, reduced to what the merge materialization consumes (T.class). - classesWithContent = lib.unique ( - builtins.concatMap (sid: builtins.attrNames (pi.perScope.${sid} or { })) subtreeScopeIds - ); - edges = map (cls: { - target = { - inherit root; - class = cls; - }; - mode = "merge"; - }) classesWithContent; in materialize pi { inherit (pi) perScope; diff --git a/nix/lib/aspects/fx/resolve.nix b/nix/lib/aspects/fx/resolve.nix index 70a0ed7e0..a3050c549 100644 --- a/nix/lib/aspects/fx/resolve.nix +++ b/nix/lib/aspects/fx/resolve.nix @@ -246,7 +246,12 @@ let pi = { perScope = subtreePhase3.perScope; classImports = subtreePhase3.classImports; - scopeContexts = relevantContexts; + # §A #9: scopeContexts is the subtree-ONLY context slice (NOT + # subtree+ancestors). provides/routes are the wider subtree+ancestor + # fields. Inert for the default-fold merge (which reads perScope), + # but the correct scope-set for the Tasks 8/9 materializers that + # consume pi.scopeContexts. + scopeContexts = subtreeContexts; contextsAreAugmented = true; provides = subtreeProvides; routes = subtreeRoutes; From 04f4e5f69a31086818a29bd658caf1f20a6e215c Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 12 Jun 2026 18:11:28 -0700 Subject: [PATCH 049/101] feat(fx): simple routes as delivery edges; triple-pass dissolved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port applySimpleRoute's four output arms (wrapRouteModules, ensureEntry, adapterWrapped, instantiateWrapped) onto the delivery-edge materializer per the §B matrix. The nest/nest-verbatim/merge mode mechanics (nestPlain, nestWithAdaptArgs, nestVerbatim, guardModule, the #572 single-eval combine, ensureTargetPath) move from route/wrap.nix into edges/route.nix as the materializer's route-edge mode switch (materializeRouteEdge in edges/materialize.nix). The route triple-pass dissolves into edge construction: dedupRoutes' two suppressions (adapterKey@scope identity dedup + redundant-root edge-set shadowing) and topoSortRoutes' producer→consumer ordering become edges/route.nix's suppressionVerdicts + a general index-based edge toposort with a loud cycle throw (§B Decision 5; no cycle reachable today, so byte- stable with the old noDeps-before-withDeps partition). The read-only oracle (edge-trace.nix) now renders simple+complex route edges through the SAME routeEdges constructor production materializes through, so the suppressed annotations are exact (constructor's own dedup rules), not the v0 path-dependent approximation. sourceVia for complex forwards stays unresolved (Task 9). Complex (__complexForward) routes stay inline in route/apply.nix (filterRootModules / getCollectedSource / resolveSourceFallback), unchanged — Task 9 ports them. wrap.nix reduces to collectClassMods (complex-forward only). Deleted as superseded: wrapRouteModules, nestPlain, nestWithAdaptArgs, nestVerbatim, nestModule, guardModule, adaptModule, the monolithic applySimpleRoute body, the old collectFromSubtree/dedupRoutes/topoSortRoutes/ findChildScopeKeys in route/apply.nix, and the dead dedupRoutes/ findChildScopeKeys re-exports. delivery-edges 14/14 byte-stable; route 7/7; full CI 961/961. --- nix/lib/aspects/fx/edge-trace.nix | 156 +----- nix/lib/aspects/fx/edges/materialize.nix | 56 ++- nix/lib/aspects/fx/edges/route.nix | 575 +++++++++++++++++++++++ nix/lib/aspects/fx/route/apply.nix | 314 +++---------- nix/lib/aspects/fx/route/default.nix | 18 +- nix/lib/aspects/fx/route/wrap.nix | 189 +------- 6 files changed, 736 insertions(+), 572 deletions(-) create mode 100644 nix/lib/aspects/fx/edges/route.nix diff --git a/nix/lib/aspects/fx/edge-trace.nix b/nix/lib/aspects/fx/edge-trace.nix index 7f80a38d8..366ff44b2 100644 --- a/nix/lib/aspects/fx/edge-trace.nix +++ b/nix/lib/aspects/fx/edge-trace.nix @@ -24,12 +24,8 @@ # Trace normalization (spec §8): sort key (T, P, S, M); entity scopes named by # id_hash (parent-blind identity), non-entity scopes by their mkScopeId string; # rewalk/synthesize edges record the identity triple, NOT resolved content. -{ lib, den }: +{ lib, ... }: let - # Reuse the ACTUAL route suppression logic (not a reimplementation): the - # point of the extractor is to render the decisions the current code makes. - route = import ./route { inherit lib den; }; - inherit (route) dedupRoutes findChildScopeKeys; # The shared edge record, sort key, scope-naming, and S/T constructors — the # ONE edge definition production (edges/default.nix) and this oracle share, so # they can never diverge (spec §3a convergence). EXTRACTED to edges/edge.nix. @@ -49,6 +45,14 @@ let # fold arm is REPLACED by this import so extractor and production converge on # one constructor (spec §3a). inherit (import ./edges/default.nix { inherit lib; }) defaultFoldEdges; + # The route edge constructor — the SAME constructor production materializes + # simple routes through (route/apply.nix → edges/materialize.nix). v0's inline + # route arm + its own dedup/suppression re-derivation is REPLACED by this + # import: the oracle and production now converge on ONE route constructor + # (spec §3a). The `suppressed` annotations are now EXACT (the constructor's own + # dedup rules), not the v0 path-dependent approximation; `sourceVia` for complex + # forwards stays "unresolved" (Task 9). + inherit (import ./edges/route.nix { inherit lib; }) routeEdges; in { # extractEdgeTrace: pipeline end-state → stably-sorted normalized edge list. @@ -133,132 +137,22 @@ in ) dedupedProvides; # ===== route edges ================================================= - # From scopedRoutes specs (post the ACTUAL dedupRoutes — reused, not - # reimplemented). Simple routes and complex (synthesize) forwards. + # Rendered by the SHARED route constructor (edges/route.nix routeEdges) — + # the SAME constructor production materializes simple routes through + # (route/apply.nix → edges/materialize.nix). The oracle no longer re-derives + # suppression: the constructor's own dedup/suppression rules are EXACT here + # (the `suppressed`/`suppressedByChildKey` annotations are the production + # decisions, not the v0 approximation). Complex forwards keep + # `sourceVia = "unresolved"` (Task 9). rawRoutes = builtins.concatLists (lib.attrValues scopedRoutes); - # The suppression decisions: which adapterKey@scope routes dedupRoutes - # keeps, and which child keys shadow root-scope adapter routes. Reuse the - # real functions over the SAME rootScopeId the pipeline used. - keptRoutes = dedupRoutes rootScopeId rawRoutes; - childKeys = findChildScopeKeys rootScopeId rawRoutes; - # Per-position suppression verdicts. A rawRoute is suppressed iff - # dedupRoutes (the ACTUAL logic, reused — not reimplemented) dropped it, - # i.e. it is not the kept instance of its identity. keptRoutes preserves - # original order and keeps the FIRST instance per identity, so a verdict is - # derived by consuming keptRoutes positionally as rawRoutes are walked: the - # head of keptRoutes is the kept route until matched, then advances. - # `byChild` (redundant-root shadow, §B rule 2) is recomputed from the same - # findChildScopeKeys output dedupRoutes consumes. - suppressVerdicts = - let - go = - kept: routes: - if routes == [ ] then - [ ] - else - let - r = builtins.head routes; - rest = builtins.tail routes; - # Identity assumption: dedupRoutes preserves original order and - # returns the SAME route records (by reference) it kept, so the - # head-of-kept structural `==` here is really reference identity — - # two distinct rawRoutes are never structurally equal in practice - # (each carries a distinct sourceScopeId/path). If dedupRoutes ever - # rebuilds records, switch this to a stable adapterKey@scope match. - isKept = kept != [ ] && builtins.head kept == r; - ak = r.adapterKey or null; - # Redundant-root shadow: an adapter route AT the root scope whose - # adapterKey also exists at a child scope (findChildScopeKeys). - byChild = ak != null && rootScopeId != null && r.sourceScopeId == rootScopeId && childKeys ? ${ak}; - verdict = { - suppressed = !isKept; - inherit byChild; - }; - in - [ verdict ] ++ go (if isKept then builtins.tail kept else kept) rest; - in - go keptRoutes rawRoutes; - - # A forward identity triple component (§B Decision 2): adapterKey if - # present (the dynamic-P adapter arm, cell 6), else a structural composite. - forwardId = - spec: - spec.adapterKey or "${spec.fromClass}>${spec.intoClass}@${spec.sourceScopeId}/${ - lib.concatStringsSep "/" (spec.staticIntoPath or spec.path or [ ]) - }"; - - routeEdge = - verdict: spec: - let - sid = spec.sourceScopeId; - isComplex = spec.__complexForward or false; - path = spec.path or spec.staticIntoPath or [ ]; - appendToParent = spec.appendToParent or false; - appendSid = if appendToParent then scopeParent.${sid} or sid else sid; - adapterKey = spec.adapterKey or null; - reinstantiate = spec.reinstantiate or false; - # Suppression verdict for this position (path-dependent — depends on - # the SET of routes present, §B path-dependent suppression rule). - # Recorded as an annotation until the route port (spec §3a). - isSuppressed = verdict.suppressed; - suppressedByChild = verdict.byChild; - - baseAnnotations = - lib.optionalAttrs (spec.adaptArgs or null != null) { adaptArgs = true; } - // lib.optionalAttrs (spec.guard or null != null) { guard = true; } - // lib.optionalAttrs (spec.collectSubtree or false) { collectSubtree = true; } - // lib.optionalAttrs ((spec.intoClass or null) == "flake") { isFlakeRoute = true; } - // lib.optionalAttrs ((spec.instantiate or null) != null) { instantiate = true; } - // lib.optionalAttrs appendToParent { appendToParent = true; } - // lib.optionalAttrs ( - # §B cell 5: ensureEntry placeholder (empty target path materialized). - # (content-blind approx; real ensureEntry also requires empty module - # set — converges Phase 2) - !isComplex && (spec.intoClass or null) != "flake" && (spec.adaptArgs or null) != null && path != [ ] - ) { ensureTargetPath = true; } - // lib.optionalAttrs isSuppressed { suppressed = true; } - // lib.optionalAttrs suppressedByChild { suppressedByChildKey = adapterKey; }; - in - if isComplex then - # Complex forward → synthesize edge. Identity triple only (no content, - # spec §8). sourceVia is path-dependent (getCollectedSource's collected- - # else-rewalk branch depends on the assembled perScope, which v0 does - # not reconstruct) — recorded as the approximate annotation - # "unresolved" per the Task-3 brief. - mkEdge { - source = synthesize (forwardId spec) spec.fromClass spec.intoClass; - target = rootTarget (name appendSid) spec.intoClass; - inherit path; - mode = "nest"; - annotations = baseAnnotations // { - complexForward = true; - sourceVia = "unresolved"; - }; - } - else - mkEdge { - source = collected (name sid) spec.fromClass; - target = rootTarget (name appendSid) spec.intoClass; - inherit path; - # §B Decision 4: reinstantiate ⇒ nest-verbatim; P=[] ⇒ merge; - # else nest. Adapter routes (cell 6) carry dynamic P — annotated. - mode = - if reinstantiate then - "nest-verbatim" - else if path == [ ] then - "merge" - else - "nest"; - annotations = - baseAnnotations - // lib.optionalAttrs (adapterKey != null) { - inherit adapterKey; - # §B cell 6: adapter arm resolves P dynamically at evalModules - # time via intoPathFn — P is not a static edge field. - dynamicPath = true; - }; - }; - routeEdges = lib.imap0 (i: spec: routeEdge (builtins.elemAt suppressVerdicts i) spec) rawRoutes; + routeEdgeList = routeEdges { + inherit + name + scopeParent + rootScopeId + rawRoutes + ; + }; # ===== spawn (rewalk) edges ======================================== # scopedSpawns: each marker lives at an OWN entity scope (ownKind); the @@ -376,7 +270,7 @@ in ) instGrouped ); - allEdges = defaultFold ++ providesEdges ++ routeEdges ++ spawnEdges ++ instantiateEdges; + allEdges = defaultFold ++ providesEdges ++ routeEdgeList ++ spawnEdges ++ instantiateEdges; in sortEdges allEdges; } diff --git a/nix/lib/aspects/fx/edges/materialize.nix b/nix/lib/aspects/fx/edges/materialize.nix index b66fa5040..f675dfba7 100644 --- a/nix/lib/aspects/fx/edges/materialize.nix +++ b/nix/lib/aspects/fx/edges/materialize.nix @@ -22,6 +22,7 @@ let inherit (import ../scope-walk.nix { inherit lib; }) subtreeScopes dedupByKey; inherit (import ./default.nix { inherit lib; }) defaultFoldEdges; + inherit (import ./route.nix { inherit lib; }) materializeNest mkAdapterFunctor; in rec { # The Π(root) record shape (§A, Task 1 census). Per-field provenance cites the @@ -85,10 +86,53 @@ rec { in dedupByKey (m: m.key or null) raw; + # materializeRouteEdge: the §B nest/nest-verbatim/merge placement for ONE + # simple-route edge carrying its already-resolved source modules + materializer + # properties → the wrapped module list to land in the target bucket. This is + # the ONLY mode switch for route delivery; the route fold (route/apply.nix) + # routes EVERY simple route through here so nest/nest-verbatim/merge placement + # is decided in one place (spec §3c materialization). + # + # The edge carries a `materialize` payload: + # { modules; path; mode; adaptArgs; guard; reinstantiate; ensureTargetPath; + # adapterKey; adapterRoute; sourceModules; instantiateEvaluated; } + # `adapterKey`/`instantiate` arms replace the module list outright (cells 6/7); + # the remaining cells (1–5) go through materializeNest (nest | nest-verbatim | + # merge contribution at P=[], + the #572 combine + ensureTargetPath). + materializeRouteEdge = + m: + if m.kind == "instantiate" then + # cell 7: eager instantiate evaluated at materialization, placed at P. + if m.sourceModules == [ ] then + [ ] + else + [ { config = lib.setAttrByPath m.path m.instantiateEvaluated; } ] + else if m.kind == "ensure-empty" then + # cell 5 with empty source and ensureTargetPath: land an empty attrset at P. + lib.optional m.ensureTargetPath { config = lib.setAttrByPath m.path { }; } + else if m.kind == "adapter" then + # cell 6: the adapter functor module (dynamic P resolved at evalModules). + lib.optional (m.modules != [ ] || m.adapterPresent) ( + mkAdapterFunctor m.adapterRoute m.sourceModules + ) + else + # cells 1–4: nest | nest-verbatim | merge contribution (P=[]), + #572. + materializeNest { + inherit (m) + modules + path + guard + adaptArgs + reinstantiate + ensureTargetPath + ; + }; + # materialize: Π + an edge list → { class → [ modules ] }. The ONLY mode - # switch. This task exercises `merge`; nest arms are explicit unreachables - # (Task 8). `perScope` and the resolved subtree are passed via the closure - # `ctx` so the switch stays a pure per-edge dispatch. + # switch for default-fold extraction. Routes/provides/spawn fold through their + # own entry (route/apply.nix → materializeRouteEdge) until their phase folds are + # absorbed (Tasks 9–11); the merge arm here is the per-root final extraction. + # `perScope` and the resolved subtree are passed via the closure `ctx`. materialize = pi: ctx: edges: let @@ -103,12 +147,8 @@ rec { // { ${cls} = (acc.${cls} or [ ]) ++ collectMerge ctx.perScope ctx.subtreeScopeIds cls; } - else if edge.mode == "nest" then - throw "den materialize: mode \"nest\" not yet ported (TODO(Task 8))" - else if edge.mode == "nest-verbatim" then - throw "den materialize: mode \"nest-verbatim\" not yet ported (TODO(Task 8))" else - throw "den materialize: unknown mode ${builtins.toJSON edge.mode}"; + throw "den materialize: assembleSubtree edge mode ${builtins.toJSON edge.mode} (nest/nest-verbatim route delivery folds through route/apply.nix:materializeRouteEdge, not assembleSubtree, until Tasks 9–11)"; in builtins.foldl' step { } edges; diff --git a/nix/lib/aspects/fx/edges/route.nix b/nix/lib/aspects/fx/edges/route.nix new file mode 100644 index 000000000..4e8b0352d --- /dev/null +++ b/nix/lib/aspects/fx/edges/route.nix @@ -0,0 +1,575 @@ +# route.nix — the simple-route edge constructor (spec §3c "edge collection", +# §B Decision 4 matrix). A `scopedRoutes` simple-route spec becomes a delivery +# edge per the §B 10-cell reachable matrix; the edge's MODE (merge | nest | +# nest-verbatim) and edge PROPERTIES (adaptArgs, guard, combineSingleEval, +# ensureTargetPath, adapterKey, instantiate, collectSubtree, to=parent) drive +# the materializer's mode switch (edges/materialize.nix). No new modes — the §B +# hybrids decompose into mode + properties. +# +# This file owns the SIMPLE-route half only. Complex (__complexForward) routes +# are dispatched by route/apply.nix:applyComplexRoute (Task 9, synthesize edges) +# and never reach here. +# +# Two projections share ONE classification (classifyRoute): +# - the trace-facing edge RECORD (identity + annotations, no content) consumed +# by the read-only oracle (edge-trace.nix) — §8 records identity, not content; +# - the MATERIALIZATION (the actual wrapped module list + target scope) consumed +# by route/apply.nix's fold, replacing applySimpleRoute. +# Both derive from the same per-route cell decision, so oracle and production can +# never disagree on which §B cell a route is. +# +# Ordering + dedup (§B Decision 5 + dedupRoutes suppressions) live HERE, at edge +# construction: dedupRoutes' two suppressions (adapterKey@scope identity dedup; +# redundant-root edge-set shadowing) and topoSortRoutes' producer→consumer order +# (now a general edge toposort with loud cycle throw). +{ lib, ... }: +let + inherit (import ./edge.nix { inherit lib; }) + mkEdge + collected + synthesize + rootTarget + ; + inherit (import ../scope-walk.nix { inherit lib; }) subtreeScopes; + + # ===== materialization mechanics (ported from route/wrap.nix) ========== + # These are MODE mechanics (how `nest`/`nest-verbatim` place a module at P), + # not mechanism vocabulary. They are invoked by the materializer's mode switch + # via the closure each edge carries. + + # Freeform type for route nesting evalModules: merges like NixOS (attrsets + # deep-merge, lists concatenate) but errors on conflicting scalar/derivation + # values instead of silently clobbering. + mergeableType = lib.mkOptionType { + name = "mergeable"; + description = "auto-merged value (attrsets merge, lists concatenate, scalars conflict)"; + merge = + loc: defs: + let + values = map (d: d.value) defs; + first = builtins.head values; + allLists = builtins.all builtins.isList values; + allMergeableAttrs = builtins.all (v: builtins.isAttrs v && !(lib.isDerivation v)) values; + in + if builtins.length defs == 1 then + first + else if allLists then + builtins.concatLists values + else if allMergeableAttrs then + (lib.types.lazyAttrsOf mergeableType).merge loc defs + else + throw "den: the option `${lib.showOption loc}' has conflicting definitions from multiple aspects"; + }; + nestingFreeformType = lib.types.lazyAttrsOf mergeableType; + + # Adapt a module's args when path is empty (top-level adaptArgs). + adaptModule = + adaptArgs: path: mod: + if adaptArgs == null || path != [ ] then + mod + else if builtins.isFunction mod then + args: mod (adaptArgs args) + else + mod; + + # Nest a module at a path using submodule evaluation with adapted specialArgs. + nestWithAdaptArgs = + path: adaptArgs: mod: args: + let + fullArgs = args // (args.config._module.args or { }); + adapted = adaptArgs fullArgs; + sourceModules = if builtins.isAttrs mod && mod ? imports then mod.imports else [ mod ]; + evaluated = lib.evalModules { + specialArgs = adapted; + modules = [ + { config._module.freeformType = nestingFreeformType; } + ] + ++ sourceModules; + }; + in + { + config = lib.setAttrByPath path ( + builtins.removeAttrs evaluated.config [ + "_module" + "warnings" + "assertions" + ] + ); + }; + + # Nest a module at a path by evaluating imports with full outer args. + nestPlain = + path: mod: args: + let + fullArgs = args // (args.config._module.args or { }); + resolveImport = imp: if builtins.isFunction imp then imp fullArgs else imp; + sourceModules = if builtins.isAttrs mod && mod ? imports then mod.imports else [ mod ]; + resolved = map resolveImport sourceModules; + evaluated = lib.evalModules { + specialArgs = fullArgs; + modules = [ + { config._module.freeformType = nestingFreeformType; } + ] + ++ resolved; + }; + in + { + config = lib.setAttrByPath path ( + builtins.removeAttrs evaluated.config [ + "_module" + "warnings" + "assertions" + ] + ); + }; + + # Nest a module at a path BY REFERENCE, keeping the collected wrapper INTACT + # (key/_file preserved) — required when the target re-instantiates the + # delivered content as its own NixOS system (nest-verbatim mode). + nestVerbatim = path: mod: { + config = lib.setAttrByPath path { imports = [ mod ]; }; + }; + + # Wrap a module with a conditional guard. A bool guard gates content with + # optionalAttrs (not mkIf): a false guard contributes NOTHING. A structural + # module (imports/_file but no flat config) recurses, gating each leaf's config. + guardModule = + guard: mod: + if guard == null then + mod + else + let + guardOne = + node: args: + let + inner = if builtins.isFunction node then node args else node; + in + if inner ? imports && !(inner ? config) then + { imports = map guardOne inner.imports; } // builtins.removeAttrs inner [ "imports" ] + else + { config = lib.optionalAttrs (guard args) (inner.config or inner); }; + in + guardOne mod; + + # The §B nest/nest-verbatim placement for ONE collected module at P. + # reinstantiate ⇒ nest-verbatim; adaptArgs (path≠[]) ⇒ nestWithAdaptArgs; + # else nestPlain. P=[] returns the module unchanged (merge contribution). + placeOne = + { + path, + adaptArgs, + reinstantiate, + guard, + }: + mod: + let + placed = + if path == [ ] then + mod + else if reinstantiate then + nestVerbatim path mod + else if adaptArgs != null then + nestWithAdaptArgs path adaptArgs mod + else + nestPlain path mod; + in + guardModule guard placed; + + # The §B materialization for a simple route's collected source modules → the + # wrapped module list landed in the target bucket. Implements the §B cells: + # - empty source: ensureTargetPath (cell 5) or no edge. + # - #572 combine (cell 3): adaptArgs≠null ∧ path≠[] ∧ ¬reinstantiate ⇒ ONE + # nestWithAdaptArgs over { imports = adapted; } (all modules in one + # evalModules so same-class aspects merge inside it). + # - otherwise (cells 1/2/4): per-module placement. + # adapterKey (cell 6) and instantiate (cell 7) are handled by the constructor + # BEFORE this (they replace the source-module list outright); this function + # sees only the wrapRouteModules path. + materializeNest = + { + modules, + path, + guard ? null, + adaptArgs ? null, + reinstantiate ? false, + ensureTargetPath ? false, + }: + let + adapted = map (adaptModule adaptArgs path) modules; + in + if adapted == [ ] then + # cell 5: ensureTargetPath materializes an empty attrset at P so the option + # lands; otherwise no module. + lib.optional ensureTargetPath { config = lib.setAttrByPath path { }; } + else if adaptArgs != null && path != [ ] && !reinstantiate then + # cell 3 (#572): ONE combined evalModules. + [ + (guardModule guard (nestWithAdaptArgs path adaptArgs { imports = adapted; })) + ] + else + map (placeOne { + inherit + path + adaptArgs + reinstantiate + guard + ; + }) adapted; + + # ===== adapter functor (§B cell 6 — dynamic P) ========================= + # The adapter arm's P is DYNAMIC: resolved at evalModules time via intoPathFn + # args, not at construction. The edge carries the functor recipe; the trace + # records P as the static route.path with dynamicPath=true. + mkAdapterFunctor = + route: sourceModules: + let + adapterMod = route.adapterModule or null; + sourceModule = { + imports = sourceModules; + }; + guardFn = route.guard or (_: lib.id); + adaptArgsFn = route.adaptArgs or (_: { }); + intoPathFn = route.intoPathFn or (_: route.path); + key = route.adapterKey; + guardArgs = route.guardArgs or { }; + intoPathArgs = route.intoPathArgs or { }; + adaptArgv = route.adaptArgv or { }; + freeformMod = + route.freeformMod or { + config._module.freeformType = lib.types.lazyAttrsOf lib.types.unspecified; + }; + adapterMods = + if adapterMod != null then + [ + freeformMod + adapterMod + ] + else + [ freeformMod ]; + in + { + __functionArgs = guardArgs // intoPathArgs // adaptArgv; + __functor = _: args: { + options.den.fwd.${key} = lib.mkOption { + defaultText = lib.literalExpression "{ }"; + default = { }; + type = lib.types.submoduleWith { + specialArgs = adaptArgsFn args; + modules = adapterMods ++ [ sourceModule ]; + }; + }; + config = guardFn args (lib.setAttrByPath (intoPathFn args) args.config.den.fwd.${key}); + }; + }; + + # ===== source collection (§B cell 9 — collectSubtree / isFlakeRoute) === + # Collect class modules from a scope and all descendants, skipping isolated + # descendants (isolation-AWARE; collection root always included). The plain + # case collects from the route's own scope only. + collectFromSubtree = + wrappedPerScope: scopeParent: scopeIsolated: rootScopeId: fromClass: + let + scopes = subtreeScopes { + inherit scopeParent; + isolated = scopeIsolated; + root = rootScopeId; + allScopeIds = builtins.attrNames wrappedPerScope; + }; + in + lib.concatMap (sid: wrappedPerScope.${sid}.${fromClass} or [ ]) scopes; + + sourceModulesOf = + { + route, + wrappedPerScope, + scopeParent, + scopeIsolated, + }: + let + isFlakeRoute = route.intoClass == "flake"; + in + if isFlakeRoute || (route.collectSubtree or false) then + collectFromSubtree wrappedPerScope scopeParent scopeIsolated route.sourceScopeId route.fromClass + else if wrappedPerScope ? ${route.sourceScopeId} then + wrappedPerScope.${route.sourceScopeId}.${route.fromClass} or [ ] + else + [ ]; + + # ===== per-route classification (§B Decision 4 matrix) ================= + # The single cell decision both projections derive from. `mode` is the §B + # M; `kind` selects the materialization arm (wrapRouteModules | adapter | + # instantiate); `props` are the trace annotations / materializer properties. + classifyRoute = + route: + let + path = route.path or [ ]; + isFlakeRoute = route.intoClass == "flake"; + hasInstantiate = (route.instantiate or null) != null; + isAdapterRoute = (route.adapterKey or null) != null; + reinstantiate = route.reinstantiate or false; + adaptArgs = route.adaptArgs or null; + in + { + inherit + path + isFlakeRoute + hasInstantiate + isAdapterRoute + reinstantiate + adaptArgs + ; + # §B mode: reinstantiate ⇒ nest-verbatim; P=[] ⇒ merge; else nest. + mode = + if reinstantiate then + "nest-verbatim" + else if path == [ ] then + "merge" + else + "nest"; + appendToParent = route.appendToParent or false; + collectSubtree = route.collectSubtree or false; + adapterKey = route.adapterKey or null; + guard = route.guard or null; + }; + + # The target scope for a route's edge: appendToParent ⇒ the PARENT scope of + # sourceScopeId (the §B "to=parent" property, consumed at construction). + appendScopeIdOf = + scopeParent: route: + if route.appendToParent or false then + scopeParent.${route.sourceScopeId} or route.sourceScopeId + else + route.sourceScopeId; + + # ===== dedup + toposort (§B Decision 5 + dedupRoutes suppressions) ===== + + # adapterKeys that exist at child (non-root) scopes — the redundant-root shadow + # input. + findChildScopeKeys = + rootScopeId: rawRoutes: + builtins.foldl' ( + acc: r: + let + ak = r.adapterKey or null; + in + if ak != null && rootScopeId != null && r.sourceScopeId != rootScopeId then + acc // { ${ak} = true; } + else + acc + ) { } rawRoutes; + + # Per-position suppression verdict for each raw route, matching dedupRoutes' + # two rules (redundant-root shadow + adapterKey@scope first-wins). Returns a + # list aligned with rawRoutes: { suppressed; byChild; adapterKey; }. + suppressionVerdicts = + rootScopeId: rawRoutes: + let + childScopeKeys = findChildScopeKeys rootScopeId rawRoutes; + go = + seen: routes: + if routes == [ ] then + [ ] + else + let + r = builtins.head routes; + rest = builtins.tail routes; + ak = r.adapterKey or null; + # §B rule 2: redundant-root shadow — an adapter route AT the root + # whose adapterKey also exists at a child scope is dropped. + isRedundantRoot = + ak != null && rootScopeId != null && r.sourceScopeId == rootScopeId && childScopeKeys ? ${ak}; + # §B rule 1: same adapterKey@scope first-occurrence wins. + key = if ak != null then "${ak}@${r.sourceScopeId}" else null; + isDupKey = key != null && seen ? ${key}; + suppressed = isRedundantRoot || isDupKey; + verdict = { + inherit suppressed; + byChild = isRedundantRoot; + adapterKey = ak; + }; + nextSeen = if !suppressed && key != null then seen // { ${key} = true; } else seen; + in + [ verdict ] ++ go nextSeen rest; + in + go { } rawRoutes; + + # The kept (non-suppressed) routes in original order — the §B Decision 5 + # toposort input. + keptRoutes = + rootScopeId: rawRoutes: + let + verdicts = suppressionVerdicts rootScopeId rawRoutes; + in + builtins.concatLists ( + lib.imap0 (i: r: lib.optional (!(builtins.elemAt verdicts i).suppressed) r) rawRoutes + ); + + # General edge toposort (§B Decision 5): a producer (intoClass@scope) must fire + # before any consumer (fromClass@scope) that reads it. Today this is the + # single-level partition topoSortRoutes did (a complex forward depending on a + # producer of its fromClass), generalized to a real toposort with a LOUD cycle + # throw printing the edge chain. Simple routes read the original per-scope data + # (not the fold state), so they never participate as dependents. + topoSort = + routes: + let + n = builtins.length routes; + routeAt = i: builtins.elemAt routes i; + producerMap = builtins.foldl' ( + acc: i: + let + r = routeAt i; + key = "${r.intoClass}@${r.sourceScopeId}"; + in + acc // { ${key} = (acc.${key} or [ ]) ++ [ i ]; } + ) { } (lib.range 0 (n - 1)); + # Dependency indices of route i: producers of its fromClass@scope OTHER than + # itself, but only when i is a complex forward (simple routes read the + # original per-scope data, never the fold state, so never depend). + depsOf = + i: + let + r = routeAt i; + in + if (r.__complexForward or false) then + builtins.filter (j: j != i) (producerMap."${r.fromClass}@${r.sourceScopeId}" or [ ]) + else + [ ]; + labelOf = + i: + let + r = routeAt i; + in + "${r.fromClass or "?"}>${r.intoClass or "?"}@${r.sourceScopeId or "?"}"; + # Kahn-style toposort over the INDEX DAG (indices are comparable; route + # records may carry functions and are not). On a remaining cycle, throw with + # the participating class@scope edge chain (§B Decision 5: a detected cycle + # is a loud config error). Today no cycle is reachable — single-level + # producer→consumer — so this is a forward guard, byte-stable with the old + # noDeps-before-withDeps partition. + emittedSet = is: lib.genAttrs (map toString is) (_: true); + go = + emitted: remaining: + if remaining == [ ] then + [ ] + else + let + es = emittedSet emitted; + ready = builtins.filter (i: builtins.all (j: es ? ${toString j}) (depsOf i)) remaining; + in + if ready == [ ] then + throw "den materialize: delivery-edge cycle among [ ${lib.concatStringsSep " -> " (map labelOf remaining)} ] — a route's source depends on its own output transitively." + else + let + readySet = lib.genAttrs (map toString ready) (_: true); + in + ready ++ go (emitted ++ ready) (builtins.filter (i: !(readySet ? ${toString i})) remaining); + in + map routeAt (go [ ] (lib.range 0 (n - 1))); + + # The ordered, deduped route list both projections fold over. Suppressed routes + # are DROPPED for materialization but RECORDED (with suppressed=true) for the + # trace — so the two consumers pass the full raw list + verdicts and select. + orderedKeptRoutes = rootScopeId: rawRoutes: topoSort (keptRoutes rootScopeId rawRoutes); +in +{ + inherit + materializeNest + mkAdapterFunctor + sourceModulesOf + classifyRoute + appendScopeIdOf + suppressionVerdicts + findChildScopeKeys + keptRoutes + orderedKeptRoutes + topoSort + ; + # Compat alias: the old apply.nix `dedupRoutes` returned the kept (non- + # suppressed) routes in original order — exactly keptRoutes. Retained for the + # route/default.nix re-export; the extractor now consumes routeEdges directly. + dedupRoutes = keptRoutes; + + # ===== trace-facing route edge constructor (§8 identity, no content) === + # Renders the simple+complex route specs as edge RECORDS for the oracle + # (edge-trace.nix). Identity + annotations only; suppression verdicts are now + # EXACT (the constructor's own dedup rules), not the v0 path-dependent + # approximation. sourceVia for complex forwards stays "unresolved" (Task 9). + # + # name — sid → stable scope name (edge.nix scopeName). + # scopeParent — parent DAG (for appendToParent target resolution). + # rootScopeId — the pipeline root (suppression rootScopeId). + # rawRoutes — the flattened scopedRoutes spec list. + routeEdges = + { + name, + scopeParent, + rootScopeId, + rawRoutes, + }: + let + verdicts = suppressionVerdicts rootScopeId rawRoutes; + forwardId = + spec: + spec.adapterKey or "${spec.fromClass}>${spec.intoClass}@${spec.sourceScopeId}/${ + lib.concatStringsSep "/" (spec.staticIntoPath or spec.path or [ ]) + }"; + routeEdge = + verdict: spec: + let + sid = spec.sourceScopeId; + isComplex = spec.__complexForward or false; + path = spec.path or spec.staticIntoPath or [ ]; + appendToParent = spec.appendToParent or false; + appendSid = if appendToParent then scopeParent.${sid} or sid else sid; + adapterKey = spec.adapterKey or null; + reinstantiate = spec.reinstantiate or false; + baseAnnotations = + lib.optionalAttrs (spec.adaptArgs or null != null) { adaptArgs = true; } + // lib.optionalAttrs (spec.guard or null != null) { guard = true; } + // lib.optionalAttrs (spec.collectSubtree or false) { collectSubtree = true; } + // lib.optionalAttrs ((spec.intoClass or null) == "flake") { isFlakeRoute = true; } + // lib.optionalAttrs ((spec.instantiate or null) != null) { instantiate = true; } + // lib.optionalAttrs appendToParent { appendToParent = true; } + // lib.optionalAttrs ( + # §B cell 5: ensureEntry placeholder (empty target path materialized). + # Content-blind approx (also requires empty module set) — converges + # with the materializer's actual ensureTargetPath at runtime. + !isComplex && (spec.intoClass or null) != "flake" && (spec.adaptArgs or null) != null && path != [ ] + ) { ensureTargetPath = true; } + // lib.optionalAttrs verdict.suppressed { suppressed = true; } + // lib.optionalAttrs verdict.byChild { suppressedByChildKey = adapterKey; }; + in + if isComplex then + mkEdge { + source = synthesize (forwardId spec) spec.fromClass spec.intoClass; + target = rootTarget (name appendSid) spec.intoClass; + inherit path; + mode = "nest"; + annotations = baseAnnotations // { + complexForward = true; + sourceVia = "unresolved"; + }; + } + else + mkEdge { + source = collected (name sid) spec.fromClass; + target = rootTarget (name appendSid) spec.intoClass; + inherit path; + mode = + if reinstantiate then + "nest-verbatim" + else if path == [ ] then + "merge" + else + "nest"; + annotations = + baseAnnotations + // lib.optionalAttrs (adapterKey != null) { + inherit adapterKey; + dynamicPath = true; + }; + }; + in + lib.imap0 (i: spec: routeEdge (builtins.elemAt verdicts i) spec) rawRoutes; +} diff --git a/nix/lib/aspects/fx/route/apply.nix b/nix/lib/aspects/fx/route/apply.nix index 69e824909..5175512cc 100644 --- a/nix/lib/aspects/fx/route/apply.nix +++ b/nix/lib/aspects/fx/route/apply.nix @@ -1,25 +1,35 @@ -# Apply registered routes — fold over deduped route specs, -# dispatching complex (forward-derived) vs simple (path nesting). +# Apply registered routes — fold over deduped + toposorted route specs, +# dispatching complex (forward-derived, Task 9) vs simple (delivery-edge, Task 8). +# +# Simple routes are now DELIVERY EDGES: their §B-matrix classification, dedup, +# ordering, and source collection live in edges/route.nix; their nest / +# nest-verbatim / merge materialization lives in edges/materialize.nix's mode +# switch (materializeRouteEdge). This file keeps only the COMPLEX-forward +# (synthesize) path inline (filterRootModules / getCollectedSource / +# resolveSourceFallback / mkAdapterFunctor-for-complex), which Task 9 ports. { lib, den, - wrapRouteModules, collectClassMods, }: let - inherit (import ../scope-walk.nix { inherit lib; }) subtreeScopes; - # Root-scope `fromClass` content a child-scope forward may pull in. When - # `fromClass` is a class some entity in the chain owns, root content under it - # is that entity's own declaration, not aggregation fodder — restrict to - # shared `den.default` (host class content reaches users opt-in via - # host-aspects). A custom forward-only class (e.g. `atuin`) only exists to - # feed forwards, so its root content is a legitimate source; keep it in full. + routeEdges = import ../edges/route.nix { inherit lib; }; + inherit (import ../edges/materialize.nix { inherit lib; }) materializeRouteEdge; + inherit (routeEdges) + classifyRoute + sourceModulesOf + appendScopeIdOf + orderedKeptRoutes + ; + + # Root-scope `fromClass` content a child-scope COMPLEX forward may pull in. + # When `fromClass` is owned by an entity in the chain, root content under it is + # that entity's own declaration, not aggregation fodder — restrict to shared + # `den.default`. A forward-only custom class keeps its full root content. filterRootModules = scopeContexts: spec: rootModules: isDenDefaultModule: let childCtx = scopeContexts.${spec.sourceScopeId} or { }; - # Classes owned by each entity kind in the chain. Total over kinds so the - # filter never falls open for a non-user-owned scope (host, standalone home). ownedClasses = (childCtx.user.classes or [ ]) ++ lib.optional (childCtx ? host) childCtx.host.class @@ -46,24 +56,13 @@ let resolveSourceFallback = spec: spawnNode: scopeParent: scopeContexts: ctx: - # Early-out to no source: the spec has nothing to resolve from (no source - # aspect/scope), or spawnNode wasn't threaded (non-home callers pass - # null via the applyRoutes default). if !(spec ? sourceAspect) || spawnNode == null || !(spec ? sourceScopeId) then [ ] else (spawnNode { - # spec.sourceScopeId is the USER scope (the forward compiles at the - # current scope, per compile-forward.nix sourceScopeId = scope). `from` - # must be the HOST scope = the user scope's parent. Using sourceScopeId - # directly gives a self-parent edge -> policyBoundAncestor returns null - # -> zero fleet peers (and spawnNode's spawnRoot == from assert trips). from = scopeParent.${spec.sourceScopeId} or spec.sourceScopeId; class = spec.fromClass; aspect = den.lib.aspects.normalizeRoot spec.sourceAspect; - # The user binding is re-supplied by the source aspect's __scopeHandlers - # (ctxFromHandlers in spawnNode's seedCtx), so spawnRoot resolves to - # the user scope; do NOT strip it here. bindings = { }; }).imports; @@ -103,64 +102,13 @@ let in appendToClass acc spec.intoClass spec.sourceScopeId newMods; - mkAdapterFunctor = - route: sourceModules: - let - adapterMod = route.adapterModule or null; - sourceModule = { - imports = sourceModules; - }; - guardFn = route.guard or (_: lib.id); - adaptArgsFn = route.adaptArgs or (_: { }); - intoPathFn = route.intoPathFn or (_: route.path); - key = route.adapterKey; - guardArgs = route.guardArgs or { }; - intoPathArgs = route.intoPathArgs or { }; - adaptArgv = route.adaptArgv or { }; - freeformMod = - route.freeformMod or { - config._module.freeformType = lib.types.lazyAttrsOf lib.types.unspecified; - }; - adapterMods = - if adapterMod != null then - [ - freeformMod - adapterMod - ] - else - [ freeformMod ]; - in - { - __functionArgs = guardArgs // intoPathArgs // adaptArgv; - __functor = _: args: { - options.den.fwd.${key} = lib.mkOption { - defaultText = lib.literalExpression "{ }"; - default = { }; - type = lib.types.submoduleWith { - specialArgs = adaptArgsFn args; - modules = adapterMods ++ [ sourceModule ]; - }; - }; - config = guardFn args (lib.setAttrByPath (intoPathFn args) args.config.den.fwd.${key}); - }; - }; - - # Collect class modules from a scope and all its descendants — skipping - # isolated descendants (and their subtrees). The collection root is always - # included so an isolated entity's own delivery route still collects itself. - collectFromSubtree = - wrappedPerScope: scopeParent: scopeIsolated: rootScopeId: fromClass: - let - # Isolation-AWARE walk. - scopes = subtreeScopes { - inherit scopeParent; - isolated = scopeIsolated; - root = rootScopeId; - allScopeIds = builtins.attrNames wrappedPerScope; - }; - in - lib.concatMap (sid: wrappedPerScope.${sid}.${fromClass} or [ ]) scopes; + isDenDefaultModule = mod: lib.hasSuffix "@default" (mod.key or mod._file or ""); + # Simple route → delivery edge → materialized module list, appended to the + # target bucket. The §B cell decision (classifyRoute), source collection + # (sourceModulesOf), and target scope (appendScopeIdOf) come from + # edges/route.nix; the nest/nest-verbatim/merge placement from + # materializeRouteEdge (the mode switch). applySimpleRoute = acc: { @@ -170,158 +118,55 @@ let scopeIsolated, }: let - isFlakeRoute = route.intoClass == "flake"; - # Subtree collection only for flake routes — parametric class keys - # resolve at entity scopes (host/user) which are descendants of the - # route's scope. Non-flake routes (e.g. into "flake-parts") collect - # only from their own scope to avoid pulling modules from unrelated - # entity subtrees whose scope args won't be available after adaptArgs. - sourceModules = - if isFlakeRoute || (route.collectSubtree or false) then - collectFromSubtree wrappedPerScope scopeParent scopeIsolated route.sourceScopeId route.fromClass - else - let - scopeExists = wrappedPerScope ? ${route.sourceScopeId}; - in - if !scopeExists then [ ] else wrappedPerScope.${route.sourceScopeId}.${route.fromClass} or [ ]; - hasInstantiate = route.instantiate or null != null; + c = classifyRoute route; + sourceModules = sourceModulesOf { + inherit + route + wrappedPerScope + scopeParent + scopeIsolated + ; + }; adapterMod = route.adapterModule or null; modulesWithAdapter = if adapterMod == null then sourceModules else sourceModules ++ [ adapterMod ]; - ensureEntry = - lib.optional - ( - !isFlakeRoute - && route.adaptArgs or null != null - && route.path or [ ] != [ ] - && modulesWithAdapter == [ ] - ) - { - config = lib.setAttrByPath route.path { }; - }; - isAdapterRoute = route.adapterKey or null != null; - adapterWrapped = lib.optional isAdapterRoute (mkAdapterFunctor route sourceModules); - # Route with instantiate: collect modules, call instantiate function, - # place the result (a derivation) at the target path. - instantiateWrapped = - let - adaptArgsFn = route.adaptArgs or (_: { }); - extraArgs = adaptArgsFn { }; - evaluated = route.instantiate ({ modules = sourceModules; } // extraArgs); - in - [ - { - config = lib.setAttrByPath route.path evaluated; - } - ]; - wrappedModules = - if hasInstantiate then - if sourceModules == [ ] then [ ] else instantiateWrapped + # The §B materialize payload selecting the cell arm. + kind = + if c.hasInstantiate then + "instantiate" else if modulesWithAdapter == [ ] then - ensureEntry - else if isAdapterRoute then - adapterWrapped - else - wrapRouteModules { - modules = modulesWithAdapter; - inherit (route) path; - guard = route.guard or null; - adaptArgs = route.adaptArgs or null; - reinstantiate = route.reinstantiate or false; - }; - # Delivery routes registered inside an isolated child collect rooted at - # their own scope but must land the result on the PARENT — the child - # scope is skipped by isolation-aware extraction, so appending there - # would drop the content. - appendScopeId = - if route.appendToParent or false then - scopeParent.${route.sourceScopeId} or route.sourceScopeId - else - route.sourceScopeId; - in - appendToClass acc route.intoClass appendScopeId wrappedModules; - - isDenDefaultModule = mod: lib.hasSuffix "@default" (mod.key or mod._file or ""); - - # Collect adapterKeys that exist at child (non-root) scopes. - findChildScopeKeys = - rootScopeId: rawRoutes: - builtins.foldl' ( - acc: r: - let - ak = r.adapterKey or null; - in - if ak != null && rootScopeId != null && r.sourceScopeId != rootScopeId then - acc // { ${ak} = true; } - else - acc - ) { } rawRoutes; - - # Dedup routes: suppress root-scope when child-scope handles the same forward, - # and dedup same adapterKey@scope. - dedupRoutes = - rootScopeId: rawRoutes: - let - childScopeKeys = findChildScopeKeys rootScopeId rawRoutes; - go = - seen: routes: - if routes == [ ] then - [ ] + "ensure-empty" + else if c.isAdapterRoute then + "adapter" else - let - r = builtins.head routes; - rest = builtins.tail routes; - ak = r.adapterKey or null; - isRedundantRoot = - ak != null && rootScopeId != null && r.sourceScopeId == rootScopeId && childScopeKeys ? ${ak}; - key = if ak != null then "${ak}@${r.sourceScopeId}" else null; - in - if isRedundantRoot then - go seen rest - else if key != null && seen ? ${key} then - go seen rest - else - [ r ] ++ go (if key != null then seen // { ${key} = true; } else seen) rest; - in - go { } rawRoutes; - - # Topologically sort routes: when forward A's intoClass feeds forward - # B's fromClass at the same scope, A must fire before B. Only - # reorders __complexForward routes; non-forward routes keep their - # original position relative to other non-forwards. (#567) - topoSortRoutes = - routes: - let - indexed = lib.imap0 (i: r: { inherit i r; }) routes; - # Build producer map: intoClass@scope → [route indices]. Both simple - # routes and complex forwards are producers: a simple route injecting - # into a home-env class (e.g. homeManager) feeds the complex forward that - # carries that class to its host output (makeHomeEnv's userForward). - # Complex forwards read from the accumulating fold state, so any producer - # of their fromClass must fire first or the injected content is lost. - producerMap = builtins.foldl' ( - acc: - { i, r }: + "nest"; + # cell 5 ensureTargetPath predicate (apply-time, content-aware): empty + # module set + adaptArgs + non-flake + path≠[]. + ensureTargetPath = + !c.isFlakeRoute && c.adaptArgs != null && c.path != [ ] && modulesWithAdapter == [ ]; + # cell 7 instantiate: eager evaluation at materialization. + instantiateEvaluated = let - key = "${r.intoClass}@${r.sourceScopeId}"; + adaptArgsFn = route.adaptArgs or (_: { }); + extraArgs = adaptArgsFn { }; in - acc // { ${key} = (acc.${key} or [ ]) ++ [ i ]; } - ) { } indexed; - # A complex forward "depends on producers" when its fromClass@scope has - # producer entries from *other* routes (meaning another route produces - # into the class it consumes). Simple routes read the original per-scope - # data, not the fold state, so they never depend on ordering themselves. - hasDeps = - { i, r }: - (r.__complexForward or false) - && builtins.any (j: j != i) (producerMap."${r.fromClass}@${r.sourceScopeId}" or [ ]); - # Partition: routes without deps first, routes with deps last. - # This is a single-level toposort (sufficient for A→B chains). - noDeps = builtins.filter (ir: !hasDeps ir) indexed; - withDeps = builtins.filter hasDeps indexed; + if c.hasInstantiate then route.instantiate ({ modules = sourceModules; } // extraArgs) else null; + wrappedModules = materializeRouteEdge { + inherit kind ensureTargetPath instantiateEvaluated; + inherit (c) + path + adaptArgs + guard + reinstantiate + ; + modules = modulesWithAdapter; + sourceModules = sourceModules; + adapterPresent = adapterMod != null; + adapterRoute = route; + }; in - map (ir: ir.r) (noDeps ++ withDeps); + appendToClass acc route.intoClass (appendScopeIdOf scopeParent route) wrappedModules; - # Main entry: dedup routes, fold applying each. + # Main entry: dedup + toposort routes, fold applying each (complex vs simple). applyRoutes = { scopedRoutes, @@ -336,9 +181,7 @@ let buildForwardAspect ? null, }: let - allRoutes = topoSortRoutes ( - dedupRoutes rootScopeId (lib.concatLists (lib.attrValues scopedRoutes)) - ); + allRoutes = orderedKeptRoutes rootScopeId (lib.concatLists (lib.attrValues scopedRoutes)); in builtins.foldl' ( @@ -373,15 +216,10 @@ let allRoutes; in { - inherit - applyRoutes - # Exported for the read-only edge-trace extractor (edge-trace.nix), which - # renders the CURRENT route suppression decisions as `suppressed` + - # `suppressedByChildKey` annotations by reusing this exact logic rather than - # reimplementing it. Additive only — no behavior change. (topoSortRoutes is - # NOT exported: the extractor renders unordered edges and has no consumer for - # the toposort; it stays internal to applyRoutes.) - dedupRoutes - findChildScopeKeys - ; + # applyRoutes is the only consumer-facing entry: the route fold. Simple routes + # are delivery edges (edges/route.nix + edges/materialize.nix); the old + # dedupRoutes/findChildScopeKeys exports (consumed by the v0 edge-trace arm) + # are dead now that the oracle renders routes via the shared routeEdges + # constructor — dropped here. Suppression/dedup lives in edges/route.nix. + inherit applyRoutes; } diff --git a/nix/lib/aspects/fx/route/default.nix b/nix/lib/aspects/fx/route/default.nix index 5f8af1de6..c7abd80f1 100644 --- a/nix/lib/aspects/fx/route/default.nix +++ b/nix/lib/aspects/fx/route/default.nix @@ -5,26 +5,14 @@ ... }: let - inherit (import ./wrap.nix { inherit lib den; }) wrapRouteModules collectClassMods; + inherit (import ./wrap.nix { inherit lib den; }) collectClassMods; inherit (import ./apply.nix { - inherit - lib - den - wrapRouteModules - collectClassMods - ; + inherit lib den collectClassMods; }) applyRoutes - dedupRoutes - findChildScopeKeys ; in { - inherit - wrapRouteModules - applyRoutes - dedupRoutes - findChildScopeKeys - ; + inherit applyRoutes; } diff --git a/nix/lib/aspects/fx/route/wrap.nix b/nix/lib/aspects/fx/route/wrap.nix index 874959913..54f08b3bb 100644 --- a/nix/lib/aspects/fx/route/wrap.nix +++ b/nix/lib/aspects/fx/route/wrap.nix @@ -1,184 +1,13 @@ -# Route module wrapping — path nesting, guards, adaptArgs. +# Route forward-aspect collection. +# +# The simple-route module wrapping (path nesting, guards, adaptArgs, verbatim, +# the #572 combine) moved to edges/route.nix (Task 8 — simple routes are +# delivery edges; the nest/nest-verbatim mode mechanics live in the materializer +# mode switch). Only `collectClassMods` remains here — it is consumed by the +# COMPLEX-forward path (route/apply.nix:applyComplexRoute, Task 9) to collect a +# forward aspect's class modules, NOT a route-nesting concern. { lib, ... }: let - # Freeform type for route nesting evalModules: merges like NixOS - # (attrsets deep-merge, lists concatenate) but errors on conflicting - # scalar or derivation values instead of silently clobbering. - mergeableType = lib.mkOptionType { - name = "mergeable"; - description = "auto-merged value (attrsets merge, lists concatenate, scalars conflict)"; - merge = - loc: defs: - let - values = map (d: d.value) defs; - first = builtins.head values; - allLists = builtins.all builtins.isList values; - # Derivations are attrsets but must not deep-merge — treat as opaque. - allMergeableAttrs = builtins.all (v: builtins.isAttrs v && !(lib.isDerivation v)) values; - in - if builtins.length defs == 1 then - first - else if allLists then - builtins.concatLists values - else if allMergeableAttrs then - (lib.types.lazyAttrsOf mergeableType).merge loc defs - else - throw "den: the option `${lib.showOption loc}' has conflicting definitions from multiple aspects"; - }; - nestingFreeformType = lib.types.lazyAttrsOf mergeableType; - - # Adapt a module's args when path is empty (top-level adaptArgs). - adaptModule = - adaptArgs: path: mod: - if adaptArgs == null || path != [ ] then - mod - else if builtins.isFunction mod then - args: mod (adaptArgs args) - else - mod; - - # Nest a module at a path using submodule evaluation with adapted specialArgs. - nestWithAdaptArgs = - path: adaptArgs: mod: args: - let - fullArgs = args // (args.config._module.args or { }); - adapted = adaptArgs fullArgs; - sourceModules = if builtins.isAttrs mod && mod ? imports then mod.imports else [ mod ]; - evaluated = lib.evalModules { - specialArgs = adapted; - modules = [ - { config._module.freeformType = nestingFreeformType; } - ] - ++ sourceModules; - }; - in - { - config = lib.setAttrByPath path ( - builtins.removeAttrs evaluated.config [ - "_module" - "warnings" - "assertions" - ] - ); - }; - - # Nest a module at a path by evaluating imports with full outer args. - # Uses evalModules with raw freeform type so conflicting keys error - # instead of silently clobbering via recursiveUpdate. - nestPlain = - path: mod: args: - let - fullArgs = args // (args.config._module.args or { }); - resolveImport = imp: if builtins.isFunction imp then imp fullArgs else imp; - sourceModules = if builtins.isAttrs mod && mod ? imports then mod.imports else [ mod ]; - resolved = map resolveImport sourceModules; - evaluated = lib.evalModules { - specialArgs = fullArgs; - modules = [ - { config._module.freeformType = nestingFreeformType; } - ] - ++ resolved; - }; - in - { - config = lib.setAttrByPath path ( - builtins.removeAttrs evaluated.config [ - "_module" - "warnings" - "assertions" - ] - ); - }; - - # Nest a module at a path by REFERENCE, keeping the collected module wrapper - # INTACT. Unlike nestPlain (which unwraps `mod.imports` and pre-evaluates the - # content in an isolated freeform evalModules, freezing it to resolved config), - # this preserves the wrapper's `key`/`_file` (assigned by wrap-classes.nix as - # `@`) and delivers the module unevaluated. Required when the - # target RE-INSTANTIATES the delivered content as its own NixOS system (e.g. - # microvm `microvm.vms..config`, whose option type re-runs eval-config with - # the full base module-list): the target then applies base-module defaults AND - # dedups identical re-declarations across {host,user} scopes by `key` — exactly - # as spawn-node's instantiation walk does. Pre-evaluating (nestPlain) instead - # strips base defaults and drops the keys, poisoning every namespace aggregate - # and double-declaring keyless modules at the target. - nestVerbatim = path: mod: { - config = lib.setAttrByPath path { imports = [ mod ]; }; - }; - - # Nest a module at a target path (dispatch between verbatim, adapt, and plain - # strategies). `reinstantiate` selects verbatim delivery for targets that - # re-evaluate the payload as their own module set. - nestModule = - path: adaptArgs: reinstantiate: mod: - if path == [ ] then - mod - else if reinstantiate then - nestVerbatim path mod - else if adaptArgs != null then - nestWithAdaptArgs path adaptArgs mod - else - nestPlain path mod; - - # Wrap a module with a conditional guard. - # - # A bool guard gates content with `lib.optionalAttrs`, not `lib.mkIf`, to - # match the forward path (forward.nix guardFn): a false guard must contribute - # *nothing* — `mkIf false` still requires the target option to exist, so an - # empty-path route into an undeclared option would fail option type-checking - # even when skipped. `optionalAttrs false` drops the subtree entirely. - # - # A structural module (carries `imports`/`_file`/`key` module metadata but no - # flat `config`) cannot be merged under `config` — its module-level keys would - # be mis-read as option definitions and fail (e.g. "the option `_file' does - # not exist"). This is the empty-path case, where the source is the raw - # collector module. Recurse into its `imports`, gating each leaf's config and - # leaving module metadata at module level. - guardModule = - guard: mod: - if guard == null then - mod - else - let - guardOne = - node: args: - let - inner = if builtins.isFunction node then node args else node; - in - if inner ? imports && !(inner ? config) then - { imports = map guardOne inner.imports; } // builtins.removeAttrs inner [ "imports" ] - else - { config = lib.optionalAttrs (guard args) (inner.config or inner); }; - in - guardOne mod; - - # Apply the adapt → nest → guard pipeline to a list of modules. - # When adaptArgs is non-null (nestWithAdaptArgs path), all modules are - # combined into a single evalModules call so that multiple aspects - # emitting to the same class merge correctly inside evalModules, - # rather than producing separate config definitions that get - # shallow-merged by the freeform `unspecified` type. (#572) - wrapRouteModules = - { - modules, - path, - guard ? null, - adaptArgs ? null, - reinstantiate ? false, - }: - let - adapted = map (adaptModule adaptArgs path) modules; - in - if adapted == [ ] then - [ ] - # reinstantiate keeps each collected wrapper keyed and separate so the - # target's own evalModules dedups them; it must not be combined into one - # adaptArgs evalModules. - else if adaptArgs != null && path != [ ] && !reinstantiate then - [ (guardModule guard (nestWithAdaptArgs path adaptArgs { imports = adapted; })) ] - else - map (mod: guardModule guard (nestModule path adaptArgs reinstantiate mod)) adapted; - # Collect class modules from a forward aspect (recursing into includes). collectClassMods = cls: aspect: @@ -189,5 +18,5 @@ let own ++ nested; in { - inherit wrapRouteModules collectClassMods; + inherit collectClassMods; } From 3ebea2565a3ff361a2c3c828c6312bb8bb5acc16 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 12 Jun 2026 18:23:17 -0700 Subject: [PATCH 050/101] =?UTF-8?q?chore(fx):=20route-port=20hygiene=20?= =?UTF-8?q?=E2=80=94=20dead=20branches,=20stale=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete the unreachable adapted==[] branch in materializeNest (sole caller is gated on non-empty modulesWithAdapter; ensure-empty arm fires first in materializeRouteEdge). Drop the redundant adapterPresent disjunct in the adapter arm (adapter kind only selected when modulesWithAdapter != []). Update materialize.nix header: Tasks 7-8 are live; the else-throw is permanent, not a stub. Add Task 9 deletion marker on applyComplexRoute. Fix phantom combineSingleEval in route.nix header (describe the inline #572 rule). Restore lost derivation comment above allMergeableAttrs. --- nix/lib/aspects/fx/edges/materialize.nix | 12 ++++++------ nix/lib/aspects/fx/edges/route.nix | 9 +++------ nix/lib/aspects/fx/route/apply.nix | 1 + 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/nix/lib/aspects/fx/edges/materialize.nix b/nix/lib/aspects/fx/edges/materialize.nix index f675dfba7..166b84c6d 100644 --- a/nix/lib/aspects/fx/edges/materialize.nix +++ b/nix/lib/aspects/fx/edges/materialize.nix @@ -4,9 +4,11 @@ # re-entries' final extraction; phase ordering becomes edge toposort (corollary # 5) as later mechanisms are ported. # -# This task (Task 7) exercises the `merge` mode only — the default-fold port. The -# `nest`/`nest-verbatim` arms throw an explicit "not yet ported" marker -# (Task 8); an explicit unreachable beats a silent wrong materialization. +# Tasks 7–8 are complete: `merge` (default-fold port) and `nest`/`nest-verbatim` +# (route delivery via materializeRouteEdge) are both live. The else-throw in the +# `materialize` switch below is NOT a Task 8 stub — it is permanently correct: +# `assembleSubtree` carries merge edges only; route nest/nest-verbatim delivery +# folds through route/apply.nix:applySimpleRoute → materializeRouteEdge. # # DESIGN INVARIANTS (spec §2 corollaries; enforced by the entity-isolation suite # and the delivery-edges fixtures): @@ -112,9 +114,7 @@ rec { lib.optional m.ensureTargetPath { config = lib.setAttrByPath m.path { }; } else if m.kind == "adapter" then # cell 6: the adapter functor module (dynamic P resolved at evalModules). - lib.optional (m.modules != [ ] || m.adapterPresent) ( - mkAdapterFunctor m.adapterRoute m.sourceModules - ) + [ (mkAdapterFunctor m.adapterRoute m.sourceModules) ] else # cells 1–4: nest | nest-verbatim | merge contribution (P=[]), + #572. materializeNest { diff --git a/nix/lib/aspects/fx/edges/route.nix b/nix/lib/aspects/fx/edges/route.nix index 4e8b0352d..3ef70a2b3 100644 --- a/nix/lib/aspects/fx/edges/route.nix +++ b/nix/lib/aspects/fx/edges/route.nix @@ -1,7 +1,7 @@ # route.nix — the simple-route edge constructor (spec §3c "edge collection", # §B Decision 4 matrix). A `scopedRoutes` simple-route spec becomes a delivery # edge per the §B 10-cell reachable matrix; the edge's MODE (merge | nest | -# nest-verbatim) and edge PROPERTIES (adaptArgs, guard, combineSingleEval, +# nest-verbatim) and edge PROPERTIES (adaptArgs, guard, #572-combine, # ensureTargetPath, adapterKey, instantiate, collectSubtree, to=parent) drive # the materializer's mode switch (edges/materialize.nix). No new modes — the §B # hybrids decompose into mode + properties. @@ -49,6 +49,7 @@ let values = map (d: d.value) defs; first = builtins.head values; allLists = builtins.all builtins.isList values; + # Derivations are attrsets but must not deep-merge — treat as opaque. allMergeableAttrs = builtins.all (v: builtins.isAttrs v && !(lib.isDerivation v)) values; in if builtins.length defs == 1 then @@ -197,11 +198,7 @@ let let adapted = map (adaptModule adaptArgs path) modules; in - if adapted == [ ] then - # cell 5: ensureTargetPath materializes an empty attrset at P so the option - # lands; otherwise no module. - lib.optional ensureTargetPath { config = lib.setAttrByPath path { }; } - else if adaptArgs != null && path != [ ] && !reinstantiate then + if adaptArgs != null && path != [ ] && !reinstantiate then # cell 3 (#572): ONE combined evalModules. [ (guardModule guard (nestWithAdaptArgs path adaptArgs { imports = adapted; })) diff --git a/nix/lib/aspects/fx/route/apply.nix b/nix/lib/aspects/fx/route/apply.nix index 5175512cc..cb5d9e4df 100644 --- a/nix/lib/aspects/fx/route/apply.nix +++ b/nix/lib/aspects/fx/route/apply.nix @@ -77,6 +77,7 @@ let }; }; + # Task 9: scheduled deletion (complex-forward port). applyComplexRoute = acc: { From 90edb0850f94844a2c4d7e575214fbdd3a15714d Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 12 Jun 2026 18:38:15 -0700 Subject: [PATCH 051/101] feat(fx): provides + complex forwards as delivery edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the Task-9 survivors onto the edge materializer and delete them. Provides (§B Decision 1): new edges/provides.nix with applyProvidesEdges (phase-2 materialization, moved from resolve.nix:applyProvides — wrapClassModule /setDefaultModuleLocation/unsatisfied-drop preserved exactly), dedupProvides ((policyName/class/path) edge-identity key), and providesEdges (trace nest-edge constructor, mergeHalf=default-fold annotation). The nest∘merge decomposition is the materialization shape: nest into the source scope's bucket; the default-fold edge carries the merge half — no literal second edge. Complex forwards (§B Decision 2): synthesize machinery (applyComplexRouteEdge, filterRootModules, getCollectedSource, resolveSourceFallback, appendToClass, isDenDefaultModule, collectClassMods) absorbed into edges/route.nix; the collected-else-rewalk source rule and filterRootModules ownedClasses S-rule are intact. One synthesize edge per forward, identity triple (forwardId, fromClass, intoClass), content built at materialization. The whole applyRoutes fold (simple+complex dispatch) now lives in edges/route.nix; materializeRouteEdge moved there too (breaks a route<->materialize import cycle, keeps route mechanics together). resolve.nix: applyProvides/dedupProvides inline defs gone; phase-2 is applyProvidesEdges; the route wrapper delegates to routeEdges.applyRoutes. The route/ directory (apply.nix, wrap.nix, default.nix) is deleted. spawn-node keeps consuming applyProvides/applyRoutes as injected params (its phase block is Task 10) — fed applyProvidesEdges + the route wrapper, calls unchanged. sourceVia for complex forwards stays "unresolved" permanently: the trace renders construction-time identity, but the collected-else-rewalk branch is materialization-time path-dependent (spec §8: synthesize records identity, not content). Documented inline. Trace fixtures byte-stable (ZERO edits); full just ci 961/961 green. --- nix/lib/aspects/fx/edge-trace.nix | 59 ++--- nix/lib/aspects/fx/edges/materialize.nix | 64 +---- nix/lib/aspects/fx/edges/provides.nix | 134 ++++++++++ nix/lib/aspects/fx/edges/route.nix | 301 ++++++++++++++++++++++- nix/lib/aspects/fx/resolve.nix | 72 +----- nix/lib/aspects/fx/route/apply.nix | 226 ----------------- nix/lib/aspects/fx/route/default.nix | 18 -- nix/lib/aspects/fx/route/wrap.nix | 22 -- 8 files changed, 468 insertions(+), 428 deletions(-) create mode 100644 nix/lib/aspects/fx/edges/provides.nix delete mode 100644 nix/lib/aspects/fx/route/apply.nix delete mode 100644 nix/lib/aspects/fx/route/default.nix delete mode 100644 nix/lib/aspects/fx/route/wrap.nix diff --git a/nix/lib/aspects/fx/edge-trace.nix b/nix/lib/aspects/fx/edge-trace.nix index 366ff44b2..ceeeee100 100644 --- a/nix/lib/aspects/fx/edge-trace.nix +++ b/nix/lib/aspects/fx/edge-trace.nix @@ -24,7 +24,7 @@ # Trace normalization (spec §8): sort key (T, P, S, M); entity scopes named by # id_hash (parent-blind identity), non-entity scopes by their mkScopeId string; # rewalk/synthesize edges record the identity triple, NOT resolved content. -{ lib, ... }: +{ lib, den, ... }: let # The shared edge record, sort key, scope-naming, and S/T constructors — the # ONE edge definition production (edges/default.nix) and this oracle share, so @@ -46,13 +46,21 @@ let # one constructor (spec §3a). inherit (import ./edges/default.nix { inherit lib; }) defaultFoldEdges; # The route edge constructor — the SAME constructor production materializes - # simple routes through (route/apply.nix → edges/materialize.nix). v0's inline + # simple + complex routes through (edges/route.nix applyRoutes). v0's inline # route arm + its own dedup/suppression re-derivation is REPLACED by this # import: the oracle and production now converge on ONE route constructor # (spec §3a). The `suppressed` annotations are now EXACT (the constructor's own # dedup rules), not the v0 path-dependent approximation; `sourceVia` for complex - # forwards stays "unresolved" (Task 9). - inherit (import ./edges/route.nix { inherit lib; }) routeEdges; + # forwards stays "unresolved" (the collected-else-rewalk source choice is + # materialization-time path-dependent — see routeEdges' note). + inherit (import ./edges/route.nix { inherit lib den; }) routeEdges; + # The provides edge constructor — the SAME constructor production materializes + # provides through (resolve.nix phase-2 → edges/provides.nix applyProvidesEdges). + # v0's inline provides arm + its own dedup is REPLACED by this import: oracle and + # production converge on ONE provides constructor (spec §3a). The two-edge + # decomposition (nest into source bucket, merge half = default-fold) is recorded + # by the constructor's `mergeHalf` annotation (§B Decision 1). + inherit (import ./edges/provides.nix { inherit lib den; }) providesEdges; in { # extractEdgeTrace: pipeline end-state → stably-sorted normalized edge list. @@ -68,7 +76,6 @@ in scopedSpawns, scopedInstantiates, rootScopeId, - dedupProvides, }: let nameArgs = { inherit scopeEntityKind scopeContexts; }; @@ -102,39 +109,13 @@ in }; # ===== provides edges (two-edge decomposition, §B Decision 1) ====== - # A provides spec → a nest edge into the SOURCE scope's bucket - # (setAttrByPath path module). The merge half is the default fold edge - # already emitted above (the perScope append is subtree-collectible) — we - # render only the nest edge, annotated providesPolicyName, with a note that - # the merge half is the default fold. Dedup key = (policyName, class, path) - # — the SAME composite key applyProvides/dedupProvides uses (NOT scope- - # keyed): two provides from one policy into one class+path collapse to one - # edge regardless of registering scope. - allProvides = builtins.concatLists (lib.attrValues scopedProvides); - dedupedProvides = dedupProvides allProvides; - providesEdges = map ( - spec: - let - path = spec.path or [ ]; - sid = spec.sourceScopeId; - in - mkEdge { - # Source is the provided module placed at P (nest construction); - # rendered as a collected source into the source scope's class bucket. - source = collected (name sid) spec.class; - target = rootTarget (name sid) spec.class; - inherit path; - # P=[] degenerates to a plain merge contribution (no nesting); P!=[] - # is the setAttrByPath nest construction. - mode = if path == [ ] then "merge" else "nest"; - annotations = { - providesPolicyName = spec.__providePolicyName or null; - # The merge half (delivery to the entity root) is the default fold - # edge above; this nest edge only constructs the placed module. - mergeHalf = "default-fold"; - }; - } - ) dedupedProvides; + # Rendered by the SHARED provides constructor (edges/provides.nix + # providesEdges) — the SAME constructor production materializes provides + # through (resolve.nix phase-2 → applyProvidesEdges). Each spec → a nest edge + # into the SOURCE scope's bucket; the merge half is the default-fold edge + # (annotated mergeHalf). Dedup key = (policyName, class, path), NOT scope- + # keyed (§B Decision 1). + providesEdgeList = providesEdges { inherit name scopedProvides; }; # ===== route edges ================================================= # Rendered by the SHARED route constructor (edges/route.nix routeEdges) — @@ -270,7 +251,7 @@ in ) instGrouped ); - allEdges = defaultFold ++ providesEdges ++ routeEdgeList ++ spawnEdges ++ instantiateEdges; + allEdges = defaultFold ++ providesEdgeList ++ routeEdgeList ++ spawnEdges ++ instantiateEdges; in sortEdges allEdges; } diff --git a/nix/lib/aspects/fx/edges/materialize.nix b/nix/lib/aspects/fx/edges/materialize.nix index 166b84c6d..2beb0e852 100644 --- a/nix/lib/aspects/fx/edges/materialize.nix +++ b/nix/lib/aspects/fx/edges/materialize.nix @@ -4,11 +4,13 @@ # re-entries' final extraction; phase ordering becomes edge toposort (corollary # 5) as later mechanisms are ported. # -# Tasks 7–8 are complete: `merge` (default-fold port) and `nest`/`nest-verbatim` -# (route delivery via materializeRouteEdge) are both live. The else-throw in the -# `materialize` switch below is NOT a Task 8 stub — it is permanently correct: -# `assembleSubtree` carries merge edges only; route nest/nest-verbatim delivery -# folds through route/apply.nix:applySimpleRoute → materializeRouteEdge. +# Tasks 7–9 are complete: `merge` (default-fold port) is live here; `nest`/ +# `nest-verbatim` (route delivery via materializeRouteEdge) and the synthesize +# (complex-forward) + provides edge materialization live in edges/route.nix + +# edges/provides.nix. The else-throw in the `materialize` switch below is NOT a +# stub — it is permanently correct: `assembleSubtree` carries merge edges only; +# route nest/nest-verbatim + synthesize delivery folds through edges/route.nix +# (applyRoutes → materializeRouteEdge), provides through edges/provides.nix. # # DESIGN INVARIANTS (spec §2 corollaries; enforced by the entity-isolation suite # and the delivery-edges fixtures): @@ -24,7 +26,6 @@ let inherit (import ../scope-walk.nix { inherit lib; }) subtreeScopes dedupByKey; inherit (import ./default.nix { inherit lib; }) defaultFoldEdges; - inherit (import ./route.nix { inherit lib; }) materializeNest mkAdapterFunctor; in rec { # The Π(root) record shape (§A, Task 1 census). Per-field provenance cites the @@ -88,51 +89,12 @@ rec { in dedupByKey (m: m.key or null) raw; - # materializeRouteEdge: the §B nest/nest-verbatim/merge placement for ONE - # simple-route edge carrying its already-resolved source modules + materializer - # properties → the wrapped module list to land in the target bucket. This is - # the ONLY mode switch for route delivery; the route fold (route/apply.nix) - # routes EVERY simple route through here so nest/nest-verbatim/merge placement - # is decided in one place (spec §3c materialization). - # - # The edge carries a `materialize` payload: - # { modules; path; mode; adaptArgs; guard; reinstantiate; ensureTargetPath; - # adapterKey; adapterRoute; sourceModules; instantiateEvaluated; } - # `adapterKey`/`instantiate` arms replace the module list outright (cells 6/7); - # the remaining cells (1–5) go through materializeNest (nest | nest-verbatim | - # merge contribution at P=[], + the #572 combine + ensureTargetPath). - materializeRouteEdge = - m: - if m.kind == "instantiate" then - # cell 7: eager instantiate evaluated at materialization, placed at P. - if m.sourceModules == [ ] then - [ ] - else - [ { config = lib.setAttrByPath m.path m.instantiateEvaluated; } ] - else if m.kind == "ensure-empty" then - # cell 5 with empty source and ensureTargetPath: land an empty attrset at P. - lib.optional m.ensureTargetPath { config = lib.setAttrByPath m.path { }; } - else if m.kind == "adapter" then - # cell 6: the adapter functor module (dynamic P resolved at evalModules). - [ (mkAdapterFunctor m.adapterRoute m.sourceModules) ] - else - # cells 1–4: nest | nest-verbatim | merge contribution (P=[]), + #572. - materializeNest { - inherit (m) - modules - path - guard - adaptArgs - reinstantiate - ensureTargetPath - ; - }; - # materialize: Π + an edge list → { class → [ modules ] }. The ONLY mode - # switch for default-fold extraction. Routes/provides/spawn fold through their - # own entry (route/apply.nix → materializeRouteEdge) until their phase folds are - # absorbed (Tasks 9–11); the merge arm here is the per-root final extraction. - # `perScope` and the resolved subtree are passed via the closure `ctx`. + # switch for default-fold extraction. The merge arm here is the per-root final + # extraction; routes/provides/spawn fold through their own entry (edges/route.nix + # applyRoutes / edges/provides.nix applyProvidesEdges) until their phase folds + # are fully absorbed (Tasks 10–11). `perScope` and the resolved subtree are + # passed via the closure `ctx`. materialize = pi: ctx: edges: let @@ -148,7 +110,7 @@ rec { ${cls} = (acc.${cls} or [ ]) ++ collectMerge ctx.perScope ctx.subtreeScopeIds cls; } else - throw "den materialize: assembleSubtree edge mode ${builtins.toJSON edge.mode} (nest/nest-verbatim route delivery folds through route/apply.nix:materializeRouteEdge, not assembleSubtree, until Tasks 9–11)"; + throw "den materialize: assembleSubtree edge mode ${builtins.toJSON edge.mode} (nest/nest-verbatim route delivery folds through edges/route.nix:materializeRouteEdge, not assembleSubtree, until Tasks 10–11)"; in builtins.foldl' step { } edges; diff --git a/nix/lib/aspects/fx/edges/provides.nix b/nix/lib/aspects/fx/edges/provides.nix new file mode 100644 index 000000000..7a5af77fd --- /dev/null +++ b/nix/lib/aspects/fx/edges/provides.nix @@ -0,0 +1,134 @@ +# provides.nix — the provides edge constructor (spec §2 corollary 4, §B +# Decision 1). A `policy.provide` spec materializes as a TWO-EDGE composition +# `nest ∘ merge`: +# +# 1. a NEST edge `(S = the provided module, T = (sourceScope-bucket, class), +# P = spec.path, M = nest)` — the `setAttrByPath path module` construction; +# 2. the DEFAULT MERGE edge every entity-root scope already has (corollary 1) — +# the nested module is appended into the SOURCE scope's own bucket +# (perScope.${sid}.${class}), and the default subtree fold carries it to the +# entity root exactly like any other collected content. +# +# This file emits ONLY the nest edge into the source scope's bucket; the merge +# half rides the existing default-fold edge (do NOT emit a literal second edge in +# materialization — the trace keeps the `mergeHalf = "default-fold"` annotation +# per the §3a oracle convention). M stays the closed enum — provides adds no mode. +# +# Edge identity / dedup is the composite key `(policyName, intoClass, path)` — NOT +# scope-keyed: two provides from one policy into one class+path collapse to one +# edge regardless of which scope registered them (first-occurrence-wins). This is +# deliberately coarser than route dedup (which keys on scope): provides identity +# is policy-authored intent, not scope of registration (§B Decision 1). +# +# Two projections share ONE dedup (dedupProvides): +# - the trace-facing edge RECORD (identity + annotations, no content) consumed +# by the read-only oracle (edge-trace.nix); +# - the MATERIALIZATION (the actual wrapped module appended to the source-scope +# bucket) consumed by resolve.nix's phase-2 fold, replacing applyProvides. +{ lib, den }: +let + inherit (import ./edge.nix { inherit lib; }) mkEdge collected rootTarget; + inherit (import ../scope-walk.nix { inherit lib; }) dedupByKey; + + # Dedup provides by composite key (policyName/class/path). Null-keyed specs + # (no __providePolicyName) are always kept. First-occurrence wins. + dedupProvides = dedupByKey ( + s: + let + pn = s.__providePolicyName or null; + in + if pn != null then "${pn}/${s.class}/${lib.concatStringsSep "/" (s.path or [ ])}" else null + ); + + # ===== materialization (ported from resolve.nix:applyProvides) ========== + # Apply the deduped provides specs onto an accumulator { classImports; perScope; }. + # Each spec → the nest-at-P module (setAttrByPath), wrapped via wrapClassModule + # (module-identity wrapping, NOT a mode); unsatisfied wraps are DROPPED. The + # wrapped module is appended to BOTH the flat classImports aggregate AND the + # SOURCE scope's perScope bucket — the latter is the merge half (subtree- + # collectible, visible to a later route's getCollectedSource / subtree walk). + # + # ctx — the pipeline base ctx (fallback when a scope has no context). + # scopeContexts — sid → context (for the wrap's collision policy resolution). + # scopedProvides — sid → [ provide specs ] (the registered provides). + # acc — { classImports; perScope; } (phase-1 output). + applyProvidesEdges = + ctx: scopeContexts: scopedProvides: acc: + let + allProvides = dedupProvides (lib.concatLists (lib.attrValues scopedProvides)); + in + builtins.foldl' ( + prev: spec: + let + targetClass = spec.class; + path = spec.path or [ ]; + sid = spec.sourceScopeId; + # Nest-at-P construction (P=[] degenerates to a plain merge contribution). + rawModule = if path == [ ] then spec.module else lib.setAttrByPath path spec.module; + wrapped = den.lib.aspects.fx.aspect.wrapClassModule { + inherit ctx; + module = rawModule; + aspectPolicy = null; + globalPolicy = null; + }; + wrappedMod = + if wrapped.unsatisfied or false then + [ ] + else + let + loc = "${targetClass}@/${lib.concatStringsSep "/" path}"; + in + [ (lib.setDefaultModuleLocation loc wrapped.module) ]; + in + { + classImports = prev.classImports // { + ${targetClass} = (prev.classImports.${targetClass} or [ ]) ++ wrappedMod; + }; + perScope = prev.perScope // { + ${sid} = (prev.perScope.${sid} or { }) // { + ${targetClass} = ((prev.perScope.${sid} or { }).${targetClass} or [ ]) ++ wrappedMod; + }; + }; + } + ) acc allProvides; + + # ===== trace-facing provides edge constructor (§8 identity, no content) = + # Renders the deduped provides specs as edge RECORDS for the oracle. Each is the + # NEST edge into the source scope's bucket (the merge half is the default-fold + # edge, annotated). P=[] degenerates to a merge contribution (no nesting). + # + # name — sid → stable scope name (edge.nix scopeName). + # scopedProvides — sid → [ provide specs ]. + providesEdges = + { name, scopedProvides }: + let + allProvides = builtins.concatLists (lib.attrValues scopedProvides); + dedupedProvides = dedupProvides allProvides; + in + map ( + spec: + let + path = spec.path or [ ]; + sid = spec.sourceScopeId; + in + mkEdge { + source = collected (name sid) spec.class; + target = rootTarget (name sid) spec.class; + inherit path; + mode = if path == [ ] then "merge" else "nest"; + annotations = { + providesPolicyName = spec.__providePolicyName or null; + # The merge half (delivery to the entity root) is the default-fold edge; + # this nest edge only constructs the placed module (§B Decision 1). + mergeHalf = "default-fold"; + }; + } + ) dedupedProvides; +in +{ + inherit + dedupProvides + applyProvidesEdges + providesEdges + ; +} diff --git a/nix/lib/aspects/fx/edges/route.nix b/nix/lib/aspects/fx/edges/route.nix index 3ef70a2b3..c23e1cac2 100644 --- a/nix/lib/aspects/fx/edges/route.nix +++ b/nix/lib/aspects/fx/edges/route.nix @@ -6,15 +6,16 @@ # the materializer's mode switch (edges/materialize.nix). No new modes — the §B # hybrids decompose into mode + properties. # -# This file owns the SIMPLE-route half only. Complex (__complexForward) routes -# are dispatched by route/apply.nix:applyComplexRoute (Task 9, synthesize edges) -# and never reach here. +# This file owns BOTH route halves: SIMPLE routes (delivery edges, §B Decision 4) +# and COMPLEX (__complexForward) routes (synthesize edges, §B Decision 2). The +# `applyRoutes` fold dispatches between them; resolve.nix and spawn-node thread +# their state in and get the assembled buckets back (the phase-3 materialization). # # Two projections share ONE classification (classifyRoute): # - the trace-facing edge RECORD (identity + annotations, no content) consumed # by the read-only oracle (edge-trace.nix) — §8 records identity, not content; # - the MATERIALIZATION (the actual wrapped module list + target scope) consumed -# by route/apply.nix's fold, replacing applySimpleRoute. +# by the `applyRoutes` fold (applySimpleRouteEdge / applyComplexRouteEdge). # Both derive from the same per-route cell decision, so oracle and production can # never disagree on which §B cell a route is. # @@ -22,7 +23,7 @@ # construction: dedupRoutes' two suppressions (adapterKey@scope identity dedup; # redundant-root edge-set shadowing) and topoSortRoutes' producer→consumer order # (now a general edge toposort with loud cycle throw). -{ lib, ... }: +{ lib, den }: let inherit (import ./edge.nix { inherit lib; }) mkEdge @@ -259,6 +260,43 @@ let }; }; + # materializeRouteEdge: the §B nest/nest-verbatim/merge placement for ONE + # simple-route edge carrying its already-resolved source modules + materializer + # properties → the wrapped module list to land in the target bucket. This is the + # ONLY mode switch for route delivery; applySimpleRouteEdge routes EVERY simple + # route through here so nest/nest-verbatim/merge placement is decided in one + # place (spec §3c materialization). + # + # `adapterKey`/`instantiate` arms replace the module list outright (cells 6/7); + # the remaining cells (1–5) go through materializeNest (nest | nest-verbatim | + # merge contribution at P=[], + the #572 combine + ensureTargetPath). + materializeRouteEdge = + m: + if m.kind == "instantiate" then + # cell 7: eager instantiate evaluated at materialization, placed at P. + if m.sourceModules == [ ] then + [ ] + else + [ { config = lib.setAttrByPath m.path m.instantiateEvaluated; } ] + else if m.kind == "ensure-empty" then + # cell 5 with empty source and ensureTargetPath: land an empty attrset at P. + lib.optional m.ensureTargetPath { config = lib.setAttrByPath m.path { }; } + else if m.kind == "adapter" then + # cell 6: the adapter functor module (dynamic P resolved at evalModules). + [ (mkAdapterFunctor m.adapterRoute m.sourceModules) ] + else + # cells 1–4: nest | nest-verbatim | merge contribution (P=[]), + #572. + materializeNest { + inherit (m) + modules + path + guard + adaptArgs + reinstantiate + ensureTargetPath + ; + }; + # ===== source collection (§B cell 9 — collectSubtree / isFlakeRoute) === # Collect class modules from a scope and all descendants, skipping isolated # descendants (isolation-AWARE; collection root always included). The plain @@ -468,30 +506,267 @@ let # are DROPPED for materialization but RECORDED (with suppressed=true) for the # trace — so the two consumers pass the full raw list + verdicts and select. orderedKeptRoutes = rootScopeId: rawRoutes: topoSort (keptRoutes rootScopeId rawRoutes); + + # ===== synthesize source + materialization (§B Decision 2 — complex forward) = + # A complex (__complexForward) route is a SINGLE synthesize edge: + # S = synthesize(forwardSpec, sourceModule) + # where sourceModule is built by ONE source rule with a fallback (NOT two edge + # kinds): `sourceModules = collected if non-empty else rewalk`. The fallback is + # internal to S-construction; the edge identity is (forwardSpec, intoClass) + # regardless of which branch produced the source. The synthesize constructor is + # `buildForwardAspect` (handlers/forward.nix) — it builds a NEW aspect from the + # source module (spec §2: "neither a plain collect nor a re-resolution"). + + # Collect class modules from a forward aspect (recursing into includes). Moved + # from route/wrap.nix — consumed only by the synthesize materialization (a + # forward-aspect class collection, not a route-nesting concern). + collectClassMods = + cls: aspect: + let + own = lib.optional (aspect ? ${cls}) aspect.${cls}; + nested = builtins.concatMap (collectClassMods cls) (aspect.includes or [ ]); + in + own ++ nested; + + # A `den.default`-tagged module — root content shared across the entity chain. + isDenDefaultModule = mod: lib.hasSuffix "@default" (mod.key or mod._file or ""); + + # Root-scope `fromClass` content a child-scope COMPLEX forward may pull in. + # S-construction rule (§B Decision 2): when `fromClass` is owned by an entity in + # the chain, root content under it is that entity's OWN declaration (not + # aggregation fodder) — restrict to shared `den.default`. A forward-only custom + # class keeps its full root content. + filterRootModules = + scopeContexts: spec: rootModules: + let + childCtx = scopeContexts.${spec.sourceScopeId} or { }; + ownedClasses = + (childCtx.user.classes or [ ]) + ++ lib.optional (childCtx ? host) childCtx.host.class + ++ lib.optional (childCtx ? home) childCtx.home.class; + in + if builtins.elem spec.fromClass ownedClasses then + builtins.filter isDenDefaultModule rootModules + else + rootModules; + + # The "collected" source branch: a child-scope forward collects its own-scope + # fromClass modules PLUS the (filtered) root-scope fromClass modules; a root- + # scope (or rootless) forward collects the flat classImports aggregate. + getCollectedSource = + acc: spec: rootScopeId: scopeContexts: + let + sid = spec.sourceScopeId; + in + if rootScopeId != null && sid != rootScopeId then + let + ownModules = (acc.perScope.${sid} or { }).${spec.fromClass} or [ ]; + rootModules = (acc.perScope.${rootScopeId} or { }).${spec.fromClass} or [ ]; + in + filterRootModules scopeContexts spec rootModules ++ ownModules + else + acc.classImports.${spec.fromClass} or [ ]; + + # The "rewalk" source branch (fallback when collected == []): re-resolve the + # source aspect via spawnNode with FULL fleet visibility. `from = scopeParent` + # of sourceScopeId (the HOST scope) so the spawn's policyBoundAncestor sees + # fleet peers — using sourceScopeId directly gives a self-parent edge → zero + # peers (§B Decision 2 fleet-visibility deciding evidence). + resolveSourceFallback = + spec: spawnNode: scopeParent: + if !(spec ? sourceAspect) || spawnNode == null || !(spec ? sourceScopeId) then + [ ] + else + (spawnNode { + from = scopeParent.${spec.sourceScopeId} or spec.sourceScopeId; + class = spec.fromClass; + aspect = den.lib.aspects.normalizeRoot spec.sourceAspect; + bindings = { }; + }).imports; + + # Append synthesized modules to a class bucket at a scope (flat + perScope). + appendToClass = acc: cls: sid: newMods: { + classImports = acc.classImports // { + ${cls} = (acc.classImports.${cls} or [ ]) ++ newMods; + }; + perScope = acc.perScope // { + ${sid} = (acc.perScope.${sid} or { }) // { + ${cls} = ((acc.perScope.${sid} or { }).${cls} or [ ]) ++ newMods; + }; + }; + }; + + # Materialize ONE synthesize edge: build the source module (collected-else- + # rewalk), run it through the forward constructor (buildForwardAspect), collect + # its intoClass modules, and append to the intoClass bucket at sourceScopeId. + # The synthesize edge records identity (forwardSpec, intoClass); the CONTENT is + # constructed here at materialization time (spec §8: synthesize records identity, + # not content). + applyComplexRouteEdge = + acc: + { + route, + rootScopeId, + scopeContexts, + scopeParent, + spawnNode, + buildForwardAspect, + }: + let + spec = route; + collectedSource = getCollectedSource acc spec rootScopeId scopeContexts; + sourceModules = + if collectedSource != [ ] then + collectedSource + else + resolveSourceFallback spec spawnNode scopeParent; + sourceModule = spec.mapModule { imports = sourceModules; }; + newMods = collectClassMods spec.intoClass (buildForwardAspect spec sourceModule); + in + appendToClass acc spec.intoClass spec.sourceScopeId newMods; + + # Materialize ONE simple-route edge: classify (§B cell), collect source, run the + # nest/nest-verbatim/merge mode switch (materializeRouteEdge), append to the + # target bucket. The cell decision (classifyRoute), source collection + # (sourceModulesOf), and target scope (appendScopeIdOf) come from THIS file; the + # placement (mode switch) from materializeRouteEdge below. + applySimpleRouteEdge = + acc: + { + route, + wrappedPerScope, + scopeParent, + scopeIsolated, + }: + let + c = classifyRoute route; + sourceModules = sourceModulesOf { + inherit + route + wrappedPerScope + scopeParent + scopeIsolated + ; + }; + adapterMod = route.adapterModule or null; + modulesWithAdapter = if adapterMod == null then sourceModules else sourceModules ++ [ adapterMod ]; + # The §B materialize payload selecting the cell arm. + kind = + if c.hasInstantiate then + "instantiate" + else if modulesWithAdapter == [ ] then + "ensure-empty" + else if c.isAdapterRoute then + "adapter" + else + "nest"; + # cell 5 ensureTargetPath predicate (apply-time, content-aware): empty + # module set + adaptArgs + non-flake + path≠[]. + ensureTargetPath = + !c.isFlakeRoute && c.adaptArgs != null && c.path != [ ] && modulesWithAdapter == [ ]; + # cell 7 instantiate: eager evaluation at materialization. + instantiateEvaluated = + let + adaptArgsFn = route.adaptArgs or (_: { }); + extraArgs = adaptArgsFn { }; + in + if c.hasInstantiate then route.instantiate ({ modules = sourceModules; } // extraArgs) else null; + wrappedModules = materializeRouteEdge { + inherit kind ensureTargetPath instantiateEvaluated; + inherit (c) + path + adaptArgs + guard + reinstantiate + ; + modules = modulesWithAdapter; + sourceModules = sourceModules; + adapterPresent = adapterMod != null; + adapterRoute = route; + }; + in + appendToClass acc route.intoClass (appendScopeIdOf scopeParent route) wrappedModules; + + # The route fold: dedup + toposort routes, fold applying each (complex synthesize + # vs simple delivery edge). The ONLY consumer-facing route entry — resolve.nix + # and spawn-node thread their state in, get the assembled { classImports; perScope } + # back. Simple + complex routes are both delivery edges now; the phase-3 fold is + # the materialization of the route edge set in topo order. + applyRoutes = + { + scopedRoutes, + wrappedPerScope, + classImports, + scopeParent ? { }, + scopeIsolated ? { }, + scopeContexts ? { }, + spawnNode ? null, + rootScopeId ? null, + buildForwardAspect ? null, + }: + let + allRoutes = orderedKeptRoutes rootScopeId (lib.concatLists (lib.attrValues scopedRoutes)); + in + builtins.foldl' + ( + acc: route: + if route.__complexForward or false then + applyComplexRouteEdge acc { + inherit + route + rootScopeId + scopeContexts + scopeParent + spawnNode + buildForwardAspect + ; + } + else + applySimpleRouteEdge acc { + inherit + route + wrappedPerScope + scopeParent + scopeIsolated + ; + } + ) + { + inherit classImports; + perScope = wrappedPerScope; + } + allRoutes; in { inherit materializeNest + materializeRouteEdge mkAdapterFunctor sourceModulesOf classifyRoute appendScopeIdOf suppressionVerdicts - findChildScopeKeys keptRoutes orderedKeptRoutes topoSort + applyRoutes ; - # Compat alias: the old apply.nix `dedupRoutes` returned the kept (non- - # suppressed) routes in original order — exactly keptRoutes. Retained for the - # route/default.nix re-export; the extractor now consumes routeEdges directly. - dedupRoutes = keptRoutes; # ===== trace-facing route edge constructor (§8 identity, no content) === # Renders the simple+complex route specs as edge RECORDS for the oracle - # (edge-trace.nix). Identity + annotations only; suppression verdicts are now - # EXACT (the constructor's own dedup rules), not the v0 path-dependent - # approximation. sourceVia for complex forwards stays "unresolved" (Task 9). + # (edge-trace.nix). Identity + annotations only; suppression verdicts are + # EXACT (the constructor's own dedup rules), not a path-dependent approximation. + # + # sourceVia for complex forwards is PERMANENTLY "unresolved" — this is NOT a + # deferred annotation. The trace renders construction-time data (the edge + # identity triple), but the collected-else-rewalk source choice (§B Decision 2) + # is MATERIALIZATION-time path-dependent: it depends on whether `getCollectedSource` + # found content in the assembled `acc.perScope` AT the synthesize edge's fold + # position (which itself depends on provides + earlier simple routes feeding the + # source class). The synthesize edge records identity, not which branch fired + # (spec §8: synthesize records identity, not content), so "unresolved" is the + # correct, final disposition — recording a concrete branch here would require + # re-running the materialization the trace is meant to be independent of. # # name — sid → stable scope name (edge.nix scopeName). # scopeParent — parent DAG (for appendToParent target resolution). diff --git a/nix/lib/aspects/fx/resolve.nix b/nix/lib/aspects/fx/resolve.nix index a3050c549..5c4d0822e 100644 --- a/nix/lib/aspects/fx/resolve.nix +++ b/nix/lib/aspects/fx/resolve.nix @@ -9,10 +9,11 @@ let inherit (import ./wrap-classes.nix { inherit lib den; }) wrapCollectedClasses; inherit (import ./assemble-pipes.nix { inherit lib den; }) assemblePipes; inherit (import ./spawn-node.nix { inherit lib den; }) mkSpawnNode; - route = import ./route { inherit lib den; }; + routeEdges = import ./edges/route.nix { inherit lib den; }; inherit (import ./edge-trace.nix { inherit lib den; }) extractEdgeTrace; inherit (import ./scope-walk.nix { inherit lib; }) subtreeScopes dedupByKey; inherit (import ./edges/materialize.nix { inherit lib; }) assembleSubtree; + inherit (import ./edges/provides.nix { inherit lib den; }) applyProvidesEdges; handlers = den.lib.aspects.fx.handlers; # Check if `ancestor` is an ancestor of `descendant` in the scopeParent tree. @@ -26,15 +27,6 @@ let else parent == ancestor || isAncestorOf scopeParent ancestor parent; - # Dedup provides by composite key (policyName/class/path). - dedupProvides = dedupByKey ( - s: - let - pn = s.__providePolicyName or null; - in - if pn != null then "${pn}/${s.class}/${lib.concatStringsSep "/" (s.path or [ ])}" else null - ); - # Phase 1: Wrap collected class imports per-scope. # Deduplicates modules with identical keys across scopes: when a shared # aspect is included by both host and user, it emits class modules in @@ -60,46 +52,9 @@ let perScope = wrappedPerScope; }; - # Phase 2: Apply policy.provide — inject modules into target classes. - applyProvides = - ctx: scopeContexts: scopedProvides: acc: - let - allProvides = dedupProvides (lib.concatLists (lib.attrValues scopedProvides)); - in - builtins.foldl' ( - prev: spec: - let - targetClass = spec.class; - path = spec.path or [ ]; - sid = spec.sourceScopeId; - scopeCtx = scopeContexts.${sid} or ctx; - rawModule = if path == [ ] then spec.module else lib.setAttrByPath path spec.module; - wrapped = den.lib.aspects.fx.aspect.wrapClassModule { - inherit ctx; - module = rawModule; - aspectPolicy = null; - globalPolicy = null; - }; - wrappedMod = - if wrapped.unsatisfied or false then - [ ] - else - let - loc = "${targetClass}@/${lib.concatStringsSep "/" path}"; - in - [ (lib.setDefaultModuleLocation loc wrapped.module) ]; - in - { - classImports = prev.classImports // { - ${targetClass} = (prev.classImports.${targetClass} or [ ]) ++ wrappedMod; - }; - perScope = prev.perScope // { - ${sid} = (prev.perScope.${sid} or { }) // { - ${targetClass} = ((prev.perScope.${sid} or { }).${targetClass} or [ ]) ++ wrappedMod; - }; - }; - } - ) acc allProvides; + # Phase 2 (policy.provide → target classes) is now an edge constructor: + # edges/provides.nix applyProvidesEdges. The nest-into-source-bucket + # materialization + the (policyName/class/path) dedup live there (§B Decision 1). # Phase 3: Apply routes. The first positional is the node spawn primitive # (threaded with this pipeline's parent scope-tree state) used to resolve a @@ -107,13 +62,12 @@ let # isolated fxResolve fallback). applyRoutes = spawnNode: ctx: scopeContexts: rootScopeId: scopeParent: scopeIsolated: scopedRoutes: acc: - route.applyRoutes { + routeEdges.applyRoutes { inherit scopedRoutes scopeContexts scopeParent scopeIsolated - ctx rootScopeId spawnNode ; @@ -230,7 +184,7 @@ let subtreeRoutes = lib.filterAttrs (sid: _: isRelevant sid) scopedRoutes; relevantContexts = lib.genAttrs relevantScopeIds (sid: augmentedScopeContexts.${sid}); subtreePhase1 = wrapPerScope ctx subtreeContexts subtreeClassImports; - subtreePhase2 = applyProvides ctx relevantContexts subtreeProvides subtreePhase1; + subtreePhase2 = applyProvidesEdges ctx relevantContexts subtreeProvides subtreePhase1; subtreePhase3 = applyRoutes spawnNodeFn ctx relevantContexts hostScopeId scopeParent scopeIsolated subtreeRoutes subtreePhase2; @@ -545,7 +499,8 @@ let # runtime when a resolved aspect carries a complex non-collected forward, # and a finite forward nesting terminates. spawnNode = mkSpawnNode { - inherit wrapPerScope applyProvides applyRoutes; + inherit wrapPerScope applyRoutes; + applyProvides = applyProvidesEdges; inherit (den.lib.aspects) normalizeRoot; inherit (den.lib.aspects.fx.aspect) ctxFromHandlers; selfRef = spawnNode; @@ -695,7 +650,7 @@ let ) baseDrain (builtins.attrNames allHomeNodes); phase1 = wrapPerScope ctx augmentedScopeContexts drainedClassImportsRaw; - phase2 = applyProvides ctx augmentedScopeContexts scopedProvides phase1; + phase2 = applyProvidesEdges ctx augmentedScopeContexts scopedProvides phase1; phase3 = applyRoutes spawnNode ctx augmentedScopeContexts result.state.rootScopeId scopeParent scopeIsolated scopedRoutes @@ -737,7 +692,6 @@ let scopeEntityKind scopedProvides scopedRoutes - dedupProvides ; scopedClassImports = scopedClassImportsRaw; scopedSpawns = (result.state.scopedSpawns or (_: { })) null; @@ -798,14 +752,15 @@ let # so a nested complex forward inside a spawned node resolves its source via # the same fleet-visible spawn (matching resolveSourceFallback's contract). spawnNode = mkSpawnNode { - inherit wrapPerScope applyProvides applyRoutes; + inherit wrapPerScope applyRoutes; + applyProvides = applyProvidesEdges; inherit (den.lib.aspects) normalizeRoot; inherit (den.lib.aspects.fx.aspect) ctxFromHandlers; selfRef = spawnNode; } mkPipeline parentState; phase1 = wrapPerScope ctx augmentedScopeContexts scopedClassImportsRaw; - phase2 = applyProvides ctx augmentedScopeContexts (result.state.scopedProvides null) phase1; + phase2 = applyProvidesEdges ctx augmentedScopeContexts (result.state.scopedProvides null) phase1; phase3 = applyRoutes spawnNode ctx augmentedScopeContexts result.state.rootScopeId scopeParent scopeIsolated (result.state.scopedRoutes null) @@ -821,6 +776,5 @@ in fxResolveWithPaths fxResolveImports wrapCollectedClasses - dedupProvides ; } diff --git a/nix/lib/aspects/fx/route/apply.nix b/nix/lib/aspects/fx/route/apply.nix deleted file mode 100644 index cb5d9e4df..000000000 --- a/nix/lib/aspects/fx/route/apply.nix +++ /dev/null @@ -1,226 +0,0 @@ -# Apply registered routes — fold over deduped + toposorted route specs, -# dispatching complex (forward-derived, Task 9) vs simple (delivery-edge, Task 8). -# -# Simple routes are now DELIVERY EDGES: their §B-matrix classification, dedup, -# ordering, and source collection live in edges/route.nix; their nest / -# nest-verbatim / merge materialization lives in edges/materialize.nix's mode -# switch (materializeRouteEdge). This file keeps only the COMPLEX-forward -# (synthesize) path inline (filterRootModules / getCollectedSource / -# resolveSourceFallback / mkAdapterFunctor-for-complex), which Task 9 ports. -{ - lib, - den, - collectClassMods, -}: -let - routeEdges = import ../edges/route.nix { inherit lib; }; - inherit (import ../edges/materialize.nix { inherit lib; }) materializeRouteEdge; - inherit (routeEdges) - classifyRoute - sourceModulesOf - appendScopeIdOf - orderedKeptRoutes - ; - - # Root-scope `fromClass` content a child-scope COMPLEX forward may pull in. - # When `fromClass` is owned by an entity in the chain, root content under it is - # that entity's own declaration, not aggregation fodder — restrict to shared - # `den.default`. A forward-only custom class keeps its full root content. - filterRootModules = - scopeContexts: spec: rootModules: isDenDefaultModule: - let - childCtx = scopeContexts.${spec.sourceScopeId} or { }; - ownedClasses = - (childCtx.user.classes or [ ]) - ++ lib.optional (childCtx ? host) childCtx.host.class - ++ lib.optional (childCtx ? home) childCtx.home.class; - in - if builtins.elem spec.fromClass ownedClasses then - builtins.filter isDenDefaultModule rootModules - else - rootModules; - - getCollectedSource = - acc: spec: rootScopeId: isDenDefaultModule: scopeContexts: - let - sid = spec.sourceScopeId; - in - if rootScopeId != null && sid != rootScopeId then - let - ownModules = (acc.perScope.${sid} or { }).${spec.fromClass} or [ ]; - rootModules = (acc.perScope.${rootScopeId} or { }).${spec.fromClass} or [ ]; - in - filterRootModules scopeContexts spec rootModules isDenDefaultModule ++ ownModules - else - acc.classImports.${spec.fromClass} or [ ]; - - resolveSourceFallback = - spec: spawnNode: scopeParent: scopeContexts: ctx: - if !(spec ? sourceAspect) || spawnNode == null || !(spec ? sourceScopeId) then - [ ] - else - (spawnNode { - from = scopeParent.${spec.sourceScopeId} or spec.sourceScopeId; - class = spec.fromClass; - aspect = den.lib.aspects.normalizeRoot spec.sourceAspect; - bindings = { }; - }).imports; - - appendToClass = acc: cls: sid: newMods: { - classImports = acc.classImports // { - ${cls} = (acc.classImports.${cls} or [ ]) ++ newMods; - }; - perScope = acc.perScope // { - ${sid} = (acc.perScope.${sid} or { }) // { - ${cls} = ((acc.perScope.${sid} or { }).${cls} or [ ]) ++ newMods; - }; - }; - }; - - # Task 9: scheduled deletion (complex-forward port). - applyComplexRoute = - acc: - { - route, - rootScopeId, - scopeContexts, - scopeParent, - ctx, - spawnNode, - buildForwardAspect, - isDenDefaultModule, - }: - let - spec = route; - collected = getCollectedSource acc spec rootScopeId isDenDefaultModule scopeContexts; - sourceModules = - if collected != [ ] then - collected - else - resolveSourceFallback spec spawnNode scopeParent scopeContexts ctx; - sourceModule = spec.mapModule { imports = sourceModules; }; - newMods = collectClassMods spec.intoClass (buildForwardAspect spec sourceModule); - in - appendToClass acc spec.intoClass spec.sourceScopeId newMods; - - isDenDefaultModule = mod: lib.hasSuffix "@default" (mod.key or mod._file or ""); - - # Simple route → delivery edge → materialized module list, appended to the - # target bucket. The §B cell decision (classifyRoute), source collection - # (sourceModulesOf), and target scope (appendScopeIdOf) come from - # edges/route.nix; the nest/nest-verbatim/merge placement from - # materializeRouteEdge (the mode switch). - applySimpleRoute = - acc: - { - route, - wrappedPerScope, - scopeParent, - scopeIsolated, - }: - let - c = classifyRoute route; - sourceModules = sourceModulesOf { - inherit - route - wrappedPerScope - scopeParent - scopeIsolated - ; - }; - adapterMod = route.adapterModule or null; - modulesWithAdapter = if adapterMod == null then sourceModules else sourceModules ++ [ adapterMod ]; - # The §B materialize payload selecting the cell arm. - kind = - if c.hasInstantiate then - "instantiate" - else if modulesWithAdapter == [ ] then - "ensure-empty" - else if c.isAdapterRoute then - "adapter" - else - "nest"; - # cell 5 ensureTargetPath predicate (apply-time, content-aware): empty - # module set + adaptArgs + non-flake + path≠[]. - ensureTargetPath = - !c.isFlakeRoute && c.adaptArgs != null && c.path != [ ] && modulesWithAdapter == [ ]; - # cell 7 instantiate: eager evaluation at materialization. - instantiateEvaluated = - let - adaptArgsFn = route.adaptArgs or (_: { }); - extraArgs = adaptArgsFn { }; - in - if c.hasInstantiate then route.instantiate ({ modules = sourceModules; } // extraArgs) else null; - wrappedModules = materializeRouteEdge { - inherit kind ensureTargetPath instantiateEvaluated; - inherit (c) - path - adaptArgs - guard - reinstantiate - ; - modules = modulesWithAdapter; - sourceModules = sourceModules; - adapterPresent = adapterMod != null; - adapterRoute = route; - }; - in - appendToClass acc route.intoClass (appendScopeIdOf scopeParent route) wrappedModules; - - # Main entry: dedup + toposort routes, fold applying each (complex vs simple). - applyRoutes = - { - scopedRoutes, - wrappedPerScope, - classImports, - scopeParent ? { }, - scopeIsolated ? { }, - scopeContexts ? { }, - ctx ? { }, - spawnNode ? null, - rootScopeId ? null, - buildForwardAspect ? null, - }: - let - allRoutes = orderedKeptRoutes rootScopeId (lib.concatLists (lib.attrValues scopedRoutes)); - in - builtins.foldl' - ( - acc: route: - if route.__complexForward or false then - applyComplexRoute acc { - inherit - route - rootScopeId - scopeContexts - scopeParent - ctx - spawnNode - buildForwardAspect - isDenDefaultModule - ; - } - else - applySimpleRoute acc { - inherit - route - wrappedPerScope - scopeParent - scopeIsolated - ; - } - ) - { - inherit classImports; - perScope = wrappedPerScope; - } - allRoutes; -in -{ - # applyRoutes is the only consumer-facing entry: the route fold. Simple routes - # are delivery edges (edges/route.nix + edges/materialize.nix); the old - # dedupRoutes/findChildScopeKeys exports (consumed by the v0 edge-trace arm) - # are dead now that the oracle renders routes via the shared routeEdges - # constructor — dropped here. Suppression/dedup lives in edges/route.nix. - inherit applyRoutes; -} diff --git a/nix/lib/aspects/fx/route/default.nix b/nix/lib/aspects/fx/route/default.nix deleted file mode 100644 index c7abd80f1..000000000 --- a/nix/lib/aspects/fx/route/default.nix +++ /dev/null @@ -1,18 +0,0 @@ -# Route module delivery — move modules between entity scopes/classes. -{ - lib, - den, - ... -}: -let - inherit (import ./wrap.nix { inherit lib den; }) collectClassMods; - inherit - (import ./apply.nix { - inherit lib den collectClassMods; - }) - applyRoutes - ; -in -{ - inherit applyRoutes; -} diff --git a/nix/lib/aspects/fx/route/wrap.nix b/nix/lib/aspects/fx/route/wrap.nix deleted file mode 100644 index 54f08b3bb..000000000 --- a/nix/lib/aspects/fx/route/wrap.nix +++ /dev/null @@ -1,22 +0,0 @@ -# Route forward-aspect collection. -# -# The simple-route module wrapping (path nesting, guards, adaptArgs, verbatim, -# the #572 combine) moved to edges/route.nix (Task 8 — simple routes are -# delivery edges; the nest/nest-verbatim mode mechanics live in the materializer -# mode switch). Only `collectClassMods` remains here — it is consumed by the -# COMPLEX-forward path (route/apply.nix:applyComplexRoute, Task 9) to collect a -# forward aspect's class modules, NOT a route-nesting concern. -{ lib, ... }: -let - # Collect class modules from a forward aspect (recursing into includes). - collectClassMods = - cls: aspect: - let - own = lib.optional (aspect ? ${cls}) aspect.${cls}; - nested = builtins.concatMap (collectClassMods cls) (aspect.includes or [ ]); - in - own ++ nested; -in -{ - inherit collectClassMods; -} From faf69fe98fd8ede28190d79f513664f79038cdc7 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 12 Jun 2026 18:47:37 -0700 Subject: [PATCH 052/101] docs(fx): fix stale post-move comment paths --- nix/lib/aspects/fx/edge-trace.nix | 2 +- nix/lib/aspects/fx/edges/provides.nix | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nix/lib/aspects/fx/edge-trace.nix b/nix/lib/aspects/fx/edge-trace.nix index ceeeee100..e28b730c6 100644 --- a/nix/lib/aspects/fx/edge-trace.nix +++ b/nix/lib/aspects/fx/edge-trace.nix @@ -120,7 +120,7 @@ in # ===== route edges ================================================= # Rendered by the SHARED route constructor (edges/route.nix routeEdges) — # the SAME constructor production materializes simple routes through - # (route/apply.nix → edges/materialize.nix). The oracle no longer re-derives + # (edges/route.nix applyRoutes → materializeRouteEdge). The oracle no longer re-derives # suppression: the constructor's own dedup/suppression rules are EXACT here # (the `suppressed`/`suppressedByChildKey` annotations are the production # decisions, not the v0 approximation). Complex forwards keep diff --git a/nix/lib/aspects/fx/edges/provides.nix b/nix/lib/aspects/fx/edges/provides.nix index 7a5af77fd..dbbdbb7cb 100644 --- a/nix/lib/aspects/fx/edges/provides.nix +++ b/nix/lib/aspects/fx/edges/provides.nix @@ -49,7 +49,7 @@ let # collectible, visible to a later route's getCollectedSource / subtree walk). # # ctx — the pipeline base ctx (fallback when a scope has no context). - # scopeContexts — sid → context (for the wrap's collision policy resolution). + # scopeContexts — sid → context (UNREAD; kept for signature parity, reworked in Task 10/11). # scopedProvides — sid → [ provide specs ] (the registered provides). # acc — { classImports; perScope; } (phase-1 output). applyProvidesEdges = From 8d719258754cda9683571028bc9a89e6f7c2623b Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 12 Jun 2026 18:59:23 -0700 Subject: [PATCH 053/101] feat(fx): spawn projection as assembleSubtree over blind dedup-free edges Dissolve spawn-node's inline phase1(wrapPerScope) -> phase2(applyProvides own-only) -> phase3(applyRoutes mergedSpawnRoutes) -> isolation-blind dedup-free subtree concat into one assembleSpawnSubtree call (new entry in edges/materialize.nix), routing the spawn final extraction through assembleSubtree. The Task-7 deferral's two blockers (different allScopeIds source; dedup-free extraction) are resolved by extending the Pi record/materializer with two dials, not by keeping the inline block: - dedupMode ? "dedup": spawn passes "raw" for the dedup-free concat (phase1 wrapPerScope already key-deduped into the perScope buckets; the cross-scope final concat must not re-dedup). collectMerge's raw arm iterates perScope attrnames filtered by subtree membership -- the spawn's exact prior iteration, preserving load-bearing module-list order. - allScopeIds ? null: spawn passes mergedScopeParent + scopedRoutes keys (a route-only scope can sit on the subtree parent-chain without a perScope bucket -- wider than perScope alone). Both dials default to canonical merge, so the per-host and entity-root re-entries are untouched. mergedSpawnRoutes (census #4 deliberate edge-identity dedup) stays as the route-merge construction in spawn-node; census #3 (own provides only), #6 (blind extraction via isolationMode), and #2-C (host-bound pipe-key stripping) preserved exactly. The _assertRoot self-parent guard keeps its augmented-forced laziness. Injection seam kept (no cycle); drain-fold spawnNode call left imperative. delivery-edges 14/14 byte-stable (incl. host-aspects-spawn rewalk fixture), full ci 961/961. --- nix/lib/aspects/fx/edges/materialize.nix | 147 +++++++++++++++++++++-- nix/lib/aspects/fx/spawn-node.nix | 88 ++++++++------ 2 files changed, 188 insertions(+), 47 deletions(-) diff --git a/nix/lib/aspects/fx/edges/materialize.nix b/nix/lib/aspects/fx/edges/materialize.nix index 2beb0e852..b864be36e 100644 --- a/nix/lib/aspects/fx/edges/materialize.nix +++ b/nix/lib/aspects/fx/edges/materialize.nix @@ -4,7 +4,7 @@ # re-entries' final extraction; phase ordering becomes edge toposort (corollary # 5) as later mechanisms are ported. # -# Tasks 7–9 are complete: `merge` (default-fold port) is live here; `nest`/ +# Tasks 7–10 are complete: `merge` (default-fold port) is live here; `nest`/ # `nest-verbatim` (route delivery via materializeRouteEdge) and the synthesize # (complex-forward) + provides edge materialization live in edges/route.nix + # edges/provides.nix. The else-throw in the `materialize` switch below is NOT a @@ -12,6 +12,11 @@ # route nest/nest-verbatim + synthesize delivery folds through edges/route.nix # (applyRoutes → materializeRouteEdge), provides through edges/provides.nix. # +# Task 10 (spawn port) is `assembleSpawnSubtree` (below): the spawn's full phase +# fold + its isolation-BLIND, dedup-FREE final extraction, expressed over this +# machinery via the `isolationMode = "blind"` + `dedupMode = "raw"` Π dials and an +# explicit `allScopeIds` subtree-universe override. +# # DESIGN INVARIANTS (spec §2 corollaries; enforced by the entity-isolation suite # and the delivery-edges fixtures): # - `materialize` contains the ONLY mode switch (merge | nest | nest-verbatim). @@ -57,6 +62,21 @@ rec { # # read inside the mode switch. # isolationMode; # §6 `aware` (default) | `blind` (spawn final extraction # # invariant). EXPLICIT — never defaulted. + # dedupMode ? "dedup"; # the merge-mode collection dial. "dedup" (default) = + # # first-occurrence-wins cross-scope key dedup + # # (extractSubtreeModules / wrapPerScope semantics). + # # "raw" = dedup-FREE concat (the spawn final-extraction + # # invariant, Task 10): the spawn's phase1 wrapPerScope + # # already key-deduped INTO the perScope buckets, and the + # # final per-scope concat must NOT re-dedup across scopes + # # (a duplicate cross-scope module is a deliberate keyless + # # re-emission the target's own evalModules reconciles). + # allScopeIds ? null; # optional subtree-universe override. null ⇒ derive from + # # perScope attrnames (the entity-root/per-host re-entries). + # # The spawn re-entry (Task 10) passes mergedScopeParent ∪ + # # scopedRoutes keys EXPLICITLY: a route-only scope can sit + # # on the subtree parent-chain without a perScope bucket, so + # # the membership universe is WIDER than perScope alone. # classInject ? null; # §1 the resolved entity class to inject into context # # args; no observable witness — defensive projection, # # default off. Not consumed this task. @@ -77,17 +97,30 @@ rec { throw "den materialize: isolationMode must be \"aware\" | \"blind\", got ${builtins.toJSON pi.isolationMode}"; # Collect the merge source for a (root, class) target: the class bucket of the - # already-bounded subtree, key-deduped first-occurrence-wins. This is exactly - # the wrapPerScope cross-scope dedup + extractSubtreeModules semantics - # (resolve.nix), now expressed as the merge-mode materialization rule. + # already-bounded subtree. With dedupMode = "dedup" (default) this is the + # wrapPerScope cross-scope dedup + extractSubtreeModules semantics (first- + # occurrence-wins by key); with dedupMode = "raw" it is a dedup-FREE concat + # (the spawn final-extraction invariant — phase1 already key-deduped into the + # buckets, so a remaining cross-scope duplicate is a deliberate keyless + # re-emission, not a dedup target). # perScope — sid → { class → [ modules ] } (the wrapped buckets). # subtreeScopeIds — the resolved, isolation-bounded scope list. + # dedupMode — "dedup" | "raw". collectMerge = - perScope: subtreeScopeIds: cls: - let - raw = lib.concatMap (sid: perScope.${sid}.${cls} or [ ]) subtreeScopeIds; - in - dedupByKey (m: m.key or null) raw; + perScope: subtreeScopeIds: dedupMode: cls: + if dedupMode == "raw" then + # Dedup-free: iterate the perScope buckets in perScope-attrname order + # (the spawn final extraction's exact iteration), restricted to subtree + # membership. Order is load-bearing without a key-dedup, so we walk + # perScope keys directly rather than the allScopeIds-ordered subtree list. + let + member = lib.genAttrs subtreeScopeIds (_: true); + in + lib.concatMap (sid: perScope.${sid}.${cls} or [ ]) ( + builtins.filter (sid: member ? ${sid}) (builtins.attrNames perScope) + ) + else + dedupByKey (m: m.key or null) (lib.concatMap (sid: perScope.${sid}.${cls} or [ ]) subtreeScopeIds); # materialize: Π + an edge list → { class → [ modules ] }. The ONLY mode # switch for default-fold extraction. The merge arm here is the per-root final @@ -104,10 +137,11 @@ rec { cls = edge.target.class; in if edge.mode == "merge" then - # merge: key-deduped module-list union of the bounded subtree's bucket. + # merge: module-list union of the bounded subtree's bucket, deduped or + # raw per ctx.dedupMode (the spawn final extraction is dedup-free). acc // { - ${cls} = (acc.${cls} or [ ]) ++ collectMerge ctx.perScope ctx.subtreeScopeIds cls; + ${cls} = (acc.${cls} or [ ]) ++ collectMerge ctx.perScope ctx.subtreeScopeIds ctx.dedupMode cls; } else throw "den materialize: assembleSubtree edge mode ${builtins.toJSON edge.mode} (nest/nest-verbatim route delivery folds through edges/route.nix:materializeRouteEdge, not assembleSubtree, until Tasks 10–11)"; @@ -129,7 +163,14 @@ rec { assembleSubtree = { root, pi }: let - allScopeIds = builtins.attrNames pi.perScope; + # The subtree-membership universe. Default = perScope attrnames (the entity- + # root / per-host re-entries). The spawn re-entry passes pi.allScopeIds + # explicitly (mergedScopeParent ∪ scopedRoutes keys) because a route-only + # scope can sit on the subtree parent-chain without a perScope bucket. + allScopeIds = pi.allScopeIds or (builtins.attrNames pi.perScope); + # The merge collection dial (§A spawn invariant): default dedup; "raw" = + # dedup-free concat (spawn final extraction). + dedupMode = pi.dedupMode or "dedup"; # The isolation set the subtree boundary uses, governed by pi.isolationMode. # Computed ONCE here so the merge-collection walk and the edge constructor's # own internal subtree walk agree on the boundary. @@ -162,6 +203,86 @@ rec { in materialize pi { inherit (pi) perScope; - inherit subtreeScopeIds; + inherit subtreeScopeIds dedupMode; } edges; + + # assembleSpawnSubtree: the spawn node's full phase-fold + final extraction, + # expressed over the edge machinery (Task 10). The spawn's inline + # phase1(wrapPerScope) → phase2(applyProvides) → phase3(applyRoutes) → + # isolation-BLIND dedup-FREE subtree concat is reproduced here as ONE entry: + # the phase fold builds the perScope buckets, then `assembleSubtree` performs + # the final per-root extraction with the spawn's two distinguishing dials — + # `isolationMode = "blind"` (census #6 documented invariant: no isolated + # descendant can appear under a spawnRoot, since isolated kinds are created by + # resolve.to in the HOST pipeline, never via spawnNode) and `dedupMode = "raw"` + # (the phase1 wrapPerScope already key-deduped INTO the buckets; the final + # cross-scope concat must NOT re-dedup — a remaining duplicate is a deliberate + # keyless re-emission the target's own evalModules reconciles). + # + # The phase primitives are passed IN (wrapPerScope/applyProvides/applyRoutes), + # so this helper introduces no resolve.nix import — the spawn keeps its existing + # injection seam; only the inline phase CALL expressions move here. + # + # class — the single class this spawn node materializes. + # spawnRoot — the spawn subtree root. + # ctx — the pipeline base ctx (phase fallback context). + # augmented — the spawn's assemblePipes-augmented contexts. + # mergedClassImports — phase1 source (parent + spawned, pipe-stripped). + # mergedScopeParent — the merged parent DAG (spawnRoot linked up to host). + # mergedScopeIsolated — merged isolation marks (inert under blind mode). + # ownProvides — the spawn's OWN provides (census #3: parent provides + # are deliberately NOT reapplied). + # mergedSpawnRoutes — parent-subtree routes (routeKey-deduped) ⊕ spawn own + # (census #4: parent routes MUST merge into the spawn). + # allScopeIds — the subtree-membership universe (mergedScopeParent ∪ + # scopedRoutes keys — WIDER than perScope alone). + # selfRef — the spawn primitive (nested-forward resolver). + # wrapPerScope/applyProvides/applyRoutes — the injected phase primitives. + assembleSpawnSubtree = + { + class, + spawnRoot, + ctx, + augmented, + mergedClassImports, + mergedScopeParent, + mergedScopeIsolated, + ownProvides, + mergedSpawnRoutes, + allScopeIds, + selfRef, + wrapPerScope, + applyProvides, + applyRoutes, + }: + let + phase1 = wrapPerScope ctx augmented mergedClassImports; + phase2 = applyProvides ctx augmented ownProvides phase1; + phase3 = + applyRoutes selfRef ctx augmented spawnRoot mergedScopeParent mergedScopeIsolated mergedSpawnRoutes + phase2; + pi = { + perScope = phase3.perScope; + classImports = phase3.classImports; + scopeContexts = augmented; + contextsAreAugmented = true; + provides = ownProvides; + routes = mergedSpawnRoutes; + rootScopeId = spawnRoot; + scopeParent = mergedScopeParent; + scopeIsolated = mergedScopeIsolated; + # Census #6 invariant + #4 spawn merge + the dedup-free extraction dial. + isolationMode = "blind"; + dedupMode = "raw"; + inherit allScopeIds; + classInject = null; + }; + assembled = assembleSubtree { + root = spawnRoot; + inherit pi; + }; + in + { + imports = assembled.${class} or [ ]; + }; } diff --git a/nix/lib/aspects/fx/spawn-node.nix b/nix/lib/aspects/fx/spawn-node.nix index 615251229..03aeb98c9 100644 --- a/nix/lib/aspects/fx/spawn-node.nix +++ b/nix/lib/aspects/fx/spawn-node.nix @@ -11,6 +11,7 @@ let inherit (import ./assemble-pipes.nix { inherit lib den; }) assemblePipes; inherit (import ./scope-walk.nix { inherit lib; }) subtreeScopes; inherit (import ./handlers/route.nix { inherit lib; }) routeKey; + inherit (import ./edges/materialize.nix { inherit lib; }) assembleSpawnSubtree; pipeNamesSet = lib.genAttrs (builtins.attrNames (den.quirks or { })) (_: true); in { @@ -115,31 +116,39 @@ in hostConfigs = null; }); - # 4. Phases 1-3 over the spawned subtree; class isolation -> one class emitted. - phase1 = wrapPerScope parentState.ctx augmented mergedClassImports; - phase2 = applyProvides parentState.ctx augmented (result.state.scopedProvides null) phase1; - # Parent-pipeline routes sourced inside the spawned subtree must apply - # here too: the spawn re-emits class content at the same scope ids but - # never re-fires schema policies, so without them a user-schema route - # (homeLinux->homeManager) never fires and the content drops. - # Dedup against the spawn's own registrations — an aspect-borne route - # can register in both pipelines, and a duplicated path != [] simple - # route would re-nest content in fresh keyless wrappers and conflict - # at the target. + # The subtree-membership universe (census #4 + Task-5 fix): the merged + # parent DAG keys ∪ the route-scope keys. WIDER than perScope: a route-only + # scope can sit on the subtree parent-chain without a class bucket. Both the + # parentSubtreeRoutes filter (below) and the final extraction (inside + # assembleSpawnSubtree, via Π.allScopeIds) walk over this same universe. + spawnAllScopeIds = lib.unique ( + builtins.attrNames mergedScopeParent ++ builtins.attrNames parentState.scopedRoutes + ); + # Isolation-BLIND subtree membership rooted at spawnRoot, over the merged # parent DAG. `isolated = {}` is passed EXPLICITLY (census #6 documented # invariant: isolated entities resolve via resolve.to in the host pipeline, # never through spawnNode, so no isolated descendant can appear under - # spawnRoot). Walked over mergedScopeParent + route-scope keys (NOT - # phase3.perScope) to avoid a cycle: phase3 depends on parentSubtreeRoutes. + # spawnRoot). Used ONLY for the parentSubtreeRoutes filter; the final + # extraction's identical blind walk happens inside assembleSpawnSubtree + # (Π.isolationMode = "blind"), both over spawnAllScopeIds — one shared walk. subtreeSet = lib.genAttrs (subtreeScopes { scopeParent = mergedScopeParent; isolated = { }; root = spawnRoot; - allScopeIds = lib.unique ( - builtins.attrNames mergedScopeParent ++ builtins.attrNames parentState.scopedRoutes - ); + allScopeIds = spawnAllScopeIds; }) (_: true); + + # Census #4 (DELIBERATE): parent-pipeline routes sourced inside the spawned + # subtree MUST re-apply — the spawn re-emits class content at the same scope + # ids but never re-fires schema policies, so without them a user-schema route + # (homeLinux->homeManager) never fires and the content drops. This is the + # `mergedSpawnRoutes` edge-identity dedup: the spawn's OWN route edges win + # over parent-subtree route edges with the same routeKey identity (an + # aspect-borne route can register in both pipelines; a duplicated path != [] + # simple route would re-nest content in fresh keyless wrappers and conflict + # at the target). Order/precedence preserved exactly: freshParent (parent + # routes whose key ∉ spawn keys) ++ spawnHere. spawnRoutes = result.state.scopedRoutes null; parentSubtreeRoutes = lib.filterAttrs (sid: _: subtreeSet ? ${sid}) parentState.scopedRoutes; mergedSpawnRoutes = @@ -154,24 +163,35 @@ in freshParent ++ spawnHere ) parentSubtreeRoutes; - phase3 = - applyRoutes selfRef parentState.ctx augmented spawnRoot mergedScopeParent mergedScopeIsolated - mergedSpawnRoutes - phase2; - - # Restrict extraction to the spawned subtree (spawnRoot + descendants). - # phase3.classImports aggregates across ALL merged scopes — including the - # host and SIBLING user scopes (the pipe-collection peers, and other users - # on the same host) — so reading it directly would leak a peer user's - # homeManager content into this node. The fleet pipe values still resolve - # correctly because assemblePipes ran over the full merged state; only the - # final per-scope class buckets are subtree-restricted here (subtreeSet, - # the isolation-blind membership defined above with the parentSubtreeRoutes - # filter — both consumers share one blind walk). + # The spawn's full phase fold + isolation-BLIND, dedup-FREE final extraction, + # expressed over the edge machinery (Task 10). The phase primitives are + # forwarded (injection seam preserved — no resolve.nix import cycle); the + # inline phase1/phase2/phase3 + subtree concat dissolved into one entry. + # phase3.classImports aggregates across ALL merged scopes (host + sibling + # users), so the extraction is subtree-restricted via Π.allScopeIds + + # isolationMode="blind" to avoid leaking a peer user's content; fleet pipe + # values still resolve correctly because assemblePipes ran over the full + # merged state before the fold. in - { - imports = lib.concatMap (sid: phase3.perScope.${sid}.${class} or [ ]) ( - builtins.filter (sid: subtreeSet ? ${sid}) (builtins.attrNames phase3.perScope) - ); + # The self-parent assert is forced via `augmented` (which the phase fold + # reads), matching the prior inline form's laziness: the throw surfaces only + # when this node's content is actually collected, not at attrset construction. + assembleSpawnSubtree { + inherit + class + spawnRoot + mergedScopeParent + mergedScopeIsolated + mergedSpawnRoutes + selfRef + wrapPerScope + applyProvides + applyRoutes + ; + ctx = parentState.ctx; + inherit augmented; + inherit mergedClassImports; + ownProvides = result.state.scopedProvides null; + allScopeIds = spawnAllScopeIds; }; } From 383c752e8f28fa0a24da66b27ed90ed4a036bc40 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 12 Jun 2026 21:07:41 -0700 Subject: [PATCH 054/101] docs(fx): clarify mode-switch invariant carve-out for Pi-builders --- nix/lib/aspects/fx/edges/materialize.nix | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/nix/lib/aspects/fx/edges/materialize.nix b/nix/lib/aspects/fx/edges/materialize.nix index b864be36e..4f9253b8b 100644 --- a/nix/lib/aspects/fx/edges/materialize.nix +++ b/nix/lib/aspects/fx/edges/materialize.nix @@ -19,9 +19,13 @@ # # DESIGN INVARIANTS (spec §2 corollaries; enforced by the entity-isolation suite # and the delivery-edges fixtures): -# - `materialize` contains the ONLY mode switch (merge | nest | nest-verbatim). -# - It carries NO mechanism vocabulary (no route/provides/spawn/instantiate -# names): mechanisms are dissolved into edges before they reach here. +# - `materialize` (the mode switch) contains the ONLY mode switch +# (merge | nest | nest-verbatim). +# - The mode switch carries NO mechanism vocabulary (no route/provides/spawn/ +# instantiate names): mechanisms are dissolved into edges before they reach +# it. Mechanism-specific Π-*builders* (e.g. `assembleSpawnSubtree`) MAY name +# their mechanism — they construct mechanism-shaped Π for the generic +# consumer; only the switch itself stays vocabulary-free. # - It performs NO isolation-flag reads: isolation is consumed at edge # CONSTRUCTION (corollary 2 — isolation is edge-absence). `assembleSubtree` # resolves the subtree boundary (via scope-walk.subtreeScopes, governed by From f643ad5fe179dc0ec1dfe0ff576bbab90534a8d2 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 12 Jun 2026 21:15:55 -0700 Subject: [PATCH 055/101] refactor(fx): dissolve findHostScopeId via scope-creation link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace findHostScopeId's name-infix heuristic with a spec->scope link recorded at scope creation. push-scope stamps the entity scope it creates into scopeByEntity, keyed by (parentScope, id_hash) — the same parent the instantiate spec is registered at, and the same entity record (hence id_hash) the spec carries. Both call sites (mkInstantiateArgs phase4 + hostConfigs B') resolve through entityScopeFor over that link. The (parent, id_hash) key handles multi-system same-name entities: id_hash is context-free (kind+name, not ancestry), so two same-name homes on different systems share an id_hash but have distinct system= parent scopes. The single-child fallback becomes the explicit T rule: a spec without a recorded entity scope (no id_hash / no link) targets its source scope's root (caller falls through to sourceScopeId). edge-trace resolvedRootVia annotation: "name-infix" -> "scope-link". --- nix/lib/aspects/fx/edge-trace.nix | 15 ++-- nix/lib/aspects/fx/handlers/push-scope.nix | 14 ++++ nix/lib/aspects/fx/pipeline.nix | 9 +++ nix/lib/aspects/fx/resolve.nix | 70 ++++++++----------- .../ci/modules/internal-api/edge-trace.nix | 10 +-- 5 files changed, 67 insertions(+), 51 deletions(-) diff --git a/nix/lib/aspects/fx/edge-trace.nix b/nix/lib/aspects/fx/edge-trace.nix index e28b730c6..4d9777867 100644 --- a/nix/lib/aspects/fx/edge-trace.nix +++ b/nix/lib/aspects/fx/edge-trace.nix @@ -181,8 +181,9 @@ in # disambiguation by reusing the grouping INPUTS (path + system metadata # only — never spec.instantiate, matching disambiguated's contract) and # annotate collisions (disambiguatedTo). resolvedRootVia annotation = - # "name-infix" with the hostScopeId findHostScopeId currently returns - # (the heuristic to dissolve in Task 11). + # "scope-link": the entity scope is resolved from the scopeByEntity link + # recorded at scope creation (push-scope), NOT reconstructed by name-infix + # (findHostScopeId dissolved in Task 11). allInstantiates = builtins.concatLists (lib.attrValues scopedInstantiates); # Spec descriptors with output, mirroring applyInstantiates:specDescriptors. instDescriptors = builtins.concatLists ( @@ -222,10 +223,10 @@ in entry: let spec = entry.spec; - # findHostScopeId is a let-binding inside resolve.nix (not - # exported); we record the resolution VIA, not the heuristic. The - # spec carries sourceScopeId; the host scope it resolves to is a - # child of that by name-infix. We annotate resolvedRootVia only. + # The entity scope is resolved by the (parentScope, id_hash) link + # recorded at scope creation (resolve.nix entityScopeFor over + # scopeByEntity); the trace records the resolution VIA, not the + # scope. The source is annotated as the spec's sourceScopeId. outPath = if isMultiSystem then lib.init entry.path ++ [ "${lib.last entry.path}@${entry.system}" ] @@ -240,7 +241,7 @@ in path = [ ]; mode = "merge"; annotations = { - resolvedRootVia = "name-infix"; + resolvedRootVia = "scope-link"; inherit (entry) system; } // lib.optionalAttrs isMultiSystem { diff --git a/nix/lib/aspects/fx/handlers/push-scope.nix b/nix/lib/aspects/fx/handlers/push-scope.nix index abf0e8f65..b5f1bd9f4 100644 --- a/nix/lib/aspects/fx/handlers/push-scope.nix +++ b/nix/lib/aspects/fx/handlers/push-scope.nix @@ -34,9 +34,22 @@ let prevEntityKind = (state.scopeEntityKind or (_: { })) null; prevSourcePolicy = (state.scopeSourcePolicy or (_: { })) null; prevIsolated = (state.scopeIsolated or (_: { })) null; + prevScopeByEntity = (state.scopeByEntity or (_: { })) null; updatedContexts = prevContexts // { ${newScopeId} = scopedCtx; }; + # Spec→scope link: record the entity scope this push created, keyed by + # (parentScope, id_hash). The instantiate spec — registered at the same + # parent scope and carrying the same entity record — resolves its scope + # via this link instead of findHostScopeId's name-infix reconstruction. + # Only entity scopes (entityKind set, record carries id_hash) are linked. + entityRecord = if entityKind == null then null else scopedCtx.${entityKind} or null; + entityIdHash = if entityRecord == null then null else entityRecord.id_hash or null; + updatedScopeByEntity = + prevScopeByEntity + // lib.optionalAttrs (entityIdHash != null) { + "${parentScope}\n${entityIdHash}" = newScopeId; + }; updatedParent = prevParent // lib.optionalAttrs (!isSameScope) { ${newScopeId} = parentScope; }; updatedPolicies = prevPolicies // { ${newScopeId} = prevPolicies.${newScopeId} or { }; @@ -67,6 +80,7 @@ let scopeEntityKind = _: updatedEntityKind; scopeSourcePolicy = _: updatedSourcePolicy; scopeIsolated = _: updatedIsolated; + scopeByEntity = _: updatedScopeByEntity; }; }; }; diff --git a/nix/lib/aspects/fx/pipeline.nix b/nix/lib/aspects/fx/pipeline.nix index ded49faee..3166a9643 100644 --- a/nix/lib/aspects/fx/pipeline.nix +++ b/nix/lib/aspects/fx/pipeline.nix @@ -169,6 +169,15 @@ let currentScope = "__unscoped"; scopeContexts = _: { }; scopeParent = _: { }; + # Spec→scope link (replaces findHostScopeId's name-infix heuristic): when + # resolve.to creates an entity scope (push-scope with entityKind set), record + # the scope it created keyed by (parentScope, entity id_hash). An instantiate + # spec — registered at the SAME parent scope, carrying the same entity record + # (hence id_hash) — looks its entity scope up directly. Key combines parent + + # id_hash because id_hash is context-free (kind+name, NOT ancestry), so two + # same-name entities on different systems share an id_hash but have distinct + # parent (system=…) scopes. See resolve.nix entityScopeFor. + scopeByEntity = _: { }; # --- Policy dispatch tracking --- firedPolicyNames = _: { }; diff --git a/nix/lib/aspects/fx/resolve.nix b/nix/lib/aspects/fx/resolve.nix index 5c4d0822e..a2c9b7041 100644 --- a/nix/lib/aspects/fx/resolve.nix +++ b/nix/lib/aspects/fx/resolve.nix @@ -77,46 +77,29 @@ let }; # Phase 4: Apply entity instantiation. - # Find the host scope ID for an instantiate spec. + # Resolve the entity scope an instantiate spec targets. + # # register-instantiate records sourceScopeId = currentScope (the parent, e.g. - # flake-system), but the entity's scope was created by resolve.to as a child. - # Search child scopes of sourceScopeId matching the entity name. - findHostScopeId = - scopeParent: allScopeIds: spec: + # flake-system); the entity's OWN scope is a CHILD created by resolve.to during + # the same policy fire (push-scope, see modules/policies/flake.nix). push-scope + # records that child scope keyed by (parentScope, id_hash) in scopeByEntity. + # Since the resolve.to and the instantiate effect share the same parent scope + # and carry the same entity record (hence id_hash), the spec looks its scope up + # DIRECTLY — no name-infix reconstruction. The (parent, id_hash) key handles + # multi-system same-name entities: id_hash is context-free (kind+name), so two + # `ben` homes on different systems share an id_hash but have distinct parent + # (system=…) scopes, keeping their links distinct. + # + # T rule (single-child fallback, spec §3d): a spec WITHOUT a recorded entity + # scope (no id_hash, or no link — e.g. a non-entity collect-perSystem spec) + # targets its source scope's root. The caller falls through to sourceScopeId. + entityScopeFor = + scopeByEntity: spec: let sid = spec.sourceScopeId or null; - entityName = spec.name or null; - # Find child scopes of sourceScopeId (where resolve.to created the entity scope). - children = - if sid != null then - builtins.filter (scopeId: scopeId != sid && (scopeParent.${scopeId} or null) == sid) allScopeIds - else - [ ]; - matchByName = - if entityName != null then - builtins.filter (scopeId: lib.hasInfix "=${entityName}" scopeId) children - else - [ ]; - # Among matches, prefer the shortest scope ID — the entity's own scope, - # not a descendant (e.g., "host=lb-prod" over "host=lb-prod,user=deploy"). - bestMatch = - if builtins.length matchByName <= 1 then - matchByName - else - let - sorted = builtins.sort (a: b: builtins.stringLength a < builtins.stringLength b) matchByName; - in - [ (builtins.head sorted) ]; + idHash = spec.id_hash or null; in - if bestMatch != [ ] then - builtins.head bestMatch - # Single-child fallback only for entity specs (which carry mainModule). - # Non-entity instantiate specs (e.g., collect-perSystem) should fall - # through to sourceScopeId so they collect from the full subtree. - else if spec ? mainModule && builtins.length children == 1 then - builtins.head children - else - null; + if sid != null && idHash != null then scopeByEntity."${sid}\n${idHash}" or null else null; # The per-host subtree extraction that produced the complete module set for a # host (host-scope + user-scope + route-delivered modules, key-deduped) now @@ -132,6 +115,7 @@ let scopedProvides, scopedRoutes, scopeParent, + scopeByEntity ? { }, scopeEntityClass ? (_: { }), scopeIsolated ? { }, spawnNodeFn, @@ -141,7 +125,7 @@ let let allScopeIds = builtins.attrNames augmentedScopeContexts; hostClass = spec.class or "nixos"; - rawHostScopeId = findHostScopeId scopeParent allScopeIds spec; + rawHostScopeId = entityScopeFor scopeByEntity spec; hostScopeId = if rawHostScopeId != null then rawHostScopeId else spec.sourceScopeId; preWalkedModules = if hostScopeId != null then @@ -262,6 +246,7 @@ let scopedProvides, scopedRoutes, scopeParent, + scopeByEntity ? { }, scopeEntityClass ? (_: { }), scopeIsolated ? { }, spawnNodeFn, @@ -276,6 +261,7 @@ let scopedProvides scopedRoutes scopeParent + scopeByEntity scopeEntityClass scopeIsolated spawnNodeFn @@ -393,6 +379,11 @@ let # Kind-level isolation marks {scopeId→true}; route collection and subtree # extraction skip isolated descendants (the collection root is exempt). scopeIsolated = (result.state.scopeIsolated or (_: { })) null; + # Spec→scope link recorded at scope creation (push-scope), keyed by + # (parentScope, id_hash). Replaces findHostScopeId's name-infix heuristic; + # both instantiate call sites (phase4 + the B′ hostConfigs build) resolve + # an entity spec's scope through it. + scopeByEntity = (result.state.scopeByEntity or (_: { })) null; # Scan raw pipe values for config-dependent thunks (functions taking # { config, ... }). If none exist, hostConfigs stays null and @@ -426,13 +417,12 @@ let else let allInstantiates = lib.concatLists (lib.attrValues (result.state.scopedInstantiates null)); - allScopeIds = builtins.attrNames scopeContexts; specsByHost = builtins.listToAttrs ( lib.concatMap ( spec: let hasOutput = (spec.intoAttr or [ ]) != [ ]; - hostScopeId = if hasOutput then findHostScopeId scopeParent allScopeIds spec else null; + hostScopeId = if hasOutput then entityScopeFor scopeByEntity spec else null; in if hostScopeId == null then [ ] @@ -452,6 +442,7 @@ let scopedProvides scopedRoutes scopeParent + scopeByEntity ; scopeEntityClass = result.state.scopeEntityClass or (_: { }); inherit scopeIsolated; @@ -664,6 +655,7 @@ let scopedProvides scopedRoutes scopeParent + scopeByEntity ctx ; # Pass drained class imports so pipe-arg deferred aspects are diff --git a/templates/ci/modules/internal-api/edge-trace.nix b/templates/ci/modules/internal-api/edge-trace.nix index fcc6d9585..ce01e59e8 100644 --- a/templates/ci/modules/internal-api/edge-trace.nix +++ b/templates/ci/modules/internal-api/edge-trace.nix @@ -282,7 +282,7 @@ in ]; mode = "merge"; annotations = { - resolvedRootVia = "name-infix"; + resolvedRootVia = "scope-link"; system = "x86_64-linux"; }; }) @@ -444,7 +444,7 @@ in ]; mode = "merge"; annotations = { - resolvedRootVia = "name-infix"; + resolvedRootVia = "scope-link"; system = "x86_64-linux"; }; }) @@ -559,7 +559,7 @@ in mode = "merge"; annotations = { disambiguatedTo = "flake.homeConfigurations.ben@aarch64-linux"; - resolvedRootVia = "name-infix"; + resolvedRootVia = "scope-link"; system = "aarch64-linux"; }; }) @@ -573,7 +573,7 @@ in mode = "merge"; annotations = { disambiguatedTo = "flake.homeConfigurations.ben@x86_64-linux"; - resolvedRootVia = "name-infix"; + resolvedRootVia = "scope-link"; system = "x86_64-linux"; }; }) @@ -790,7 +790,7 @@ in ]; mode = "merge"; annotations = { - resolvedRootVia = "name-infix"; + resolvedRootVia = "scope-link"; system = "x86_64-linux"; }; }) From 389ea6ae74e951139ed586d740c52c0808683ff5 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 12 Jun 2026 21:18:57 -0700 Subject: [PATCH 056/101] feat(fx): instantiates as flake-output T-arm edges via shared constructor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the flake-output T-arm edge construction (spec descriptors + @system disambiguation) into edges/instantiate.nix, shared by production (resolve.nix applyInstantiates) and the read-only oracle (edge-trace.nix). The @system collision repair — qualify colliding output names with @system on multi-system, lib.warn same-entity dedup keeps last — is now the documented T-arm-local rule both consume, so the @system rule can never diverge between oracle and production (spec §3a convergence). applyInstantiates keeps ONLY the lazy thunk-tree build: descriptors and disambiguation touch path + system metadata exclusively (never spec.instantiate), so instantiate stays forced on output ACCESS — laziness preserved exactly. The oracle's parallel inline disambiguation re-derivation is deleted in favour of the shared constructor. --- nix/lib/aspects/fx/edge-trace.nix | 114 ++++++++--------------- nix/lib/aspects/fx/edges/instantiate.nix | 95 +++++++++++++++++++ nix/lib/aspects/fx/resolve.nix | 79 ++-------------- 3 files changed, 143 insertions(+), 145 deletions(-) create mode 100644 nix/lib/aspects/fx/edges/instantiate.nix diff --git a/nix/lib/aspects/fx/edge-trace.nix b/nix/lib/aspects/fx/edge-trace.nix index 4d9777867..baf9525bb 100644 --- a/nix/lib/aspects/fx/edge-trace.nix +++ b/nix/lib/aspects/fx/edge-trace.nix @@ -61,6 +61,12 @@ let # decomposition (nest into source bucket, merge half = default-fold) is recorded # by the constructor's `mergeHalf` annotation (§B Decision 1). inherit (import ./edges/provides.nix { inherit lib den; }) providesEdges; + # The flake-output T-arm constructor — the SAME descriptors + @system + # disambiguation production maps to lazy instantiate thunks (resolve.nix + # applyInstantiates). The oracle's inline disambiguation re-derivation is + # REPLACED by this import (spec §3a convergence): the @system rule is now + # production's, not a parallel render. + instantiateEdges = import ./edges/instantiate.nix { inherit lib; }; in { # extractEdgeTrace: pipeline end-state → stably-sorted normalized edge list. @@ -176,83 +182,45 @@ in # ===== instantiate edges (flake-output T-arm) ====================== # scopedInstantiates → flake-output edges. T = [ "flake" ] ++ intoAttr. - # @system disambiguation: when the SAME output path is targeted by specs - # on DIFFERENT systems, each is qualified @. We render the - # disambiguation by reusing the grouping INPUTS (path + system metadata - # only — never spec.instantiate, matching disambiguated's contract) and - # annotate collisions (disambiguatedTo). resolvedRootVia annotation = - # "scope-link": the entity scope is resolved from the scopeByEntity link - # recorded at scope creation (push-scope), NOT reconstructed by name-infix - # (findHostScopeId dissolved in Task 11). + # Rendered by the SHARED flake-output T-arm constructor (edges/instantiate.nix) + # — the SAME descriptors + @system disambiguation production maps to lazy + # instantiate thunks (resolve.nix applyInstantiates). The oracle maps the + # disambiguated descriptors to edge records instead; both touch path + system + # metadata only (never spec.instantiate), so this is laziness-safe and the + # @system rule can never diverge (spec §3a). resolvedRootVia = "scope-link": + # the entity scope is resolved from the scopeByEntity link recorded at scope + # creation (push-scope), NOT reconstructed by name-infix (findHostScopeId + # dissolved in Task 11). allInstantiates = builtins.concatLists (lib.attrValues scopedInstantiates); - # Spec descriptors with output, mirroring applyInstantiates:specDescriptors. - instDescriptors = builtins.concatLists ( - map ( - spec: - let - hasOutput = (spec.intoAttr or [ ]) != [ ]; - in - if !hasOutput then - [ ] - else - [ - { - path = [ "flake" ] ++ spec.intoAttr; - system = spec.system or null; - inherit spec; - } - ] - ) allInstantiates - ); - # Group by output path (the disambiguated grouping inputs). - instGrouped = builtins.foldl' ( - acc: entry: + disambiguated = instantiateEdges.disambiguate (instantiateEdges.specDescriptors allInstantiates); + instantiateEdgeList = map ( + entry: let - k = lib.concatStringsSep "." entry.path; + spec = entry.spec; + # @system-qualified when disambiguate rewrote the path-tail (the last + # element gained an `@` suffix). The constructor owns the rule; + # the oracle reads its decision off the resulting path. + baseName = lib.last ([ "flake" ] ++ (spec.intoAttr or [ ])); + isMultiSystem = lib.last entry.path != baseName; in - acc // { ${k} = (acc.${k} or [ ]) ++ [ entry ]; } - ) { } instDescriptors; - instantiateEdges = builtins.concatLists ( - lib.mapAttrsToList ( - _: entries: - let - systems = lib.unique (map (e: e.system or null) entries); - isMultiSystem = builtins.length entries > 1 && builtins.length systems > 1; - in - map ( - entry: - let - spec = entry.spec; - # The entity scope is resolved by the (parentScope, id_hash) link - # recorded at scope creation (resolve.nix entityScopeFor over - # scopeByEntity); the trace records the resolution VIA, not the - # scope. The source is annotated as the spec's sourceScopeId. - outPath = - if isMultiSystem then - lib.init entry.path ++ [ "${lib.last entry.path}@${entry.system}" ] - else - entry.path; - in - mkEdge { - # Source content comes from the host subtree (collected); we record - # the source as the spec's source scope + class. - source = collected (name (spec.sourceScopeId or rootScopeId)) (spec.class or "nixos"); - target = outputTarget outPath; - path = [ ]; - mode = "merge"; - annotations = { - resolvedRootVia = "scope-link"; - inherit (entry) system; - } - // lib.optionalAttrs isMultiSystem { - disambiguatedTo = lib.concatStringsSep "." outPath; - }; - } - ) entries - ) instGrouped - ); + mkEdge { + # Source content comes from the host subtree (collected); the source is + # the spec's source scope + class. + source = collected (name (spec.sourceScopeId or rootScopeId)) (spec.class or "nixos"); + target = outputTarget entry.path; + path = [ ]; + mode = "merge"; + annotations = { + resolvedRootVia = "scope-link"; + inherit (entry) system; + } + // lib.optionalAttrs isMultiSystem { + disambiguatedTo = lib.concatStringsSep "." entry.path; + }; + } + ) disambiguated; - allEdges = defaultFold ++ providesEdgeList ++ routeEdgeList ++ spawnEdges ++ instantiateEdges; + allEdges = defaultFold ++ providesEdgeList ++ routeEdgeList ++ spawnEdges ++ instantiateEdgeList; in sortEdges allEdges; } diff --git a/nix/lib/aspects/fx/edges/instantiate.nix b/nix/lib/aspects/fx/edges/instantiate.nix new file mode 100644 index 000000000..c74e13e0c --- /dev/null +++ b/nix/lib/aspects/fx/edges/instantiate.nix @@ -0,0 +1,95 @@ +# instantiate.nix — the flake-output T-arm edge constructor (spec §2: T = a +# flake-output path). An instantiate spec delivers a host/home's collected class +# content to a flake-output attrpath (nixosConfigurations., etc.). Unlike +# the entity-root T-arm (default fold / routes / provides), the flake-output arm +# carries its OWN edge-construction rules — notably @system disambiguation for +# colliding output names — which are T-arm-LOCAL, never general materializer +# steps (spec §2 "the flake-output arm carries its own edge-construction rules"). +# +# Both the read-only oracle (edge-trace.nix) and production (resolve.nix +# applyInstantiates) source their flake-output descriptors from HERE, so they can +# never disagree on the @system rule (spec §3a convergence). Production maps the +# disambiguated descriptors to lazy instantiate thunks; the oracle maps them to +# edge records. Neither path touches spec.instantiate — only path + system +# metadata — so the descriptor build is laziness-safe (the thunk tree the +# materializer builds, resolve.nix:instantiateConfigs, is what forces instantiate +# on output ACCESS). +{ lib, ... }: +let + # spec → output descriptor { path; system; spec }, or [] when the spec has no + # output (intoAttr empty). path = [ "flake" ] ++ intoAttr. + specDescriptor = + spec: + let + hasOutput = (spec.intoAttr or [ ]) != [ ]; + in + if !hasOutput then + [ ] + else + [ + { + path = [ "flake" ] ++ spec.intoAttr; + system = spec.system or null; + inherit spec; + } + ]; + + # All output descriptors for a flat instantiate-spec list. + specDescriptors = specs: lib.concatMap specDescriptor specs; + + # @system disambiguation (the T-arm-local rule). Entries colliding on the same + # output path are resolved: + # - DIFFERENT systems → qualify each output name with @system so both are + # accessible (e.g. homeConfigurations."ben@x86_64-linux"). Without this, + # lib.recursiveUpdate would deep-merge two independent module-system + # evaluations and corrupt both. + # - SAME entity via multiple policy paths (e.g. fleet + direct) → dedup, + # keeping the last (lib.warn on collision). The kept modules are compatible. + # + # Returns the disambiguated descriptor list (path possibly @system-qualified). + # Inspects path + system metadata ONLY — never spec.instantiate (laziness-safe). + disambiguate = + descriptors: + let + pathStr = builtins.concatStringsSep "."; + grouped = builtins.foldl' ( + acc: entry: + let + key = pathStr entry.path; + in + acc // { ${key} = (acc.${key} or [ ]) ++ [ entry ]; } + ) { } descriptors; + resolveGroup = + _: entries: + if builtins.length entries <= 1 then + entries + else + let + systems = map (e: e.system or null) entries; + uniqueSystems = lib.unique systems; + isMultiSystem = builtins.length uniqueSystems > 1; + in + if isMultiSystem then + map ( + e: + let + basePath = lib.init e.path; + baseName = lib.last e.path; + in + e // { path = basePath ++ [ "${baseName}@${e.system}" ]; } + ) entries + else + let + entry = lib.last entries; + in + lib.warnIf (builtins.length entries > 1) + "den: multiple instantiate specs target ${builtins.concatStringsSep "." entry.path} on ${ + if entry.system != null then entry.system else "unknown" + }; keeping last" + [ entry ]; + in + lib.concatLists (lib.mapAttrsToList resolveGroup grouped); +in +{ + inherit specDescriptors disambiguate; +} diff --git a/nix/lib/aspects/fx/resolve.nix b/nix/lib/aspects/fx/resolve.nix index a2c9b7041..ca8c56c39 100644 --- a/nix/lib/aspects/fx/resolve.nix +++ b/nix/lib/aspects/fx/resolve.nix @@ -14,6 +14,7 @@ let inherit (import ./scope-walk.nix { inherit lib; }) subtreeScopes dedupByKey; inherit (import ./edges/materialize.nix { inherit lib; }) assembleSubtree; inherit (import ./edges/provides.nix { inherit lib den; }) applyProvidesEdges; + instantiateEdges = import ./edges/instantiate.nix { inherit lib; }; handlers = den.lib.aspects.fx.handlers; # Check if `ancestor` is an ancestor of `descendant` in the scopeParent tree. @@ -271,78 +272,12 @@ let allInstantiates = lib.concatLists (lib.attrValues scopedInstantiates); - # Build spec descriptors: { path, system, spec } without calling instantiate. - # concatMap is strict in the list but the instantiate thunk is deferred. - specDescriptors = lib.concatMap ( - spec: - let - hasOutput = (spec.intoAttr or [ ]) != [ ]; - in - if !hasOutput then - [ ] - else - [ - { - path = [ "flake" ] ++ spec.intoAttr; - system = spec.system or null; - inherit spec; - } - ] - ) allInstantiates; - - # Disambiguate instantiate entries targeting the same output path from - # different entities. When the same user name appears on multiple systems - # (e.g. den.homes.x86_64-linux.ben + den.homes.aarch64-darwin.ben both - # producing homeConfigurations.ben), lib.recursiveUpdate would deeply - # merge the two independent module-system evaluations, corrupting both. - # Fix: qualify each colliding entry's output name with its system so both - # are accessible (e.g. homeConfigurations."ben@x86_64-linux"). - # Same-entity duplicates (e.g. fleet + direct policy) are left as-is - # since they produce compatible modules. - # - # Only inspects path and system metadata — never touches spec.instantiate. - disambiguated = - let - pathStr = builtins.concatStringsSep "."; - grouped = builtins.foldl' ( - acc: entry: - let - key = pathStr entry.path; - in - acc // { ${key} = (acc.${key} or [ ]) ++ [ entry ]; } - ) { } specDescriptors; - resolve = - _: entries: - if builtins.length entries <= 1 then - entries - else - let - systems = map (e: e.system or null) entries; - uniqueSystems = lib.unique systems; - isMultiSystem = builtins.length uniqueSystems > 1; - in - if isMultiSystem then - # Different systems: qualify each output name with @system. - map ( - e: - let - basePath = lib.init e.path; - baseName = lib.last e.path; - in - e // { path = basePath ++ [ "${baseName}@${e.system}" ]; } - ) entries - else - # Same entity via multiple policy paths: deduplicate. - let - entry = lib.last entries; - in - lib.warnIf (builtins.length entries > 1) - "den: multiple instantiate specs target ${builtins.concatStringsSep "." entry.path} on ${ - if entry.system != null then entry.system else "unknown" - }; keeping last" - [ entry ]; - in - lib.concatLists (lib.mapAttrsToList resolve grouped); + # Flake-output T-arm edge construction (spec §2: T = a flake-output path). + # The descriptors + @system disambiguation are the T-arm-LOCAL rules, shared + # with the read-only oracle (edge-trace.nix) via edges/instantiate.nix so + # production and oracle agree on the @system rule (spec §3a). Both touch + # path + system metadata only — never spec.instantiate (laziness-safe). + disambiguated = instantiateEdges.disambiguate (instantiateEdges.specDescriptors allInstantiates); # Build lazy output tree. Each leaf calls spec.instantiate on first access. instantiateConfigs = map ( From 75cd440331090c428e20bada1547ea2fc7474b4a Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 12 Jun 2026 21:38:11 -0700 Subject: [PATCH 057/101] =?UTF-8?q?fix(fx):=20B=E2=80=B2=20peer=20configs?= =?UTF-8?q?=20over=20assembled=20contexts,=20not=20raw=20(=C2=A7A=20#8/#2/?= =?UTF-8?q?#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hostConfigs (re-entry B′) builds each peer host's full config for cross-host config-dependent pipe-thunk resolution. It built those over RAW scopeContexts (census #8) and RAW undrained imports (#2/#7) — so a peer whose config CONSUMES a pipe value (an aspect reading a quirk via context, e.g. `{ feat, ... }`) got its pipe value un-injected and threw `attribute 'feat' missing` instead of matching the peer's real instantiate output (variant B). Fix (§A option b): B′ now builds over augmentedScopeContextsNoCfg — a hostConfigs-NULL assemblePipes pass that is cycle-free (the cycle was assemblePipes-with-hostConfigs → hostConfigs) yet resolves every pipeline-parametric pipe value — plus the matching drainedForHostConfigs (mkDrained parameterized by its augmented-contexts source). Pipe-consuming and pipe-arg-deferred peers resolve correctly; a deferred include on a genuinely config-dependent pipe stays deferred under B′ (a real inter-config recursion no pass breaks — a documented limitation, not an opaque throw). Witness deadbugs/bprime-basedrain-crosshost (defect + control): the defect fails `feat missing` over raw contexts and passes under the fix; the control (non-pipe config field) is stable both ways. --- nix/lib/aspects/fx/resolve.nix | 71 +++++++- .../deadbugs/bprime-basedrain-crosshost.nix | 158 ++++++++++++++++++ 2 files changed, 222 insertions(+), 7 deletions(-) create mode 100644 templates/ci/modules/deadbugs/bprime-basedrain-crosshost.nix diff --git a/nix/lib/aspects/fx/resolve.nix b/nix/lib/aspects/fx/resolve.nix index ca8c56c39..54ea80735 100644 --- a/nix/lib/aspects/fx/resolve.nix +++ b/nix/lib/aspects/fx/resolve.nix @@ -371,9 +371,23 @@ let ) allInstantiates ); mkArgs = mkInstantiateArgs { - augmentedScopeContexts = scopeContexts; + # §A #8/#2/#7 fix (option b): B′ builds peer configs over the + # hostConfigs-NULL ASSEMBLED contexts (pipe values resolved), not + # raw scopeContexts. The census flagged B′'s raw-context use as + # cycle-forced (assemblePipes-with-hostConfigs needs hostConfigs); + # but the hostConfigs-NULL pass is cycle-free and resolves every + # pipeline-parametric pipe value. A pipe-CONSUMING peer aspect (one + # that reads a quirk value via context, e.g. `{ feat, ... }`) thus + # gets its pipe value injected — pre-fix the raw context left `feat` + # unbound and the peer config threw `feat missing` instead of + # matching its real instantiate output (variant B). Witnessed by + # deadbugs/bprime-basedrain-crosshost. + augmentedScopeContexts = augmentedScopeContextsNoCfg; + # …and over the matching DRAINED import map (deferred includes whose + # pipeline-parametric pipe-args are now resolved). Pre-fix this was + # raw scopedClassImportsRaw — the §A #2/#7 baseDrain carry-over. + scopedClassImportsRaw = drainedForHostConfigs; inherit - scopedClassImportsRaw scopedProvides scopedRoutes scopeParent @@ -398,6 +412,39 @@ let inherit scopeParent; }; + # §A #2/#7 B′ baseDrain ACCIDENT fix (two-pass, option c). + # + # hostConfigs (re-entry B′) builds each peer host's full config for cross- + # host config-dependent pipe-thunk resolution. Pre-fix it built those from + # RAW (undrained) imports, so a peer whose config depends on a DEFERRED + # include (one that deferred on a pipe-name / enrichment arg) diverged from + # the peer's real instantiate output (variant B) — throwing `feat missing` + # instead of resolving. Witnessed by deadbugs/bprime-basedrain-crosshost. + # + # The fix: B′ consumes a DRAINED import map. The cycle that forced raw — + # baseDrain → augmentedScopeContexts → hostConfigs → (B′ would read the + # drained map) — is broken by draining over a hostConfigs-NULL augmented + # contexts here. assemblePipes with hostConfigs=null resolves every + # PIPELINE-PARAMETRIC pipe value (host/user-derived, no config dependency) + # and leaves config-dependent pipe thunks deferred (__configThunk), so: + # - pipe-arg-deferred includes whose pipe is pipeline-parametric (the + # common case, incl. the witness `feat`) DRAIN correctly for B′; + # - the rarer deferred-include-on-a-CONFIG-dependent-pipe sub-case stays + # deferred under B′ (its pipe value genuinely needs a peer's config, + # which is the cross-host thunk B′ is mid-resolving — a real recursion + # no pass can break; it remains a documented limitation). + # No cycle: augmentedScopeContextsNoCfg / drainedForHostConfigs / spawnNode / + # parentState all read RAW scopeContexts + scopedClassImports only, never + # hostConfigs or augmentedScopeContexts. + augmentedScopeContextsNoCfg = assemblePipes { + inherit scopeContexts scopeEntityKind; + hostConfigs = null; + scopedClassImports = scopedClassImportsRaw; + scopedPipeEffects = result.state.scopedPipeEffects null; + inherit scopeParent; + }; + drainedForHostConfigs = mkDrained augmentedScopeContextsNoCfg; + # Parent-state bundle for node spawns. Uses the RAW scopeContexts and # scopedClassImports (not the augmented/drained maps): the spawned node # re-derives pipes via its OWN assemblePipes over the merged state, so @@ -432,15 +479,22 @@ let selfRef = spawnNode; } mkPipeline parentState; - # Post-assembly drain: resolve deferred includes. - # Two categories of deferred includes are drained here: + # 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: + # - drainedClassImportsRaw — over the hostConfigs-augmented contexts + # (the host's OWN phase1–4 path; hostConfigs already resolved by then). + # - drainedForHostConfigs — over the hostConfigs-NULL augmented + # contexts (the cross-host B′ peer-config build, §A #2/#7 ACCIDENT fix). + # Two categories of deferred includes are drained: # 1. Pipe-arg deferred: required args are pipe names, now available # from assemblePipes. # 2. Enrichment-deferred: required args (e.g., isNixos) were provided # by a parent scope's policy enrichment but weren't available when # the child scope was walked. The drain inherits parent scope context # to resolve these. - drainedClassImportsRaw = + mkDrained = + augmentedContexts: let allDeferred = (result.state.scopedDeferredIncludes or (_: { })) null; inherit (den.lib.aspects.fx.keyClassification) classifyKeys; @@ -452,7 +506,7 @@ let enrichedScopeCtx = scopeId: let - ownCtx = augmentedScopeContexts.${scopeId} or { }; + ownCtx = augmentedContexts.${scopeId} or { }; inherit' = sid: let @@ -462,7 +516,7 @@ let { } else let - parentCtx = augmentedScopeContexts.${pid} or { }; + parentCtx = augmentedContexts.${pid} or { }; grandparentCtx = inherit' pid; in grandparentCtx // parentCtx; @@ -575,6 +629,9 @@ let } ) baseDrain (builtins.attrNames allHomeNodes); + # The host's OWN phase1–4 drain, over the hostConfigs-augmented contexts. + drainedClassImportsRaw = mkDrained augmentedScopeContexts; + phase1 = wrapPerScope ctx augmentedScopeContexts drainedClassImportsRaw; phase2 = applyProvidesEdges ctx augmentedScopeContexts scopedProvides phase1; phase3 = diff --git a/templates/ci/modules/deadbugs/bprime-basedrain-crosshost.nix b/templates/ci/modules/deadbugs/bprime-basedrain-crosshost.nix new file mode 100644 index 000000000..c24cc2fbb --- /dev/null +++ b/templates/ci/modules/deadbugs/bprime-basedrain-crosshost.nix @@ -0,0 +1,158 @@ +# B′ cross-host peer-config divergence witness (§A #8/#2/#7, Task 11c). +# +# `hostConfigs` (re-entry B′, resolve.nix) builds each peer host's full nixos +# config for cross-host config-dependent pipe-thunk resolution (assemble-pipes +# resolveEntry reads `config = hostConfigs.${sourceScopeId}`). Pre-fix B′ built +# those configs over the RAW scopeContexts (census #8) and RAW (undrained) class +# imports (census #2/#7) — so a peer whose config CONSUMES a pipe value (or +# depends on a deferred include) diverged from its real instantiate output +# (variant B): the pipe value was never injected / the include never drained, so +# the peer config threw `attribute 'feat' missing` instead of resolving. +# +# Witness shape (the §A probe template, minimised to the natural host topology): +# - a pipe-consuming peer aspect `needs-feat.nixos = { feat, ... }: +# { networking.domain = builtins.head feat; }` — reads the `feat` quirk value +# from its context. `feat` is a host-scope pipe (pipe.for → [ host.name ]), +# resolved by assemblePipes, NOT present in raw scopeContexts; +# - a cross-host `collectAll` config-thunk `host-marks = { config, ... }: +# [ "d-${config.networking.domain}" ]` reading each peer's config — this is +# what forces hostConfigs (B′) to build the peer configs. +# +# Only igloo COLLECTS (asymmetric on purpose): a mutual collectAll (both hosts +# reading each other's config) is a genuine inter-config cycle no pass can break. +# Both hosts EMIT a host-marks value, so igloo's collectAll builds BOTH configs. +# +# Before the fix: igloo's collectAll reads iceberg's `config.networking.domain` +# via hostConfigs.${iceberg} (B′), built over raw contexts → `feat` unbound → +# `feat missing`. After the fix (B′ over augmentedScopeContextsNoCfg + +# drainedForHostConfigs): the peer config matches variant B and resolves. +# +# Control: a cross-host thunk reading a NON-pipe config field (hostName, set from +# the host record) resolves under B′ regardless — isolating the pipe/deferred +# injection as the cause. +{ denTest, lib, ... }: +{ + flake.tests.bprime-basedrain-crosshost = { + + # Defect case: cross-host config-thunk reads a PIPE-derived config field. + # Pre-fix this threw `feat missing`; after, it resolves to the peer's value. + test-crosshost-thunk-reads-pipe-config = denTest ( + { + den, + igloo, + lib, + ... + }: + let + inherit (den.lib.policy) pipe; + in + { + den.quirks.feat.description = "A host-scope pipe (scalar feature value)."; + den.quirks.host-marks.description = "Cross-host config-derived marks."; + + # Every host emits its own `feat` pipe value; only igloo collects marks. + den.policies.emit-feat = { host, ... }: [ (pipe.from "feat" [ (pipe.for (_: [ host.name ])) ]) ]; + den.policies.collect-marks = _: [ + (pipe.from "host-marks" [ (pipe.collectAll ({ host, ... }: true)) ]) + ]; + den.schema.host.includes = [ den.policies.emit-feat ]; + + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.hosts.x86_64-linux.iceberg.users.alice = { }; + + # The pipe-CONSUMING peer aspect: reads `feat` from context (resolved by + # assemblePipes, absent from raw scopeContexts). Sets a config field FROM + # the pipe value — so a peer's config.networking.domain depends on it. + den.aspects.needs-feat.nixos = + { feat, ... }: + { + networking.domain = builtins.head feat; + }; + den.aspects.igloo.includes = [ + den.aspects.needs-feat + den.policies.collect-marks + ]; + den.aspects.iceberg.includes = [ den.aspects.needs-feat ]; + + # Each host EMITS a host-marks value = a config-thunk reading its OWN + # pipe-derived domain. igloo's collectAll gathers them, forcing + # hostConfigs (B′) to build each peer's config (where the defect lived). + den.aspects.igloo.host-marks = { config, ... }: [ "d-${config.networking.domain}" ]; + den.aspects.iceberg.host-marks = { config, ... }: [ "d-${config.networking.domain}" ]; + + # Consume the collected marks into igloo's config so the test reads them. + den.aspects.igloo.nixos = + { host-marks, lib, ... }: + { + networking.search = lib.sort (a: b: a < b) host-marks; + }; + + expr = { + # igloo's own pipe-derived domain = its feat = "igloo". + domain = igloo.networking.domain; + # Cross-host collectAll sees BOTH peers' pipe-derived domains via B′. + marks = igloo.networking.search; + }; + expected = { + domain = "igloo"; + marks = [ + "d-iceberg" + "d-igloo" + ]; + }; + } + ); + + # Control: cross-host config-thunk reads a NON-pipe config field (hostName, + # set from the host record — present in raw contexts). Resolves under B′ + # regardless of the augmented-context fix — confirms the pipe injection is + # the cause. + test-crosshost-thunk-reads-nonpipe-config = denTest ( + { + den, + igloo, + lib, + ... + }: + let + inherit (den.lib.policy) pipe; + in + { + den.quirks.host-marks.description = "Cross-host config-derived marks."; + + den.policies.collect-marks = _: [ + (pipe.from "host-marks" [ (pipe.collectAll ({ host, ... }: true)) ]) + ]; + + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.hosts.x86_64-linux.iceberg.users.alice = { }; + + # NON-pipe config field: hostName set from the host record (raw context). + den.aspects.set-hostname.nixos = + { host, ... }: + { + networking.hostName = host.name; + }; + den.aspects.igloo.includes = [ + den.aspects.set-hostname + den.policies.collect-marks + ]; + den.aspects.iceberg.includes = [ den.aspects.set-hostname ]; + den.aspects.igloo.host-marks = { config, ... }: [ "n-${config.networking.hostName}" ]; + den.aspects.iceberg.host-marks = { config, ... }: [ "n-${config.networking.hostName}" ]; + + den.aspects.igloo.nixos = + { host-marks, lib, ... }: + { + networking.search = lib.sort (a: b: a < b) host-marks; + }; + + expr = igloo.networking.search; + expected = [ + "n-iceberg" + "n-igloo" + ]; + } + ); + }; +} From 4332b29ddb6be0a2e5e16248e6d6609224da7717 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 12 Jun 2026 22:00:11 -0700 Subject: [PATCH 058/101] refactor(fx): delivery-half dead-code sweep + extractor convergence --- nix/lib/aspects/fx/edge-trace.nix | 21 ++++++++------ nix/lib/aspects/fx/edges/default.nix | 9 +++--- nix/lib/aspects/fx/edges/materialize.nix | 33 +++++++++++----------- nix/lib/aspects/fx/edges/provides.nix | 5 ++-- nix/lib/aspects/fx/handlers/push-scope.nix | 2 +- nix/lib/aspects/fx/pipeline.nix | 4 +-- nix/lib/aspects/fx/resolve.nix | 20 +++++++------ 7 files changed, 50 insertions(+), 44 deletions(-) diff --git a/nix/lib/aspects/fx/edge-trace.nix b/nix/lib/aspects/fx/edge-trace.nix index baf9525bb..dbfcab206 100644 --- a/nix/lib/aspects/fx/edge-trace.nix +++ b/nix/lib/aspects/fx/edge-trace.nix @@ -5,13 +5,17 @@ # port is gated by diffing its constructor's edges against the edges this # extractor renders from the SAME end-state. # -# v0 captures the clean edges exactly (default folds, simple routes, provides, -# spawns, instantiates). Path-dependent decisions (route suppression, -# findHostScopeId root selection, complex-forward source choice, @system -# requalification) are recorded as ANNOTATIONS (spec §3a, approximate-then- -# converge) rather than independently re-derived — re-deriving them would mean -# re-implementing the very logic the port deletes. Annotation fidelity converges -# to exact edge fields constructor-by-constructor in Phase 2. +# All edge kinds (default folds, simple + complex routes, provides, spawns, +# instantiates) now render through the SAME constructors production materializes +# through (edges/default.nix, edges/route.nix, edges/provides.nix, +# edges/instantiate.nix), so the v0 approximate-then-converge annotations (spec +# §3a) have converged to exact edge fields: route suppression is the route +# constructor's own dedup verdict; the instantiation root is the scope-link +# (resolvedRootVia = "scope-link"), not a name-infix reconstruction; the @system +# requalification is the shared instantiate constructor's rule. The ONE residual +# annotation is `sourceVia = "unresolved"` for complex-forward (synthesize) edges: +# the collected-else-rewalk source choice is materialization-time path-dependent, +# so the trace records identity, not the resolved branch (spec §8; see routeEdges). # # Edge record: { source; target; path; mode; annotations; } # S (source) — collected(scopeName, class) | rewalk(aspect, bindings, class) @@ -189,8 +193,7 @@ in # metadata only (never spec.instantiate), so this is laziness-safe and the # @system rule can never diverge (spec §3a). resolvedRootVia = "scope-link": # the entity scope is resolved from the scopeByEntity link recorded at scope - # creation (push-scope), NOT reconstructed by name-infix (findHostScopeId - # dissolved in Task 11). + # creation (push-scope), not reconstructed by a name-infix heuristic. allInstantiates = builtins.concatLists (lib.attrValues scopedInstantiates); disambiguated = instantiateEdges.disambiguate (instantiateEdges.specDescriptors allInstantiates); instantiateEdgeList = map ( diff --git a/nix/lib/aspects/fx/edges/default.nix b/nix/lib/aspects/fx/edges/default.nix index 5d6b7771d..a1ec14d79 100644 --- a/nix/lib/aspects/fx/edges/default.nix +++ b/nix/lib/aspects/fx/edges/default.nix @@ -4,10 +4,11 @@ # (materialize.nix → resolve.nix) source their edges from HERE, so extractor and # production can never disagree on an edge's shape (spec §3a convergence). # -# This task (Task 7) lands the DEFAULT-FOLD constructor only. Routes, provides, -# spawn, and instantiate constructors are added by Tasks 8–11; until then those -# mechanisms' edges are still rendered by edge-trace.nix's own (soon-superseded) -# inline arms. +# This file holds the DEFAULT-FOLD constructor. The other mechanisms' edge +# constructors live in their own siblings: routes + complex forwards in route.nix, +# provides in provides.nix, the flake-output T-arm in instantiate.nix. (Spawn +# edges have no shared constructor — the spawn drain-fold stays imperative and the +# oracle renders its rewalk edges directly; see edge-trace.nix.) { lib, ... }: let inherit (import ./edge.nix { inherit lib; }) mkEdge collected rootTarget; diff --git a/nix/lib/aspects/fx/edges/materialize.nix b/nix/lib/aspects/fx/edges/materialize.nix index 4f9253b8b..ae65c4a00 100644 --- a/nix/lib/aspects/fx/edges/materialize.nix +++ b/nix/lib/aspects/fx/edges/materialize.nix @@ -38,26 +38,26 @@ let in rec { # The Π(root) record shape (§A, Task 1 census). Per-field provenance cites the - # census verdict that constrains it; fields not yet consumed by THIS task's - # default-fold port still belong in the record because Tasks 8–11 consume them - # (the variants become visible data instead of implicit state-threading). + # census verdict that constrains it. The default-fold merge consumes only + # perScope/scopeParent/scopeIsolated/rootScopeId/dedupMode/allScopeIds; the + # remaining fields are the route/provides/spawn projection inputs (the variants + # are visible data instead of implicit state-threading). # # Π(root) = { - # scopeContexts; # §9 subtree-only context slice. NOT consumed by the + # scopeContexts; # §9 subtree-only context slice. NOT read by the # # default-fold merge (which reads perScope buckets # # directly); routes/provides/synthesize materialize - # # against it (Tasks 8–9). + # # against it. # contextsAreAugmented; # §8 DELIBERATE (cycle-forced) — B′ gets raw contexts. # # Carried so a unified assembleSubtree knows which it - # # got. Not consumed this task. + # # got. # classImports; # §2 the collected class buckets, per-scope (perScope). # # The default-fold merge SOURCE. TARGET semantics = - # # drained (Task 11 owns the B′ baseDrain ACCIDENT); - # # the Π builder must NOT enshrine raw as B′'s contract. + # # drained (the B′ baseDrain divergence is fixed via + # # the augmented-context build, §A #8/#2/#7 option b). # provides; # §9 subtree+ancestors; §3 spawn's own suffices. - # # Not consumed this task (provides port = Task 9). # routes; # §9 subtree+ancestors; §4 parent-subtree routes merge - # # into a spawn. Not consumed this task (route = Task 8). + # # into a spawn. # rootScopeId; # §5 DELIBERATE — the subtree root (pipeline root | # # hostScopeId | spawnRoot). The merge target's root. # scopeParent; # the parent DAG slice (subtree/ancestor walks). @@ -83,7 +83,7 @@ rec { # # the membership universe is WIDER than perScope alone. # classInject ? null; # §1 the resolved entity class to inject into context # # args; no observable witness — defensive projection, - # # default off. Not consumed this task. + # # default off. # } # Resolve Π's isolation marks into the `isolated` set the subtree walk takes, @@ -129,9 +129,10 @@ rec { # materialize: Π + an edge list → { class → [ modules ] }. The ONLY mode # switch for default-fold extraction. The merge arm here is the per-root final # extraction; routes/provides/spawn fold through their own entry (edges/route.nix - # applyRoutes / edges/provides.nix applyProvidesEdges) until their phase folds - # are fully absorbed (Tasks 10–11). `perScope` and the resolved subtree are - # passed via the closure `ctx`. + # applyRoutes / edges/provides.nix applyProvidesEdges) — those phase folds are + # kept as orchestration (the fold call-sites stay; only their per-mechanism logic + # moved into the edge constructors), so `assembleSubtree` carries merge edges + # only. `perScope` and the resolved subtree are passed via the closure `ctx`. materialize = pi: ctx: edges: let @@ -148,7 +149,7 @@ rec { ${cls} = (acc.${cls} or [ ]) ++ collectMerge ctx.perScope ctx.subtreeScopeIds ctx.dedupMode cls; } else - throw "den materialize: assembleSubtree edge mode ${builtins.toJSON edge.mode} (nest/nest-verbatim route delivery folds through edges/route.nix:materializeRouteEdge, not assembleSubtree, until Tasks 10–11)"; + throw "den materialize: assembleSubtree edge mode ${builtins.toJSON edge.mode} (nest/nest-verbatim route delivery folds through edges/route.nix:materializeRouteEdge, not assembleSubtree)"; in builtins.foldl' step { } edges; @@ -261,7 +262,7 @@ rec { }: let phase1 = wrapPerScope ctx augmented mergedClassImports; - phase2 = applyProvides ctx augmented ownProvides phase1; + phase2 = applyProvides ctx ownProvides phase1; phase3 = applyRoutes selfRef ctx augmented spawnRoot mergedScopeParent mergedScopeIsolated mergedSpawnRoutes phase2; diff --git a/nix/lib/aspects/fx/edges/provides.nix b/nix/lib/aspects/fx/edges/provides.nix index dbbdbb7cb..ae5c80e81 100644 --- a/nix/lib/aspects/fx/edges/provides.nix +++ b/nix/lib/aspects/fx/edges/provides.nix @@ -48,12 +48,11 @@ let # SOURCE scope's perScope bucket — the latter is the merge half (subtree- # collectible, visible to a later route's getCollectedSource / subtree walk). # - # ctx — the pipeline base ctx (fallback when a scope has no context). - # scopeContexts — sid → context (UNREAD; kept for signature parity, reworked in Task 10/11). + # ctx — the pipeline base ctx (the wrap context for every provide). # scopedProvides — sid → [ provide specs ] (the registered provides). # acc — { classImports; perScope; } (phase-1 output). applyProvidesEdges = - ctx: scopeContexts: scopedProvides: acc: + ctx: scopedProvides: acc: let allProvides = dedupProvides (lib.concatLists (lib.attrValues scopedProvides)); in diff --git a/nix/lib/aspects/fx/handlers/push-scope.nix b/nix/lib/aspects/fx/handlers/push-scope.nix index b5f1bd9f4..d0572a0da 100644 --- a/nix/lib/aspects/fx/handlers/push-scope.nix +++ b/nix/lib/aspects/fx/handlers/push-scope.nix @@ -41,7 +41,7 @@ let # Spec→scope link: record the entity scope this push created, keyed by # (parentScope, id_hash). The instantiate spec — registered at the same # parent scope and carrying the same entity record — resolves its scope - # via this link instead of findHostScopeId's name-infix reconstruction. + # via this link directly (no post-hoc name-infix reconstruction). # Only entity scopes (entityKind set, record carries id_hash) are linked. entityRecord = if entityKind == null then null else scopedCtx.${entityKind} or null; entityIdHash = if entityRecord == null then null else entityRecord.id_hash or null; diff --git a/nix/lib/aspects/fx/pipeline.nix b/nix/lib/aspects/fx/pipeline.nix index 3166a9643..19f94daf6 100644 --- a/nix/lib/aspects/fx/pipeline.nix +++ b/nix/lib/aspects/fx/pipeline.nix @@ -169,8 +169,8 @@ let currentScope = "__unscoped"; scopeContexts = _: { }; scopeParent = _: { }; - # Spec→scope link (replaces findHostScopeId's name-infix heuristic): when - # resolve.to creates an entity scope (push-scope with entityKind set), record + # Spec→scope link (the entity scope is recorded, never name-infix matched): + # when resolve.to creates an entity scope (push-scope with entityKind set), record # the scope it created keyed by (parentScope, entity id_hash). An instantiate # spec — registered at the SAME parent scope, carrying the same entity record # (hence id_hash) — looks its entity scope up directly. Key combines parent + diff --git a/nix/lib/aspects/fx/resolve.nix b/nix/lib/aspects/fx/resolve.nix index 54ea80735..ba4e4db1a 100644 --- a/nix/lib/aspects/fx/resolve.nix +++ b/nix/lib/aspects/fx/resolve.nix @@ -169,7 +169,7 @@ let subtreeRoutes = lib.filterAttrs (sid: _: isRelevant sid) scopedRoutes; relevantContexts = lib.genAttrs relevantScopeIds (sid: augmentedScopeContexts.${sid}); subtreePhase1 = wrapPerScope ctx subtreeContexts subtreeClassImports; - subtreePhase2 = applyProvidesEdges ctx relevantContexts subtreeProvides subtreePhase1; + subtreePhase2 = applyProvidesEdges ctx subtreeProvides subtreePhase1; subtreePhase3 = applyRoutes spawnNodeFn ctx relevantContexts hostScopeId scopeParent scopeIsolated subtreeRoutes subtreePhase2; @@ -179,9 +179,11 @@ let # data instead of implicit state-threading. assembleSubtree resolves # the isolation-AWARE subtree boundary (isolationMode = "aware") and # merge-materializes the host class bucket (the wrapPerScope/ - # extractSubtreeModules merge semantics). Fields not consumed by the - # default-fold merge (contexts/provides/routes) are carried for the - # per-port absorption of this re-entry (Tasks 8–11). + # extractSubtreeModules merge semantics). The route/provides + # materialization runs in subtreePhase2/3 above (the phase folds are + # kept as orchestration); the contexts/provides/routes carried on `pi` + # conform to the canonical Π record shape (§A) for symmetry with the + # spawn re-entry, though the default-fold merge reads only perScope. pi = { perScope = subtreePhase3.perScope; classImports = subtreePhase3.classImports; @@ -315,9 +317,9 @@ let # extraction skip isolated descendants (the collection root is exempt). scopeIsolated = (result.state.scopeIsolated or (_: { })) null; # Spec→scope link recorded at scope creation (push-scope), keyed by - # (parentScope, id_hash). Replaces findHostScopeId's name-infix heuristic; - # both instantiate call sites (phase4 + the B′ hostConfigs build) resolve - # an entity spec's scope through it. + # (parentScope, id_hash). The instantiate spec's scope is resolved through + # it directly (no name-infix reconstruction); both instantiate call sites + # (phase4 + the B′ hostConfigs build) use the link. scopeByEntity = (result.state.scopeByEntity or (_: { })) null; # Scan raw pipe values for config-dependent thunks (functions taking @@ -633,7 +635,7 @@ let drainedClassImportsRaw = mkDrained augmentedScopeContexts; phase1 = wrapPerScope ctx augmentedScopeContexts drainedClassImportsRaw; - phase2 = applyProvidesEdges ctx augmentedScopeContexts scopedProvides phase1; + phase2 = applyProvidesEdges ctx scopedProvides phase1; phase3 = applyRoutes spawnNode ctx augmentedScopeContexts result.state.rootScopeId scopeParent scopeIsolated scopedRoutes @@ -744,7 +746,7 @@ let } mkPipeline parentState; phase1 = wrapPerScope ctx augmentedScopeContexts scopedClassImportsRaw; - phase2 = applyProvidesEdges ctx augmentedScopeContexts (result.state.scopedProvides null) phase1; + phase2 = applyProvidesEdges ctx (result.state.scopedProvides null) phase1; phase3 = applyRoutes spawnNode ctx augmentedScopeContexts result.state.rootScopeId scopeParent scopeIsolated (result.state.scopedRoutes null) From ecf00975e47eaf0e64e91cdc6b3bca57c1c34ced Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 12 Jun 2026 22:05:21 -0700 Subject: [PATCH 059/101] docs(fx): de-stale pi.scopeContexts comment (tasks done) --- nix/lib/aspects/fx/resolve.nix | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nix/lib/aspects/fx/resolve.nix b/nix/lib/aspects/fx/resolve.nix index ba4e4db1a..767efd133 100644 --- a/nix/lib/aspects/fx/resolve.nix +++ b/nix/lib/aspects/fx/resolve.nix @@ -189,8 +189,8 @@ let classImports = subtreePhase3.classImports; # §A #9: scopeContexts is the subtree-ONLY context slice (NOT # subtree+ancestors). provides/routes are the wider subtree+ancestor - # fields. Inert for the default-fold merge (which reads perScope), - # but the correct scope-set for the Tasks 8/9 materializers that + # fields. Inert for the default-fold merge (which reads perScope); + # the correct scope-set for the route/provides materializers that # consume pi.scopeContexts. scopeContexts = subtreeContexts; contextsAreAugmented = true; From cf3bab647d72cf20e58896fa40e5a96c846a2343 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 12 Jun 2026 22:07:14 -0700 Subject: [PATCH 060/101] docs(fx): replace bare task tag with sourceVia rationale --- nix/lib/aspects/fx/edge-trace.nix | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nix/lib/aspects/fx/edge-trace.nix b/nix/lib/aspects/fx/edge-trace.nix index dbfcab206..a02be0371 100644 --- a/nix/lib/aspects/fx/edge-trace.nix +++ b/nix/lib/aspects/fx/edge-trace.nix @@ -134,7 +134,8 @@ in # suppression: the constructor's own dedup/suppression rules are EXACT here # (the `suppressed`/`suppressedByChildKey` annotations are the production # decisions, not the v0 approximation). Complex forwards keep - # `sourceVia = "unresolved"` (Task 9). + # `sourceVia = "unresolved"` — see the header: the collected-else-rewalk + # source is materialization-time path-dependent, so the trace is identity-only. rawRoutes = builtins.concatLists (lib.attrValues scopedRoutes); routeEdgeList = routeEdges { inherit From b02827c7b2cca1a1cd60598d4f355b12f595e2ae Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 12 Jun 2026 22:19:51 -0700 Subject: [PATCH 061/101] feat(fx): deliver primitive; route/provides as shims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the user-facing `deliver` delivery-edge constructor over the edge layer (spec §4): `deliver { from; to; at ? []; mode ? "merge"; }`. `from` is a class name (collect) or { module; } (inject); `mode` ∈ merge|nest|verbatim, explicit (no reinstantiate flag, no isolation-derived verbatim magic). route and provide become PERMANENT sugar shims over deliver, signatures byte-stable: route maps fromClass/intoClass/path→from/to/at and reinstantiate=true→mode=verbatim; provide maps class/module/path→module-source deliver. Mechanism-only route fields (collectSubtree, appendToParent, instantiate, adapterKey, ...) ride through an internal __extra escape hatch and are NOT on the deliver surface. appendToParent is constructor-internal only, reachable through the route shim for compat. Both shims carry a TODO comment; no live deprecation warning. deliver emits the same route/provide effect descriptors the edge constructors already consume, so the materializer learns no new mechanism name (§2 invariant). New deliver public-api suite (11 tests): each mode end-to-end, at-path nesting, module source, mode validation, shim-equivalence (route/provide descriptor == deliver descriptor) + a live edge-trace oracle check. Docs: deliver reference section; route/provide marked as shims. --- docs/src/content/docs/reference/policies.mdx | 44 +- nix/lib/policy-effects.nix | 160 ++++++- templates/ci/modules/public-api/deliver.nix | 413 +++++++++++++++++++ 3 files changed, 605 insertions(+), 12 deletions(-) create mode 100644 templates/ci/modules/public-api/deliver.nix diff --git a/docs/src/content/docs/reference/policies.mdx b/docs/src/content/docs/reference/policies.mdx index 1b5567460..f39a48c93 100644 --- a/docs/src/content/docs/reference/policies.mdx +++ b/docs/src/content/docs/reference/policies.mdx @@ -66,9 +66,47 @@ Remove an aspect via the constraint registry: policy.exclude den.aspects.unwanted ``` +### `policy.deliver spec` + +The user-facing **delivery primitive**: declare one delivery edge from a source +into a target class at a path, with an explicit mode. `route` and `provide` +(below) are sugar shims over `deliver`. + +```nix +# Class source — move a class's collected content (the `route` case): +policy.deliver { + from = "myClass"; # class name → collect that class's bucket + to = "nixos"; # target class + at = [ "services" "myService" ]; # attrpath ([] = merge at the class root) + mode = "nest"; # "merge" | "nest" | "verbatim" +} + +# Module source — inject a NEW module (the `provide` case): +policy.deliver { + from = { module = { pkgs, ... }: { ... }; }; + to = "nixos"; +} +``` + +`mode` is exhaustive and explicit: + +- `merge` (default) — union into the target class bucket (use with `at = []`). +- `nest` — evaluate and place the source at `at`. +- `verbatim` — place the collected module wrappers _by reference_ so a target + whose option `merge` re-instantiates them (e.g. a microvm guest config) sees + the live modules together with its base modules. There is **no + `reinstantiate` flag** — verbatim is requested by `mode` directly. `verbatim` + applies only to class sources, not module sources. + +`deliver` deliberately does **not** expose `appendToParent` (parent-targeting is +constructor-internal) — use a class source whose `to` already names the target. + ### `policy.route spec` -Route class or quirk content from one scope partition into a target class: +Route class or quirk content from one scope partition into a target class. A +permanent sugar shim over [`policy.deliver`](#policydeliver-spec) — +`fromClass`→`from`, `intoClass`→`to`, `path`→`at`, and `reinstantiate = true`→ +`mode = "verbatim"`: ```nix policy.route { @@ -80,7 +118,9 @@ policy.route { ### `policy.provide spec` -Deliver a module directly to a target class, bypassing the aspect tree: +Deliver a module directly to a target class, bypassing the aspect tree. A +permanent sugar shim over [`policy.deliver`](#policydeliver-spec) with a module +source (`class`→`to`, `module`→`from.module`, `path`→`at`): ```nix policy.provide { class = "nixos"; module = { pkgs, ... }: { ... }; } diff --git a/nix/lib/policy-effects.nix b/nix/lib/policy-effects.nix index 023d36cde..8ca55ae0d 100644 --- a/nix/lib/policy-effects.nix +++ b/nix/lib/policy-effects.nix @@ -1,6 +1,6 @@ # Typed policy effect constructors. # Policies return lists of these; the pipeline dispatches on __policyEffect. -{ ... }: +{ lib, ... }: let # Coerce a value into an inner policy record for use in `for` / `when`. # Accepts: policies (__isPolicy), effect descriptors (__policyEffect), @@ -32,8 +32,95 @@ let name = ""; fn = p; }; + + # ===== deliver — the user-facing delivery-edge constructor ============ + # `deliver` is the public primitive over the edge layer (spec §4): it declares + # ONE delivery edge `(S, T, P, M)` and nothing else. `route` and `provides` + # (the attrset entries below) are PERMANENT shims that desugar onto it, so + # `deliver` lives in this `let` block where both can reference it. + # + # deliver { + # from; # the source S: + # # - a class name (string) → collect that class's bucket + # # and move it (the route case); + # # - { module = ; } → inject a NEW module that + # # did not come from the walk (the provides case). + # to; # target class T (the instantiation-root class). + # at ? [ ]; # attrpath P inside T's config ([] = merge at root). + # mode ? "merge"; # M, explicit and exhaustive: "merge" | "nest" | "verbatim". + # guard ? null; # optional conditional gate (class-source only). + # adaptArgs ? null;# optional specialArgs adapter (class-source only). + # } + # + # Surface rules (spec §4, binding): + # - `mode = "verbatim"` is EXPLICIT — there is no `reinstantiate` flag on + # `deliver` and no isolation-derived verbatim magic (rejected by the spec). + # The `route` shim maps its legacy `reinstantiate = true` to `mode = + # "verbatim"` so existing users keep working without touching `deliver`. + # - `appendToParent` is NOT accepted here — `to` already names the target; + # parent-targeting is constructor-internal (Task 8 fixed T = parent root at + # construction) and reachable only through the `route` shim for compat. + # + # `deliver` produces the same effect descriptor the edge constructors already + # consume (`route` / `provide`), so the materializer never learns a new + # mechanism name (spec §2 invariant). Shims thread their mechanism-only fields + # through the internal `__extra` escape hatch — never part of the user surface. + deliver = + { + from, + to, + at ? [ ], + mode ? "merge", + guard ? null, + adaptArgs ? null, + __extra ? { }, + }: + let + validModes = { + merge = true; + nest = true; + verbatim = true; + }; + isModuleSource = builtins.isAttrs from && from ? module; + in + if !(validModes ? ${mode}) then + throw "den: deliver: mode must be one of merge|nest|verbatim, got '${toString mode}'" + else if isModuleSource then + # Module source S = the provided module — a `provide` edge (nest ∘ merge, + # §B Decision 1). `verbatim` is meaningless for injected content (there is + # no collected wrapper to keep by reference) — reject it explicitly. + if mode == "verbatim" then + throw "den: deliver: mode \"verbatim\" applies to class sources (collected modules), not module sources" + else + { + __policyEffect = "provide"; + value = { + class = to; + inherit (from) module; + path = at; + } + // __extra; + } + else + # Class source S = collected(from) — a `route` edge. mode → reinstantiate + # (verbatim) is the ONLY mode→flag translation; nest/merge are derived by + # the route classifier from path, matching the legacy route shape. + { + __policyEffect = "route"; + value = { + fromClass = from; + intoClass = to; + path = at; + reinstantiate = mode == "verbatim"; + } + // lib.optionalAttrs (guard != null) { inherit guard; } + // lib.optionalAttrs (adaptArgs != null) { inherit adaptArgs; } + // __extra; + }; in { + inherit deliver; + # Create a new context scope (fan-out). Each resolve creates a parallel # branch — a sibling context with new bindings merged into parent. # policy.resolve {} (empty bindings) is a no-op. @@ -100,6 +187,16 @@ in # Route class or quirk content from one scope partition into a target class. # Tier 1 delivery — replaces den.batteries.forward for the common case. # + # SHIM over `deliver` (spec §4): `route` is PERMANENT user-API sugar and may + # live on indefinitely. It desugars its full legacy signature onto a single + # `deliver` edge: `fromClass`→`from`, `intoClass`→`to`, `path`/`intoPath`→`at`, + # and `reinstantiate = true`→`mode = "verbatim"`. The mechanism-only fields + # (`collectSubtree`, `appendToParent`, `instantiate`, `adapterKey`, + # `adapterModule`, …) are route-internal and ride through `deliver`'s `__extra` + # escape hatch — they are NOT part of the `deliver` surface. + # + # TODO: add deprecation warning before any future removal. + # # `intoPath` is the public target-path name — it pairs with `intoClass` and # `fromClass`, matching the forward API. `path` is kept as a back-compat # alias; both normalize to the internal `path` key the route handler reads. @@ -109,15 +206,36 @@ in spec: let path = spec.intoPath or spec.path or [ ]; + reinstantiate = spec.reinstantiate or false; + # Everything that is not a clean `deliver` field is a route-internal + # mechanism field threaded verbatim through __extra. + extra = builtins.removeAttrs spec [ + "fromClass" + "intoClass" + "intoPath" + "path" + "reinstantiate" + "guard" + "adaptArgs" + ]; in if (spec ? intoPath) && (spec ? path) then throw "den: policy.route: pass either `intoPath` or `path`, not both" else - { - __policyEffect = "route"; - value = builtins.removeAttrs spec [ "intoPath" ] // { - inherit path; - }; + deliver { + from = spec.fromClass or "?"; + to = spec.intoClass or "?"; + at = path; + mode = + if reinstantiate then + "verbatim" + else if path == [ ] then + "merge" + else + "nest"; + guard = spec.guard or null; + adaptArgs = spec.adaptArgs or null; + __extra = extra; }; # Request post-pipeline instantiation of an entity's class content. @@ -131,10 +249,32 @@ in # Unlike route (which moves existing pipeline content), provide injects # new content that didn't come from the pipeline walk. # spec: { class, module, path? } - provide = spec: { - __policyEffect = "provide"; - value = spec; - }; + # + # SHIM over `deliver` (spec §4): `provide` is PERMANENT user-API sugar. It + # desugars onto a module-source `deliver` edge: `class`→`to`, `module`→ + # `from.module`, `path`→`at` (P=[] degenerates to a plain merge contribution, + # P≠[] is the `nest ∘ merge` decomposition). Any extra spec fields ride + # through `__extra`. + # + # TODO: add deprecation warning before any future removal. + provide = + spec: + let + extra = builtins.removeAttrs spec [ + "class" + "module" + "path" + ]; + in + deliver { + from = { + inherit (spec) module; + }; + to = spec.class; + at = spec.path or [ ]; + mode = if (spec.path or [ ]) == [ ] then "merge" else "nest"; + __extra = extra; + }; # Request a deferred node spawn. Records a marker resolved post-walk # over the parent pipeline's full scope-tree state (host + siblings), so the diff --git a/templates/ci/modules/public-api/deliver.nix b/templates/ci/modules/public-api/deliver.nix new file mode 100644 index 000000000..651f00f60 --- /dev/null +++ b/templates/ci/modules/public-api/deliver.nix @@ -0,0 +1,413 @@ +# Tests for policy.deliver — the user-facing delivery-edge primitive (spec §4). +# +# `deliver { from; to; at?; mode?; }` declares ONE delivery edge (S, T, P, M). +# `route` and `provides` are PERMANENT shims over it. These tests cover: +# - each mode (merge / nest / verbatim) end-to-end; +# - `at`-path nesting; +# - module-source delivery (the provides case); +# - SHIM-EQUIVALENCE: a `route {...}` / `provide {...}` and the `deliver {...}` +# they desugar to produce IDENTICAL edge traces (the strongest faithfulness +# check — uses the edge-trace oracle, spec §3a / §5.3). +{ denTest, lib, ... }: +let + # Submodule option helper: declares an option at `name` with a listOf str type. + mkListSubmodule = + name: + { lib, ... }: + { + options.${name} = lib.mkOption { + type = lib.types.submoduleWith { + modules = [ + { + options.items = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + }; + } + ]; + }; + default = { }; + }; + }; + + # Resolve a host entity to its raw edge trace (the migration oracle). Used by + # the shim-equivalence tests: a route/provide config and its deliver desugaring + # are byte-identical except for the constructor, so their RAW traces (same + # scopeContexts) must be equal — no name-normalization needed. + hostTrace = + den: cls: host: + (den.lib.aspects.resolveWithPaths cls (den.lib.resolveEntity "host" { inherit host; })).edgeTrace; +in +{ + flake.tests.deliver = { + + # ===== mode = merge (default), class source, at = [] ================ + # Equivalent to route { fromClass; intoClass; path = []; }. + test-deliver-merge = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.classes.custom.description = "Custom source class"; + + den.policies.deliver-merge = + { host, ... }: + [ + (den.lib.policy.deliver { + from = "custom"; + to = host.class; + }) + ]; + + den.default.includes = [ den.policies.deliver-merge ]; + + den.aspects.igloo = { + custom.networking.hostName = "delivered-merge"; + }; + + expr = igloo.networking.hostName; + expected = "delivered-merge"; + } + ); + + # ===== mode = nest, class source, at = [ ... ] ====================== + # Equivalent to route { ...; path = [ "box" ]; }. + test-deliver-nest = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.classes.src.description = "Source class for nest deliver"; + + den.policies.deliver-nest = + { host, ... }: + [ + (den.lib.policy.deliver { + from = "src"; + to = host.class; + at = [ "deliver-box" ]; + mode = "nest"; + }) + ]; + + den.default.includes = [ den.policies.deliver-nest ]; + + den.aspects.igloo = { + nixos.imports = [ (mkListSubmodule "deliver-box") ]; + nixos.deliver-box.items = [ "from-nixos-owned" ]; + src.items = [ "from-src-class" ]; + }; + + expr = lib.sort (a: b: a < b) igloo.deliver-box.items; + expected = [ + "from-nixos-owned" + "from-src-class" + ]; + } + ); + + # ===== module source (the provides case) ============================ + # `from = { module = ...; }` injects a NEW module into the target class. + test-deliver-module-source = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.policies.deliver-module = + { host, ... }: + [ + (den.lib.policy.deliver { + from = { + module = { + networking.hostName = "delivered-module"; + }; + }; + to = host.class; + }) + ]; + + den.default.includes = [ den.policies.deliver-module ]; + + den.aspects.igloo = { }; + + expr = igloo.networking.hostName; + expected = "delivered-module"; + } + ); + + # ===== module source nested at a path =============================== + test-deliver-module-source-at = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.policies.deliver-module-at = + { host, ... }: + [ + (den.lib.policy.deliver { + from = { + module.items = [ "from-deliver" ]; + }; + to = host.class; + at = [ "deliver-box" ]; + mode = "nest"; + }) + ]; + + den.default.includes = [ den.policies.deliver-module-at ]; + + den.aspects.igloo = { + nixos.imports = [ (mkListSubmodule "deliver-box") ]; + nixos.deliver-box.items = [ "from-aspect" ]; + }; + + expr = lib.sort (a: b: a < b) igloo.deliver-box.items; + expected = [ + "from-aspect" + "from-deliver" + ]; + } + ); + + # ===== mode rejects unknown values ================================== + test-deliver-bad-mode-throws = denTest ( + { den, ... }: + { + expr = + (builtins.tryEval ( + den.lib.policy.deliver { + from = "x"; + to = "y"; + mode = "bogus"; + } + )).success; + expected = false; + } + ); + + # ===== verbatim rejected for module sources ========================= + test-deliver-verbatim-module-throws = denTest ( + { den, ... }: + { + expr = + (builtins.tryEval ( + den.lib.policy.deliver { + from = { + module = { }; + }; + to = "y"; + mode = "verbatim"; + } + )).success; + expected = false; + } + ); + + # ===== mode = verbatim (class source) =============================== + # nest-verbatim: collected wrappers placed BY REFERENCE so the target's own + # `merge` re-instantiates them together with its base modules (microvm-style + # slot). The collected `guestcfg` module reads a default declared by a BASE + # module of the target slot — only possible if the module ships LIVE (re- + # evaluated with the base), proving verbatim, not a pre-frozen attrset. + # `deliver { mode = "verbatim"; }` is the explicit-mode replacement for the + # legacy `reinstantiate` flag (no flag on the surface). + test-deliver-verbatim = denTest ( + { den, igloo, ... }: + let + reinstantiatingBase = + { lib, ... }: + { + options.fromBase = lib.mkOption { + type = lib.types.str; + default = "BASE-DEFAULT"; + }; + config._module.freeformType = lib.types.lazyAttrsOf lib.types.anything; + }; + # A slot whose `merge` re-runs evalModules over the delivered defs + the + # base module-list (exactly as microvm's eval-config does). + reinstantiatingSlot = + { lib, ... }: + { + options.guestSlot = lib.mkOption { + default = null; + type = lib.types.nullOr ( + lib.mkOptionType { + name = "reinstantiated config"; + merge = + _loc: defs: + lib.evalModules { + modules = [ reinstantiatingBase ] ++ map (d: d.value) defs; + }; + } + ); + }; + }; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.classes.guestcfg.description = "verbatim-delivered guest config"; + + den.policies.deliver-verbatim = + { host, ... }: + [ + (den.lib.policy.deliver { + from = "guestcfg"; + to = host.class; + mode = "verbatim"; + at = [ "guestSlot" ]; + }) + ]; + den.default.includes = [ den.policies.deliver-verbatim ]; + + den.aspects.igloo = { + nixos.imports = [ reinstantiatingSlot ]; + # The guestcfg-class content delivered verbatim into guestSlot; it reads + # `fromBase`, a default only present once re-instantiated WITH the base. + guestcfg = + { config, ... }: + { + networking.hostName = "guest-vm"; + echoed = config.fromBase; + }; + }; + + # `.guestSlot.config` — the re-instantiated evalModules result's config. + expr = { + hn = igloo.guestSlot.config.networking.hostName; + echoed = igloo.guestSlot.config.echoed; + }; + expected = { + hn = "guest-vm"; + echoed = "BASE-DEFAULT"; + }; + } + ); + + # ===== SHIM-EQUIVALENCE: route ≡ deliver (class source) ============= + # A `route { fromClass; intoClass; path; }` and the `deliver` it desugars to + # produce the SAME effect descriptor — construction-level faithfulness. Equal + # descriptors ⇒ identical downstream edges (the edge constructors consume the + # descriptor, not the surface call). + test-shim-route-eq-deliver = denTest ( + { den, ... }: + { + expr = + let + r = den.lib.policy.route { + fromClass = "shimsrc"; + intoClass = "nixos"; + path = [ "shim-box" ]; + }; + d = den.lib.policy.deliver { + from = "shimsrc"; + to = "nixos"; + at = [ "shim-box" ]; + mode = "nest"; + }; + in + r == d; + expected = true; + } + ); + + # route reinstantiate ≡ deliver verbatim — the mode→flag mapping. + test-shim-route-reinstantiate-eq-deliver-verbatim = denTest ( + { den, ... }: + { + expr = + let + r = den.lib.policy.route { + fromClass = "c"; + intoClass = "nixos"; + path = [ "p" ]; + reinstantiate = true; + }; + d = den.lib.policy.deliver { + from = "c"; + to = "nixos"; + at = [ "p" ]; + mode = "verbatim"; + }; + in + r == d; + expected = true; + } + ); + + # ===== SHIM-EQUIVALENCE: provide ≡ deliver (module source) ========== + test-shim-provide-eq-deliver = denTest ( + { den, ... }: + { + expr = + let + p = den.lib.policy.provide { + class = "nixos"; + module.items = [ "m" ]; + path = [ "box" ]; + }; + d = den.lib.policy.deliver { + from = { + module.items = [ "m" ]; + }; + to = "nixos"; + at = [ "box" ]; + mode = "nest"; + }; + in + p == d; + expected = true; + } + ); + + # ===== SHIM-EQUIVALENCE via the edge oracle: deliver edge trace ===== + # A `deliver`-built topology produces the SAME delivery edge a `route` does. + # The construction-level equivalence above (route descriptor == deliver + # descriptor) guarantees the full traces match; here we confirm the deliver + # constructor lands the expected nest edge in the live edge trace (oracle). + test-shim-deliver-edgetrace = denTest ( + { den, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.classes.shimsrc.description = "shim source class"; + den.policies.deliver-p = + { host, ... }: + [ + (den.lib.policy.deliver { + from = "shimsrc"; + to = host.class; + at = [ "shim-box" ]; + mode = "nest"; + }) + ]; + den.default.includes = [ den.policies.deliver-p ]; + den.aspects.igloo = { + nixos.imports = [ (mkListSubmodule "shim-box") ]; + shimsrc.items = [ "x" ]; + }; + + # The deliver-built trace contains the shimsrc>nixos nest edge at + # shim-box (the policy fires per scope; each registers the edge — the + # faithful trace records each, exactly as the route shim would). + expr = + let + trace = hostTrace den "nixos" den.hosts.x86_64-linux.igloo; + shimEdges = builtins.filter ( + e: (e.source.collected.class or null) == "shimsrc" && (e.target.class or null) == "nixos" + ) trace; + modesPaths = map (e: { + inherit (e) mode path; + }) shimEdges; + in + { + allNest = builtins.all (e: e.mode == "nest" && e.path == [ "shim-box" ]) modesPaths; + atLeastOne = modesPaths != [ ]; + }; + expected = { + allNest = true; + atLeastOne = true; + }; + } + ); + + }; +} From 8610affa72c3e1c67e8bd30f62ccf1304ad1d45b Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 12 Jun 2026 22:27:04 -0700 Subject: [PATCH 062/101] test+docs(fx): positive controls on deliver throw tests; provide mode-derivation note --- docs/src/content/docs/reference/policies.mdx | 3 +- templates/ci/modules/public-api/deliver.nix | 73 ++++++++++++++------ 2 files changed, 55 insertions(+), 21 deletions(-) diff --git a/docs/src/content/docs/reference/policies.mdx b/docs/src/content/docs/reference/policies.mdx index f39a48c93..66c286fce 100644 --- a/docs/src/content/docs/reference/policies.mdx +++ b/docs/src/content/docs/reference/policies.mdx @@ -120,7 +120,8 @@ policy.route { Deliver a module directly to a target class, bypassing the aspect tree. A permanent sugar shim over [`policy.deliver`](#policydeliver-spec) with a module -source (`class`→`to`, `module`→`from.module`, `path`→`at`): +source (`class`→`to`, `module`→`from.module`, `path`→`at`; `mode` is derived +from `path` — `merge` at the root, `nest` under a non-empty `path`): ```nix policy.provide { class = "nixos"; module = { pkgs, ... }: { ... }; } diff --git a/templates/ci/modules/public-api/deliver.nix b/templates/ci/modules/public-api/deliver.nix index 651f00f60..9a3889ac1 100644 --- a/templates/ci/modules/public-api/deliver.nix +++ b/templates/ci/modules/public-api/deliver.nix @@ -170,36 +170,69 @@ in ); # ===== mode rejects unknown values ================================== + # tryEval can't capture the throw message, so pair the failing call with a + # positive control differing ONLY in `mode` — proves the throw is mode + # validation, not an unrelated eval error giving a false `success = false`. test-deliver-bad-mode-throws = denTest ( { den, ... }: { - expr = - (builtins.tryEval ( - den.lib.policy.deliver { - from = "x"; - to = "y"; - mode = "bogus"; - } - )).success; - expected = false; + expr = { + bogus = + (builtins.tryEval ( + den.lib.policy.deliver { + from = "x"; + to = "y"; + mode = "bogus"; + } + )).success; + control = + (builtins.tryEval ( + den.lib.policy.deliver { + from = "x"; + to = "y"; + mode = "merge"; + } + )).success; + }; + expected = { + bogus = false; + control = true; + }; } ); # ===== verbatim rejected for module sources ========================= + # Positive control: the SAME module source with mode = "nest" succeeds, so + # the failure is the verbatim×module rule, not the module shape. test-deliver-verbatim-module-throws = denTest ( { den, ... }: { - expr = - (builtins.tryEval ( - den.lib.policy.deliver { - from = { - module = { }; - }; - to = "y"; - mode = "verbatim"; - } - )).success; - expected = false; + expr = { + verbatim = + (builtins.tryEval ( + den.lib.policy.deliver { + from = { + module = { }; + }; + to = "y"; + mode = "verbatim"; + } + )).success; + control = + (builtins.tryEval ( + den.lib.policy.deliver { + from = { + module = { }; + }; + to = "y"; + mode = "nest"; + } + )).success; + }; + expected = { + verbatim = false; + control = true; + }; } ); From 4bb2deb30a05ee255eb2c7a8c94a40ae5ddcb3d4 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 12 Jun 2026 22:38:51 -0700 Subject: [PATCH 063/101] =?UTF-8?q?docs(fx):=20correct=20B=E2=80=B2=20fix?= =?UTF-8?q?=20label=20to=20option=20b=20(was=20mislabeled=20option=20c)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nix/lib/aspects/fx/resolve.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/lib/aspects/fx/resolve.nix b/nix/lib/aspects/fx/resolve.nix index 767efd133..96d5201ae 100644 --- a/nix/lib/aspects/fx/resolve.nix +++ b/nix/lib/aspects/fx/resolve.nix @@ -414,7 +414,7 @@ let inherit scopeParent; }; - # §A #2/#7 B′ baseDrain ACCIDENT fix (two-pass, option c). + # §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- # host config-dependent pipe-thunk resolution. Pre-fix it built those from From b02d56d81070c57943de00fe717a8c90432075ab Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Sat, 13 Jun 2026 13:18:23 -0700 Subject: [PATCH 064/101] chore(entities,fx): drop unused reservedSystems inherit + orphaned blank line --- nix/lib/aspects/fx/handlers/constraint.nix | 1 - nix/lib/entities/home.nix | 1 - nix/lib/entities/host.nix | 1 - 3 files changed, 3 deletions(-) diff --git a/nix/lib/aspects/fx/handlers/constraint.nix b/nix/lib/aspects/fx/handlers/constraint.nix index 52e8f4673..dcf2ffd5f 100644 --- a/nix/lib/aspects/fx/handlers/constraint.nix +++ b/nix/lib/aspects/fx/handlers/constraint.nix @@ -7,7 +7,6 @@ ... }: let - lookupEntries = registry: nodeIdentity: let diff --git a/nix/lib/entities/home.nix b/nix/lib/entities/home.nix index 77b115a40..e99a2a1a2 100644 --- a/nix/lib/entities/home.nix +++ b/nix/lib/entities/home.nix @@ -13,7 +13,6 @@ let resolveResultOption pathSetByScopeOption resolvedCtxModule - reservedSystems preprocessHosts ; diff --git a/nix/lib/entities/host.nix b/nix/lib/entities/host.nix index 4824c4c93..04195fa96 100644 --- a/nix/lib/entities/host.nix +++ b/nix/lib/entities/host.nix @@ -13,7 +13,6 @@ let resolveResultOption pathSetByScopeOption resolvedCtxModule - reservedSystems preprocessHosts ; From f4260835ce508c932c80e574a1f536526744aa0e Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Sat, 13 Jun 2026 14:10:48 -0700 Subject: [PATCH 065/101] feat(context): restore perHost/perUser/perHome shim (rule-correct); document binding rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-adds the den.lib.perHost/perUser/perHome API (removed in 70e6af27) as thin deprecated aliases — they shipped in main, so removal was a real breaking change. The restored shim drops the old hasExtras->{} self-suppression (that WAS the #609 bug); it now delivers the current binding rule (bind-at-scope / class-local fan-out / inert-if-misplaced), identical to a plain { host, ... }: function. Docs: parametric.mdx gains the formal binding rule + the silent-inert footgun (the only mitigation, since the lib.warn was deliberately rejected); debug.md and lib-deprecated.mdx corrected to match the restored, rule-correct shim. --- .../content/docs/explanation/parametric.mdx | 29 +++++++++ docs/src/content/docs/guides/debug.md | 9 +-- .../content/docs/reference/lib-deprecated.mdx | 24 +++++--- modules/context/perHost-perUser.nix | 50 ++++++++++++++++ .../ci/modules/deprecated/perctx-shim.nix | 59 +++++++++++++++++++ 5 files changed, 160 insertions(+), 11 deletions(-) create mode 100644 modules/context/perHost-perUser.nix create mode 100644 templates/ci/modules/deprecated/perctx-shim.nix diff --git a/docs/src/content/docs/explanation/parametric.mdx b/docs/src/content/docs/explanation/parametric.mdx index aeb34faa4..02b175e75 100644 --- a/docs/src/content/docs/explanation/parametric.mdx +++ b/docs/src/content/docs/explanation/parametric.mdx @@ -46,6 +46,35 @@ den.aspects.firewall = { }; ``` +## The binding rule + +When a parametric aspect at some scope **S** destructures an **entity-kind** arg +(`host`, `user`, `home`, …), exactly one of three things happens: + +1. **In-context → bind once at S.** If the kind is already in S's context (e.g. + a `{ user, … }` aspect included at a user scope), the arg binds and the aspect + emits once, at S. +2. **Schema-DAG descendant → fan out, emit at S.** If the kind is a *descendant* + of S in the entity schema (e.g. a `{ user, … }` aspect included at the **host** + scope — users live under hosts), the aspect fans out once per matching + descendant, each emitting **class-locally at S**. This is how a host-scope + `{ user, … }` aspect produces per-user content on the host. +3. **Neither → inert, silently.** If the kind is neither in-context nor a + descendant of S — including a misplaced arg, or **any entity-kind arg at the + root/flake scope** — the aspect contributes nothing. There is **no warning** + (a warning was considered and deliberately rejected: legitimate fan-out and + misplacement are indistinguishable without whole-fleet context, so a warning + would fire on correct code). + +

+ ## Where Parametric Args Work