From ad5fb847e694eec2d1ee2b74514e437ab7c9751e Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Wed, 2 Sep 2026 14:21:30 -0700 Subject: [PATCH 01/59] refactor: rename the provenance chain to aspect-chain den spelled three unrelated things "provider": the type of an `includes` element, the `provides` delivery namespace, and the ancestor chain that forms identity. Both bugs fixed on this branch were a function reading the wrong one, and a reviewer scanning for that has to disambiguate by hand every time. The chain takes gen-aspects' name for it, which is not a new minting: # gen-aspects/lib/identity.nix:4 aspectPath = a: (a.meta.aspect-chain or [ ]) ++ [ (a.name or "") ]; Identical formula, identical ancestors-only convention. `meta.provider` is `internal = true; visible = false`, so no user-facing surface moves. The internal marker becomes `__aspectChain`, following den's camelCase convention for `__`-prefixed markers rather than the meta field's spelling. `providerType` and `providerPrefix` are deliberately untouched: the first is a different concept, and the second is a false friend that a later step splits into `origin`. den's `providerPrefix` accumulates per level while gen's is fixed per container, so they are not the same thing despite the spelling, and mapping them by name would miswire silently. The published capture field renames too, which breaks den-diagram; that consumer is updated in the same change set. It reads the field through `or [ ]` defaults at four sites, so leaving it stale would not error, it would make every node read as a root in every diagram. --- modules/aspects/batteries/import-tree.nix | 2 +- .../aspects/batteries/insecure/insecure.nix | 2 +- modules/aspects/batteries/tty-autologin.nix | 2 +- modules/aspects/batteries/unfree/unfree.nix | 2 +- modules/aspects/batteries/vm-autologin.nix | 2 +- nix/denTest.nix | 4 +- nix/lib/aspects/fx/aspect/children.nix | 8 ++-- nix/lib/aspects/fx/aspect/normalize.nix | 14 +++---- nix/lib/aspects/fx/aspect/provide.nix | 2 +- .../aspects/fx/handlers/compile-static.nix | 4 +- nix/lib/aspects/fx/identity.nix | 6 ++- nix/lib/aspects/fx/key-classification.nix | 2 +- nix/lib/aspects/fx/trace.nix | 8 ++-- nix/lib/aspects/has-aspect.nix | 8 ++-- nix/lib/aspects/types.nix | 34 ++++++++--------- nix/lib/den-brackets.nix | 8 ++-- nix/lib/resolve-entity.nix | 2 +- .../deadbugs/multi-def-namespace-identity.nix | 4 +- .../nested-aspect-include-identity.nix | 2 +- .../deadbugs/nested-key-wrapper-identity.nix | 4 +- .../deadbugs/aspect-chain-doubling.nix | 6 +-- .../deadbugs/multidef-provides-internals.nix | 2 +- .../internal-api/aspect-content-type.nix | 6 +-- .../modules/internal-api/aspect-key-type.nix | 4 +- .../ci/modules/internal-api/aspect-meta.nix | 4 +- .../ci/modules/internal-api/fx-aspect.nix | 2 +- .../internal-api/fx-bind-subsystem.nix | 4 +- .../modules/internal-api/fx-constraints.nix | 38 +++++++++---------- .../modules/internal-api/fx-diag-capture.nix | 14 +++---- .../modules/internal-api/fx-diag-context.nix | 2 +- .../modules/internal-api/fx-full-pipeline.nix | 6 +-- templates/ci/modules/internal-api/fx-gate.nix | 10 ++--- .../ci/modules/internal-api/fx-identity.nix | 8 ++-- .../modules/internal-api/fx-regressions.nix | 12 +++--- .../modules/internal-api/has-aspect-lib.nix | 2 +- .../ci/modules/internal-api/has-aspect.nix | 2 +- .../ci/modules/internal-api/include-dedup.nix | 2 +- .../modules/aspects/hosts/server.nix | 2 +- 38 files changed, 125 insertions(+), 121 deletions(-) diff --git a/modules/aspects/batteries/import-tree.nix b/modules/aspects/batteries/import-tree.nix index 975fb753b..ee5b38141 100644 --- a/modules/aspects/batteries/import-tree.nix +++ b/modules/aspects/batteries/import-tree.nix @@ -76,7 +76,7 @@ in { name = "import-tree(${baseNameOf rootStr})"; - meta.provider = [ + meta.aspect-chain = [ "den" "batteries" ]; diff --git a/modules/aspects/batteries/insecure/insecure.nix b/modules/aspects/batteries/insecure/insecure.nix index d76d26a39..b96545e84 100644 --- a/modules/aspects/batteries/insecure/insecure.nix +++ b/modules/aspects/batteries/insecure/insecure.nix @@ -14,7 +14,7 @@ let __functor = _self: allowed-names: { name = "insecure(${builtins.concatStringsSep "," allowed-names})"; - meta.provider = [ + meta.aspect-chain = [ "den" "provides" ]; diff --git a/modules/aspects/batteries/tty-autologin.nix b/modules/aspects/batteries/tty-autologin.nix index a75d0c542..841e7dd1c 100644 --- a/modules/aspects/batteries/tty-autologin.nix +++ b/modules/aspects/batteries/tty-autologin.nix @@ -23,7 +23,7 @@ let __functor = _self: username: { name = "tty-autologin(${username})"; - meta.provider = [ + meta.aspect-chain = [ "den" "provides" ]; diff --git a/modules/aspects/batteries/unfree/unfree.nix b/modules/aspects/batteries/unfree/unfree.nix index 01953227b..46dafac91 100644 --- a/modules/aspects/batteries/unfree/unfree.nix +++ b/modules/aspects/batteries/unfree/unfree.nix @@ -14,7 +14,7 @@ let __functor = _self: allowed-names: { name = "unfree(${builtins.concatStringsSep "," allowed-names})"; - meta.provider = [ + meta.aspect-chain = [ "den" "provides" ]; diff --git a/modules/aspects/batteries/vm-autologin.nix b/modules/aspects/batteries/vm-autologin.nix index 758119094..d700fe7b6 100644 --- a/modules/aspects/batteries/vm-autologin.nix +++ b/modules/aspects/batteries/vm-autologin.nix @@ -23,7 +23,7 @@ let __functor = _self: username: { name = "vm-autologin(${username})"; - meta.provider = [ + meta.aspect-chain = [ "den" "provides" ]; diff --git a/nix/denTest.nix b/nix/denTest.nix index e4e94b1fe..0e427a725 100644 --- a/nix/denTest.nix +++ b/nix/denTest.nix @@ -155,7 +155,7 @@ let let displayName = if e.excluded then "~${e.name}" else e.name; subs = buildTree ( - if e.isProvider then "${lib.concatStringsSep "/" e.provider}/${e.name}" else e.name + if e.isProvider then "${lib.concatStringsSep "/" e.aspect-chain}/${e.name}" else e.name ) entries; in if subs == [ ] then [ displayName ] else [ displayName ] ++ subs; @@ -169,7 +169,7 @@ let let root = builtins.head roots; rootName = - if root.isProvider then "${lib.concatStringsSep "/" root.provider}/${root.name}" else root.name; + if root.isProvider then "${lib.concatStringsSep "/" root.aspect-chain}/${root.name}" else root.name; in [ root.name ] ++ buildTree rootName entries; in diff --git a/nix/lib/aspects/fx/aspect/children.nix b/nix/lib/aspects/fx/aspect/children.nix index f64126cde..e419bc8be 100644 --- a/nix/lib/aspects/fx/aspect/children.nix +++ b/nix/lib/aspects/fx/aspect/children.nix @@ -153,18 +153,18 @@ let else [ ]; # Compute exclude identity, normalizing content wrappers that have - # __provider but no name (nested keys without _ prefix). + # __aspectChain but no name (nested keys without _ prefix). excludeIdentity = ref: if builtins.isAttrs ref && ref.__isPolicy or false then ref.name - else if builtins.isAttrs ref && ref ? __provider && !(ref ? name) then + else if builtins.isAttrs ref && ref ? __aspectChain && !(ref ? name) then let - prov = ref.__provider; + prov = ref.__aspectChain; in identity.key { name = if prov != [ ] then lib.last prov else ""; - meta.provider = if prov != [ ] then lib.init prov else [ ]; + meta.aspect-chain = if prov != [ ] then lib.init prov else [ ]; } else identity.key ref; diff --git a/nix/lib/aspects/fx/aspect/normalize.nix b/nix/lib/aspects/fx/aspect/normalize.nix index fb24568d4..d142ffc7e 100644 --- a/nix/lib/aspects/fx/aspect/normalize.nix +++ b/nix/lib/aspects/fx/aspect/normalize.nix @@ -86,29 +86,29 @@ let else wrapBareFn child # Content wrapper from aspectContentType (has __contentValues but no name - # yet). Inject identity from __provider and extract parametric functions + # yet). Inject identity from __aspectChain and extract parametric functions # into includes so the pipeline resolves them. listOf doesn't call # providerType.merge per-element, so inner wrappers in includes lists # arrive here unprocessed. - # A navigated nested aspect carries __provider (its full path) but may have + # A navigated nested aspect carries __aspectChain (its full path) but may have # no __contentValues (single-def keys forward their raw value directly). - # Either way, when it has no name yet, derive name + meta.provider from - # __provider so it resolves to its OWN identity (e.g. apps/gaming/steam) + # Either way, when it has no name yet, derive name + meta.aspect-chain from + # __aspectChain so it resolves to its OWN identity (e.g. apps/gaming/steam) # regardless of inclusion path. Without this it falls through nameless and # children.nix renames it to /:, so the same aspect # included via two paths gets two identities and fails to dedup. else if - builtins.isAttrs child && (child ? __contentValues || child ? __provider) && !(child ? name) + builtins.isAttrs child && (child ? __contentValues || child ? __aspectChain) && !(child ? name) then let - prov = child.__provider or [ ]; + prov = child.__aspectChain or [ ]; provName = if prov != [ ] then lib.last prov else null; fns = builtins.filter isParametricContent (child.__contentValues or [ ]); in child // lib.optionalAttrs (provName != null) { name = provName; - meta.provider = lib.init prov; + meta.aspect-chain = lib.init prov; } // lib.optionalAttrs (fns != [ ]) { includes = (child.includes or [ ]) ++ map (d: d.value) fns; diff --git a/nix/lib/aspects/fx/aspect/provide.nix b/nix/lib/aspects/fx/aspect/provide.nix index 4949cf539..a11d6db4f 100644 --- a/nix/lib/aspects/fx/aspect/provide.nix +++ b/nix/lib/aspects/fx/aspect/provide.nix @@ -99,7 +99,7 @@ let inherit (resolveProviderFn providerVal) innerFn args isParamWrapper; isPositionalFn = lib.isFunction innerFn && args == { }; providerMeta = { - provider = (aspect.meta.provider or [ ]) ++ [ aspectName ]; + aspect-chain = (aspect.meta.aspect-chain or [ ]) ++ [ aspectName ]; selfProvide = true; }; in diff --git a/nix/lib/aspects/fx/handlers/compile-static.nix b/nix/lib/aspects/fx/handlers/compile-static.nix index cbd59ba6d..880f62f11 100644 --- a/nix/lib/aspects/fx/handlers/compile-static.nix +++ b/nix/lib/aspects/fx/handlers/compile-static.nix @@ -32,7 +32,9 @@ in raw = param.aspect; aspect = builtins.removeAttrs raw parametricInternalKeys; nodeIdentity = identity.key aspect; - chainIdentity = identity.pathKey ((aspect.meta.provider or [ ]) ++ [ (aspect.name or "") ]); + chainIdentity = identity.pathKey ( + (aspect.meta.aspect-chain or [ ]) ++ [ (aspect.name or "") ] + ); isMeaningful = isMeaningfulName (aspect.name or ""); in { diff --git a/nix/lib/aspects/fx/identity.nix b/nix/lib/aspects/fx/identity.nix index 3e2c2a5ac..f323ab965 100644 --- a/nix/lib/aspects/fx/identity.nix +++ b/nix/lib/aspects/fx/identity.nix @@ -6,7 +6,9 @@ let aspectPath = a: - (a.meta.provider or [ ]) ++ [ (a.name or "") ] ++ lib.optional (a ? __ctxId) "{${a.__ctxId}}"; + (a.meta.aspect-chain or [ ]) + ++ [ (a.name or "") ] + ++ lib.optional (a ? __ctxId) "{${a.__ctxId}}"; pathKey = path: lib.concatStringsSep "/" path; @@ -15,7 +17,7 @@ let # 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 "") ]); + baseKey = a: pathKey ((a.meta.aspect-chain or [ ]) ++ [ (a.name or "") ]); # True when an identity string refers to an anonymous/unresolved node. isAnonIdentity = diff --git a/nix/lib/aspects/fx/key-classification.nix b/nix/lib/aspects/fx/key-classification.nix index bb51ec886..a54b2437b 100644 --- a/nix/lib/aspects/fx/key-classification.nix +++ b/nix/lib/aspects/fx/key-classification.nix @@ -25,7 +25,7 @@ let "__entityKind" "__parametricResolvedArgs" "__contentValues" - "__provider" + "__aspectChain" "__providesForwarded" "_module" "_" diff --git a/nix/lib/aspects/fx/trace.nix b/nix/lib/aspects/fx/trace.nix index d64032f96..4378b9106 100644 --- a/nix/lib/aspects/fx/trace.nix +++ b/nix/lib/aspects/fx/trace.nix @@ -65,11 +65,11 @@ let # Shared entry fields for both trace handlers. mkBaseEntry = class: param: { inherit class; - provider = param.meta.provider or [ ]; + aspect-chain = param.meta.aspect-chain or [ ]; excluded = param.meta.excluded or false; excludedFrom = param.meta.excludedFrom or null; replacedBy = param.meta.replacedBy or null; - isProvider = (param.meta.provider or [ ]) != [ ]; + isProvider = (param.meta.aspect-chain or [ ]) != [ ]; handlers = param.meta.handleWith or [ ]; hasClass = param ? ${class}; isParametric = param.meta.isParametric or false; @@ -137,7 +137,7 @@ let { param, state }: let rawName = param.meta.originalName or param.name or ""; - provPath = lib.concatStringsSep "/" (param.meta.provider or [ ]); + provPath = lib.concatStringsSep "/" (param.meta.aspect-chain or [ ]); entityKind = let direct = param.__entityKind or null; @@ -268,7 +268,7 @@ let name = policyName; class = ""; parent = null; - provider = [ ]; + aspect-chain = [ ]; excluded = false; excludedFrom = null; replacedBy = null; diff --git a/nix/lib/aspects/has-aspect.nix b/nix/lib/aspects/has-aspect.nix index c8ba22fc1..4f870ef79 100644 --- a/nix/lib/aspects/has-aspect.nix +++ b/nix/lib/aspects/has-aspect.nix @@ -8,12 +8,12 @@ let ref: if (ref ? name) && (ref ? meta) then pathKey (aspectPath ref) - else if ref ? __provider then - # Nested aspect from freeform traversal — content merger sets __provider + else if ref ? __aspectChain then + # Nested aspect from freeform traversal — content merger sets __aspectChain # but not name/meta. Derive path key from the provider chain. - pathKey ref.__provider + pathKey ref.__aspectChain else - throw "hasAspect: ref must have `name`+`meta` or `__provider` (got ${builtins.typeOf ref})."; + throw "hasAspect: ref must have `name`+`meta` or `__aspectChain` (got ${builtins.typeOf ref})."; # Resolve tree via fx pipeline, returning the full result state. One run # yields both the pathSet (membership, for hasAspect) and resolvedNodes diff --git a/nix/lib/aspects/types.nix b/nix/lib/aspects/types.nix index a535572ae..c899c45d9 100644 --- a/nix/lib/aspects/types.nix +++ b/nix/lib/aspects/types.nix @@ -314,12 +314,12 @@ let isContentWrapper = d: builtins.isAttrs d.value - && (d.value ? __contentValues || d.value ? __provider) + && (d.value ? __contentValues || d.value ? __aspectChain) && !(d.value ? __fn); nameFromProvider = v: let - prov = v.__provider or [ ]; + prov = v.__aspectChain or [ ]; in if prov != [ ] then lib.last prov else null; # A content wrapper holds every definition of its key. Taking one value @@ -329,7 +329,7 @@ let # includes element, which listOf hands over without reaching this merge. # # One def rather than one per definition: the wrapper is the only - # carrier of __provider, so splitting it leaves the parametric defs + # carrier of __aspectChain, so splitting it leaves the parametric defs # nameless. They then resolve to an anonymous per-inclusion identity, # which defeats gate dedup and duplicates their content once per path. wrapperToAspect = @@ -342,7 +342,7 @@ let // { value = d.value - # Only narrow what exists: a navigated child carries __provider + # Only narrow what exists: a navigated child carries __aspectChain # with no __contentValues, and inventing an empty one here makes # the wrapper re-flatten to nothing instead of failing loudly. // lib.optionalAttrs (d.value ? __contentValues) { __contentValues = parts.wrong; } @@ -350,11 +350,11 @@ let includes = (d.value.includes or [ ]) ++ map (cv: cv.value) parts.right; } # Preserve identity: inject name and provider chain from - # __provider so aspectSubmodule.merge produces a meaningful + # __aspectChain so aspectSubmodule.merge produces a meaningful # identity instead of an anonymous include index. // lib.optionalAttrs (provName != null) { name = provName; - meta.provider = lib.init d.value.__provider; + meta.aspect-chain = lib.init d.value.__aspectChain; }; }; defs' = map (d: if isContentWrapper d then wrapperToAspect d else d) defs; @@ -534,7 +534,7 @@ let annotatedSub // { __contentValues = defsForKey; - __provider = provBase; + __aspectChain = provBase; _ = underscoreAt provBase annotatedSub; } ); @@ -566,7 +566,7 @@ let in lib.optionalAttrs (builtins.isAttrs v && !(v ? __functor)) v; # Both spellings arrive as a content wrapper when the key is defined in - # more than one file, carrying `__contentValues` / `__provider` / `_` + # more than one file, carrying `__contentValues` / `__aspectChain` / `_` # alongside the real children. Those are wrapper machinery, not # provides children: unfiltered they surface as `provides` keys, enter # `__providesForwarded`, and `_` (not `__`-prefixed) registers an inert @@ -591,7 +591,7 @@ let includes = map (k: attrs.${k}) (builtins.filter isChildKey (builtins.attrNames attrs)); }; }; - # Annotate nested attrset children with __provider so deeply nested + # Annotate nested attrset children with __aspectChain so deeply nested # aspects carry provenance for hasAspect resolution, and give each # one its own ._ so the shorthand holds at every depth rather than # only at this wrapper. Without the recursion, navigation through a @@ -609,10 +609,10 @@ let childPath = provPath ++ [ k ]; sub = annotateChildren childPath v; in - if isChildKey k && builtins.isAttrs v && !(v ? __provider) && !(v ? __contentValues) then + if isChildKey k && builtins.isAttrs v && !(v ? __aspectChain) && !(v ? __contentValues) then sub // { - __provider = childPath; + __aspectChain = childPath; _ = underscoreAt childPath sub; } else @@ -624,7 +624,7 @@ let // annotatedMerged // { __contentValues = flatDefs; - __provider = provider; + __aspectChain = provider; __providesForwarded = unshadowedProvides; # Root aspects publish `provides` and `_` as one value — provides- # children plus the all-children functor (mergeWithAspectMeta's @@ -659,7 +659,7 @@ let # 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 + # __contentValues/__aspectChain shape even though the pipeline ignores the # key for dispatch. Everything else gets the provenance/content wrapper. merge = loc: defs: @@ -684,7 +684,7 @@ let ); default = null; }; - options.provider = lib.mkOption { + options.aspect-chain = lib.mkOption { internal = true; visible = false; description = "Provider path tracking aspect provenance"; @@ -732,13 +732,13 @@ let lib.types.submodule ( { name, config, ... }: let - # The chain this aspect's children hang off. `meta.provider` defaults to + # The chain this aspect's children hang off. `meta.aspect-chain` defaults to # `typeCfg.providerPrefix`, but providerType.merge overrides it when it # re-types an included nested aspect (wrapperToAspect injects the chain - # from __provider). Reading the static typeCfg there truncates the chain + # from __aspectChain). Reading the static typeCfg there truncates the chain # to the aspect's own name, so `alpha/tools` and `beta/tools` both hand # their children the prefix ["tools"] and the children collide. - childProviderPrefix = config.meta.provider ++ [ config.name ]; + childProviderPrefix = config.meta.aspect-chain ++ [ config.name ]; in { freeformType = lib.types.lazyAttrsOf ( diff --git a/nix/lib/den-brackets.nix b/nix/lib/den-brackets.nix index 5fb990627..5f583eeec 100644 --- a/nix/lib/den-brackets.nix +++ b/nix/lib/den-brackets.nix @@ -26,14 +26,14 @@ let in if tail == [ ] then resolved else resolveWithProvidesFallback resolved tail; - # Ensure bare attrset results from bracket resolution carry __provider + # Ensure bare attrset results from bracket resolution carry __aspectChain # so the pipeline can compute stable identity. Forwarded attrs from - # content wrappers are bare attrsets that lack __provider — without + # content wrappers are bare attrsets that lack __aspectChain — without # this, they get anonymous identities and dedup fails. tagProvider = path: result: - if builtins.isAttrs result && !(result ? __provider) && !(result ? __fn) then - result // { __provider = path; } + if builtins.isAttrs result && !(result ? __aspectChain) && !(result ? __fn) then + result // { __aspectChain = path; } else result; diff --git a/nix/lib/resolve-entity.nix b/nix/lib/resolve-entity.nix index bf957632f..b93fe39eb 100644 --- a/nix/lib/resolve-entity.nix +++ b/nix/lib/resolve-entity.nix @@ -67,7 +67,7 @@ let inherit name; meta = { handleWith = null; - provider = [ ]; + aspect-chain = [ ]; }; excludes = schemaExcludes; includes = selfProvide ++ schemaIncludes; diff --git a/templates/ci/modules/deadbugs/multi-def-namespace-identity.nix b/templates/ci/modules/deadbugs/multi-def-namespace-identity.nix index d2a4bfec9..900226c3e 100644 --- a/templates/ci/modules/deadbugs/multi-def-namespace-identity.nix +++ b/templates/ci/modules/deadbugs/multi-def-namespace-identity.nix @@ -1,9 +1,9 @@ # Regression: navigating through a MULTI-DEF nested namespace key strips # aspect identity from its children. # -# aspectContentType's multi-def branch returns `subForwarded // { __provider; +# aspectContentType's multi-def branch returns `subForwarded // { __aspectChain; # __contentValues; }` — the colliding key itself is tagged, but its forwarded -# children are raw attrsets with no `name` and no `__provider`. wrapChild then +# children are raw attrsets with no `name` and no `__aspectChain`. wrapChild then # falls through nameless and children.nix renames the child to # `/:`, so the same aspect included via two paths gets two # identities: emit-class dedup fails and the class content double-applies diff --git a/templates/ci/modules/deadbugs/nested-aspect-include-identity.nix b/templates/ci/modules/deadbugs/nested-aspect-include-identity.nix index 29eb71aaf..b19f48fe3 100644 --- a/templates/ci/modules/deadbugs/nested-aspect-include-identity.nix +++ b/templates/ci/modules/deadbugs/nested-aspect-include-identity.nix @@ -5,7 +5,7 @@ # # Reproduces apps.gaming.steam included via BOTH roles.gaming (host) and a # per-user entity-named aspect's includes (user-aspect-auto-include policy): -# the navigated nested aspect carried __provider but no name, so wrapChild left +# the navigated nested aspect carried __aspectChain but no name, so wrapChild left # it nameless and children.nix renamed it to /:. That gave a # different identity on the user path than the host path, defeating cross-scope # dedup, so steam's programs.steam.package was defined twice. diff --git a/templates/ci/modules/deadbugs/nested-key-wrapper-identity.nix b/templates/ci/modules/deadbugs/nested-key-wrapper-identity.nix index 56190384b..527ae8751 100644 --- a/templates/ci/modules/deadbugs/nested-key-wrapper-identity.nix +++ b/templates/ci/modules/deadbugs/nested-key-wrapper-identity.nix @@ -1,5 +1,5 @@ # A nested aspect key carries its identity on the content wrapper, via -# __provider. Anything that takes definitions out of the wrapper has to keep +# __aspectChain. Anything that takes definitions out of the wrapper has to keep # that identity, or the definitions resolve to an anonymous per-inclusion name, # gate dedup stops matching them, and their content lands once per include path. { denTest, ... }: @@ -82,7 +82,7 @@ } ); - # An annotated child three levels down carries __provider and no + # An annotated child three levels down carries __aspectChain and no # __contentValues of its own. Nothing may invent an empty one when it passes # through providerType: rawHasCV pins that the child starts without one, so # the test cannot pass by accident on a shallower shape whose middle name IS diff --git a/templates/ci/modules/features/deadbugs/aspect-chain-doubling.nix b/templates/ci/modules/features/deadbugs/aspect-chain-doubling.nix index 028a376c0..76caf083f 100644 --- a/templates/ci/modules/features/deadbugs/aspect-chain-doubling.nix +++ b/templates/ci/modules/features/deadbugs/aspect-chain-doubling.nix @@ -1,4 +1,4 @@ -# `meta.provider` is a node's position, not an accumulating set. Two files +# `meta.aspect-chain` is a node's position, not an accumulating set. Two files # defining one aspect path each inject the same chain, and a `listOf` type # concatenated them into ["a" "a"] — which every descendant then inherited as # its own prefix, corrupting the whole subtree's identities. @@ -17,7 +17,7 @@ den.hosts.x86_64-linux.igloo.users.tux = { }; den.aspects.a.tools.nixos.environment.etc."t".text = "y"; - expr = den.aspects.igloo.provides.shared.meta.provider or [ ]; + expr = den.aspects.igloo.provides.shared.meta.aspect-chain or [ ]; expected = [ "a" ]; } ); @@ -31,7 +31,7 @@ den.aspects.a.tools.nixos.environment.etc."t".text = "y"; den.aspects.igloo.provides.shared = den.aspects.a.tools; - expr = den.aspects.igloo.provides.shared.meta.provider or [ ]; + expr = den.aspects.igloo.provides.shared.meta.aspect-chain or [ ]; expected = [ "a" ]; } ); diff --git a/templates/ci/modules/features/deadbugs/multidef-provides-internals.nix b/templates/ci/modules/features/deadbugs/multidef-provides-internals.nix index a01e9d834..aa204dc98 100644 --- a/templates/ci/modules/features/deadbugs/multidef-provides-internals.nix +++ b/templates/ci/modules/features/deadbugs/multidef-provides-internals.nix @@ -1,5 +1,5 @@ # A `provides` (or `_`) key defined in more than one file merges into a content -# wrapper, which carries `__contentValues` / `__provider` / `_` beside the real +# wrapper, which carries `__contentValues` / `__aspectChain` / `_` beside the real # children. Those leaked out as provides children: they surfaced as keys of the # published `provides`, entered `__providesForwarded`, and `_` — not being # `__`-prefixed — registered an inert cross-provide policy of its own. diff --git a/templates/ci/modules/internal-api/aspect-content-type.nix b/templates/ci/modules/internal-api/aspect-content-type.nix index 58a7992fa..9f0c2e3e4 100644 --- a/templates/ci/modules/internal-api/aspect-content-type.nix +++ b/templates/ci/modules/internal-api/aspect-content-type.nix @@ -129,7 +129,7 @@ in } ); - # aspectContentType wraps values with __contentValues and __provider. + # aspectContentType wraps values with __contentValues and __aspectChain. test-content-wrapper-shape = denTest ( { den, ... }: let @@ -145,8 +145,8 @@ in { expr = { hasContentValues = val ? __contentValues; - hasProvider = val ? __provider; - provider = val.__provider; + hasProvider = val ? __aspectChain; + provider = val.__aspectChain; valueCount = builtins.length val.__contentValues; }; expected = { diff --git a/templates/ci/modules/internal-api/aspect-key-type.nix b/templates/ci/modules/internal-api/aspect-key-type.nix index f3ae3e9bb..595401fa1 100644 --- a/templates/ci/modules/internal-api/aspect-key-type.nix +++ b/templates/ci/modules/internal-api/aspect-key-type.nix @@ -75,7 +75,7 @@ in { expr = { hasContentValues = val ? __contentValues; - hasProvider = val ? __provider; + hasProvider = val ? __aspectChain; valueCount = builtins.length val.__contentValues; value = (builtins.head val.__contentValues).value; }; @@ -113,7 +113,7 @@ in { expr = { hasContentValues = val ? __contentValues; - hasProvider = val ? __provider; + hasProvider = val ? __aspectChain; valueCount = builtins.length val.__contentValues; }; expected = { diff --git a/templates/ci/modules/internal-api/aspect-meta.nix b/templates/ci/modules/internal-api/aspect-meta.nix index 846ed97b6..eaa8a359d 100644 --- a/templates/ci/modules/internal-api/aspect-meta.nix +++ b/templates/ci/modules/internal-api/aspect-meta.nix @@ -80,7 +80,7 @@ KEYS ; }; - expected.KEYS = "collisionPolicy:file:handleWith:loc:name:provider:self"; + expected.KEYS = "aspect-chain:collisionPolicy:file:handleWith:loc:name:self"; } ); @@ -110,7 +110,7 @@ KEYS ; }; - expected.KEYS = "collisionPolicy:file:foo:handleWith:loc:name:provider:self"; + expected.KEYS = "aspect-chain:collisionPolicy:file:foo:handleWith:loc:name:self"; } ); diff --git a/templates/ci/modules/internal-api/fx-aspect.nix b/templates/ci/modules/internal-api/fx-aspect.nix index b53792674..3f6c2a21f 100644 --- a/templates/ci/modules/internal-api/fx-aspect.nix +++ b/templates/ci/modules/internal-api/fx-aspect.nix @@ -285,7 +285,7 @@ in let target = { name = "targetAspect"; - meta.provider = [ "pkg" ]; + meta.aspect-chain = [ "pkg" ]; }; aspect = { name = "constrainedAspect"; diff --git a/templates/ci/modules/internal-api/fx-bind-subsystem.nix b/templates/ci/modules/internal-api/fx-bind-subsystem.nix index 6a7934df6..f77fa5937 100644 --- a/templates/ci/modules/internal-api/fx-bind-subsystem.nix +++ b/templates/ci/modules/internal-api/fx-bind-subsystem.nix @@ -146,7 +146,7 @@ child = { name = "deferred-child"; meta = { - provider = [ "test" ]; + aspect-chain = [ "test" ]; }; }; # Capture the resolve-complete stub. @@ -186,7 +186,7 @@ stubDeferred = stub.meta.deferred; stubIncludes = stub.includes; # Provider from original meta should be preserved. - stubProvider = stub.meta.provider; + stubProvider = stub.meta.aspect-chain; }; expected = { queuedCount = 1; diff --git a/templates/ci/modules/internal-api/fx-constraints.nix b/templates/ci/modules/internal-api/fx-constraints.nix index c909a396f..4e0d79dab 100644 --- a/templates/ci/modules/internal-api/fx-constraints.nix +++ b/templates/ci/modules/internal-api/fx-constraints.nix @@ -268,7 +268,7 @@ let ref = { name = "drop"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; }; decl = den.lib.aspects.fx.constraints.exclude ref; in @@ -283,7 +283,7 @@ let ref = { name = "drop"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; }; decl = den.lib.aspects.fx.constraints.exclude.global ref; in @@ -306,11 +306,11 @@ let ref = { name = "old"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; }; replacement = { name = "new"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; includes = [ ]; }; decl = den.lib.aspects.fx.constraints.substitute ref replacement; @@ -326,11 +326,11 @@ let ref = { name = "old"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; }; replacement = { name = "new"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; includes = [ ]; }; decl = den.lib.aspects.fx.constraints.substitute.global ref replacement; @@ -369,7 +369,7 @@ fx = den.lib.fx; ref = { name = "drop"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; }; decl = den.lib.aspects.fx.constraints.exclude ref; comp = fx.bind (fx.send "chain-push" { identity = "parent"; }) ( @@ -407,7 +407,7 @@ fx = den.lib.fx; ref = { name = "drop"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; }; decl = den.lib.aspects.fx.constraints.exclude ref; comp = fx.bind (fx.send "chain-push" { identity = "a"; }) ( @@ -448,7 +448,7 @@ fx = den.lib.fx; ref = { name = "drop"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; }; decl = den.lib.aspects.fx.constraints.exclude.global ref; comp = fx.bind (fx.send "chain-push" { identity = "a"; }) ( @@ -490,7 +490,7 @@ decl = den.lib.aspects.fx.constraints.filterBy (a: a.name != "drop"); aspect = { name = "drop"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; }; comp = fx.bind (fx.send "chain-push" { identity = "parent"; }) ( _: @@ -525,7 +525,7 @@ decl = den.lib.aspects.fx.constraints.filterBy (a: a.name != "drop"); aspect = { name = "drop"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; }; comp = fx.bind (fx.send "chain-push" { identity = "a"; }) ( _: @@ -632,7 +632,7 @@ fx = den.lib.fx; target = { name = "drop"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; }; parent = { name = "root"; @@ -641,12 +641,12 @@ includes = [ { name = "keep"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; includes = [ ]; } { name = "drop"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; includes = [ ]; } ]; @@ -691,11 +691,11 @@ fx = den.lib.fx; targetA = { name = "a"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; }; targetB = { name = "b"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; }; parent = { name = "root"; @@ -708,17 +708,17 @@ includes = [ { name = "a"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; includes = [ ]; } { name = "b"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; includes = [ ]; } { name = "c"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; includes = [ ]; } ]; diff --git a/templates/ci/modules/internal-api/fx-diag-capture.nix b/templates/ci/modules/internal-api/fx-diag-capture.nix index d673d20a8..43eec9183 100644 --- a/templates/ci/modules/internal-api/fx-diag-capture.nix +++ b/templates/ci/modules/internal-api/fx-diag-capture.nix @@ -53,7 +53,7 @@ fxLib = den.lib.aspects.fx; target = { name = "drop"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; }; root = { name = "root"; @@ -63,7 +63,7 @@ includes = [ { name = "keep"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; nixos = { a = 1; }; @@ -71,7 +71,7 @@ } { name = "drop"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; nixos = { b = 2; }; @@ -152,7 +152,7 @@ fxLib = den.lib.aspects.fx; target = { name = "drop"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; }; root = { name = "root"; @@ -162,12 +162,12 @@ includes = [ { name = "keep"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; includes = [ ]; } { name = "drop"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; includes = [ ]; } ]; @@ -221,7 +221,7 @@ fxLib = den.lib.aspects.fx; target = { name = "x"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; }; root = { name = "root"; diff --git a/templates/ci/modules/internal-api/fx-diag-context.nix b/templates/ci/modules/internal-api/fx-diag-context.nix index 8a2432812..03a22f797 100644 --- a/templates/ci/modules/internal-api/fx-diag-context.nix +++ b/templates/ci/modules/internal-api/fx-diag-context.nix @@ -50,7 +50,7 @@ let excludeDecl = den.lib.aspects.fx.constraints.exclude { name = "drop"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; }; in { diff --git a/templates/ci/modules/internal-api/fx-full-pipeline.nix b/templates/ci/modules/internal-api/fx-full-pipeline.nix index 815284ee9..e716b22b8 100644 --- a/templates/ci/modules/internal-api/fx-full-pipeline.nix +++ b/templates/ci/modules/internal-api/fx-full-pipeline.nix @@ -110,7 +110,7 @@ let target = { name = "drop"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; }; self = { name = "host"; @@ -120,7 +120,7 @@ includes = [ { name = "keep"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; nixos = { a = 1; }; @@ -128,7 +128,7 @@ } { name = "drop"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; nixos = { b = 2; }; diff --git a/templates/ci/modules/internal-api/fx-gate.nix b/templates/ci/modules/internal-api/fx-gate.nix index 762945e59..78cd33a1c 100644 --- a/templates/ci/modules/internal-api/fx-gate.nix +++ b/templates/ci/modules/internal-api/fx-gate.nix @@ -16,7 +16,7 @@ pipeline = den.lib.aspects.fx.pipeline; aspect = { name = "my-aspect"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; includes = [ ]; }; comp = fx.send "gate" { @@ -49,7 +49,7 @@ pipeline = den.lib.aspects.fx.pipeline; aspect = { name = "dup-aspect"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; includes = [ ]; }; # Send gate twice — second should be blocked by dedup. @@ -93,7 +93,7 @@ pipeline = den.lib.aspects.fx.pipeline; aspect = { name = "excluded-aspect"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; includes = [ ]; }; nodeIdentity = den.lib.aspects.fx.identity.key aspect; @@ -147,12 +147,12 @@ pipeline = den.lib.aspects.fx.pipeline; aspect = { name = "original-aspect"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; includes = [ ]; }; replacement = { name = "replacement-aspect"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; includes = [ ]; }; nodeIdentity = den.lib.aspects.fx.identity.key aspect; diff --git a/templates/ci/modules/internal-api/fx-identity.nix b/templates/ci/modules/internal-api/fx-identity.nix index 12385f3f0..b9e80f7fd 100644 --- a/templates/ci/modules/internal-api/fx-identity.nix +++ b/templates/ci/modules/internal-api/fx-identity.nix @@ -7,13 +7,13 @@ { flake.tests.fx-identity = { - test-aspectPath-with-provider = denTest ( + test-aspectPath-with-chain = denTest ( { den, ... }: let a = { name = "sub"; meta = { - provider = [ "monitoring" ]; + aspect-chain = [ "monitoring" ]; }; }; in @@ -26,7 +26,7 @@ } ); - test-aspectPath-no-provider = denTest ( + test-aspectPath-no-chain = denTest ( { den, ... }: let a = { @@ -57,7 +57,7 @@ a = { name = "drop"; meta = { - provider = [ ]; + aspect-chain = [ ]; }; includes = [ "x" ]; }; diff --git a/templates/ci/modules/internal-api/fx-regressions.nix b/templates/ci/modules/internal-api/fx-regressions.nix index eb55f9144..7fb2a223c 100644 --- a/templates/ci/modules/internal-api/fx-regressions.nix +++ b/templates/ci/modules/internal-api/fx-regressions.nix @@ -22,7 +22,7 @@ provider = { name = "monitoring"; meta = { - provider = [ ]; + aspect-chain = [ ]; }; includes = [ inner ]; }; @@ -128,15 +128,15 @@ } ); - # Meta carryover: meta.provider survives deep resolution. - test-meta-provider-survives = denTest ( + # Meta carryover: meta.aspect-chain survives deep resolution. + test-meta-chain-survives = denTest ( { den, ... }: let fx = den.lib.fx; child = { name = "sub"; meta = { - provider = [ "monitoring" ]; + aspect-chain = [ "monitoring" ]; }; nixos = { }; includes = [ ]; @@ -144,7 +144,7 @@ parent = { name = "monitoring"; meta = { - provider = [ ]; + aspect-chain = [ ]; }; includes = [ child ]; }; @@ -163,7 +163,7 @@ childResult = builtins.head (builtins.head result.value).includes; in { - expr = childResult.meta.provider; + expr = childResult.meta.aspect-chain; expected = [ "monitoring" ]; } ); diff --git a/templates/ci/modules/internal-api/has-aspect-lib.nix b/templates/ci/modules/internal-api/has-aspect-lib.nix index 961abb47d..7c1bbc708 100644 --- a/templates/ci/modules/internal-api/has-aspect-lib.nix +++ b/templates/ci/modules/internal-api/has-aspect-lib.nix @@ -201,7 +201,7 @@ class = "nixos"; # missing `name` — must throw ref = { - meta.provider = [ "x" ]; + meta.aspect-chain = [ "x" ]; }; }); in diff --git a/templates/ci/modules/internal-api/has-aspect.nix b/templates/ci/modules/internal-api/has-aspect.nix index fd58495db..959b3f3d9 100644 --- a/templates/ci/modules/internal-api/has-aspect.nix +++ b/templates/ci/modules/internal-api/has-aspect.nix @@ -623,7 +623,7 @@ # Nested aspects accessed via freeform key traversal (e.g., # den.aspects.disk.zfs-disk-single) lack name/meta but carry - # __provider. hasAspect must resolve these via __provider chain. + # __aspectChain. hasAspect must resolve these via __aspectChain chain. test-H2-nested-freeform-present = denTest ( { den, ... }: { diff --git a/templates/ci/modules/internal-api/include-dedup.nix b/templates/ci/modules/internal-api/include-dedup.nix index f1687fdc9..0f7175851 100644 --- a/templates/ci/modules/internal-api/include-dedup.nix +++ b/templates/ci/modules/internal-api/include-dedup.nix @@ -296,7 +296,7 @@ let shared = { name = "shared"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; nixos = { x = 1; }; diff --git a/templates/diagram-demo/modules/aspects/hosts/server.nix b/templates/diagram-demo/modules/aspects/hosts/server.nix index 3138b06d2..e22b623a8 100644 --- a/templates/diagram-demo/modules/aspects/hosts/server.nix +++ b/templates/diagram-demo/modules/aspects/hosts/server.nix @@ -17,7 +17,7 @@ (den.lib.aspects.fx.constraints.exclude den.aspects.monitoring.nginx-exporter) # Remove all aspects whose provider chain starts with "monitoring" (prefix filter) (den.lib.aspects.fx.constraints.filterBy ( - a: lib.take 1 (a.meta.provider or [ ]) != [ "monitoring" ] + a: lib.take 1 (a.meta.aspect-chain or [ ]) != [ "monitoring" ] )) ]; }; From 64a6af252211634da166039399fc82c2f3476b52 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Wed, 2 Sep 2026 14:31:17 -0700 Subject: [PATCH 02/59] test: move the chain-doubling test to internal-api It asserts on `meta.aspect-chain`, which is `internal = true; visible = false` and therefore not a seam. den already separates white-box tests from behaviour tests by directory; this one was in neither. The rename in e6608408 breaking it is the tell: a test at a seam does not notice an internal rename. --- .../deadbugs => internal-api}/aspect-chain-doubling.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename templates/ci/modules/{features/deadbugs => internal-api}/aspect-chain-doubling.nix (96%) diff --git a/templates/ci/modules/features/deadbugs/aspect-chain-doubling.nix b/templates/ci/modules/internal-api/aspect-chain-doubling.nix similarity index 96% rename from templates/ci/modules/features/deadbugs/aspect-chain-doubling.nix rename to templates/ci/modules/internal-api/aspect-chain-doubling.nix index 76caf083f..e1858a4d7 100644 --- a/templates/ci/modules/features/deadbugs/aspect-chain-doubling.nix +++ b/templates/ci/modules/internal-api/aspect-chain-doubling.nix @@ -4,7 +4,7 @@ # its own prefix, corrupting the whole subtree's identities. { denTest, ... }: { - flake.tests.deadbugs.aspect-chain-doubling = { + flake.tests.aspect-chain-doubling = { test-agreeing-definitions-collapse = denTest ( { den, ... }: From 2f2afa2c8aaec506675b1bc7e8f78204e75ed001 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Wed, 2 Sep 2026 16:54:06 -0700 Subject: [PATCH 03/59] fix: distinguish absent aspect-chain from root meta.aspect-chain defaulted to typeCfg.providerPrefix or [ ], so a root aspect and an inline includes literal both landed on [ ] and nothing downstream could tell them apart. The option is now nullOr (listOf str) with no default; a declared aspect sets its own chain via aspectMeta's mkDefault instead of inheriting one, so the value travels with the aspect through re-inclusion the way meta.loc does not. Audit every reader: identity.aspectPath/baseKey, compile-static's chainIdentity, provide's self-provide meta, and trace's entry/isProvider/ provPath all build ON TOP of the chain (own identity, a child's prefix, or display) rather than testing for root, so they now coalesce null and root alike via a shared identity.ownChain helper instead of the stale `or [ ]` that stopped catching a present-but-null key. --- nix/lib/aspects/fx/aspect/provide.nix | 2 +- .../aspects/fx/handlers/compile-static.nix | 4 +- nix/lib/aspects/fx/identity.nix | 20 +++++-- nix/lib/aspects/fx/trace.nix | 11 ++-- nix/lib/aspects/types.nix | 54 +++++++++++++++---- .../internal-api/aspect-chain-absence.nix | 34 ++++++++++++ 6 files changed, 101 insertions(+), 24 deletions(-) create mode 100644 templates/ci/modules/internal-api/aspect-chain-absence.nix diff --git a/nix/lib/aspects/fx/aspect/provide.nix b/nix/lib/aspects/fx/aspect/provide.nix index a11d6db4f..b8d56c16b 100644 --- a/nix/lib/aspects/fx/aspect/provide.nix +++ b/nix/lib/aspects/fx/aspect/provide.nix @@ -99,7 +99,7 @@ let inherit (resolveProviderFn providerVal) innerFn args isParamWrapper; isPositionalFn = lib.isFunction innerFn && args == { }; providerMeta = { - aspect-chain = (aspect.meta.aspect-chain or [ ]) ++ [ aspectName ]; + aspect-chain = identity.ownChain aspect ++ [ aspectName ]; selfProvide = true; }; in diff --git a/nix/lib/aspects/fx/handlers/compile-static.nix b/nix/lib/aspects/fx/handlers/compile-static.nix index 880f62f11..df49839fa 100644 --- a/nix/lib/aspects/fx/handlers/compile-static.nix +++ b/nix/lib/aspects/fx/handlers/compile-static.nix @@ -32,9 +32,7 @@ in raw = param.aspect; aspect = builtins.removeAttrs raw parametricInternalKeys; nodeIdentity = identity.key aspect; - chainIdentity = identity.pathKey ( - (aspect.meta.aspect-chain or [ ]) ++ [ (aspect.name or "") ] - ); + chainIdentity = identity.pathKey (identity.ownChain aspect ++ [ (aspect.name or "") ]); isMeaningful = isMeaningfulName (aspect.name or ""); in { diff --git a/nix/lib/aspects/fx/identity.nix b/nix/lib/aspects/fx/identity.nix index f323ab965..89e68ed9f 100644 --- a/nix/lib/aspects/fx/identity.nix +++ b/nix/lib/aspects/fx/identity.nix @@ -4,11 +4,20 @@ ... }: let - aspectPath = + # meta.aspect-chain is null for an aspect that hasn't set its own chain yet + # (e.g. an inline includes literal — see aspectMeta in types.nix). Callers + # here build ON TOP of the chain (this aspect's own identity, or a child's + # prefix); that is a computation, not a check for root, so null and root + # both contribute the empty chain. + ownChain = a: - (a.meta.aspect-chain or [ ]) - ++ [ (a.name or "") ] - ++ lib.optional (a ? __ctxId) "{${a.__ctxId}}"; + let + c = a.meta.aspect-chain or null; + in + if c == null then [ ] else c; + + aspectPath = + a: ownChain a ++ [ (a.name or "") ] ++ lib.optional (a ? __ctxId) "{${a.__ctxId}}"; pathKey = path: lib.concatStringsSep "/" path; @@ -17,7 +26,7 @@ let # 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.aspect-chain or [ ]) ++ [ (a.name or "") ]); + baseKey = a: pathKey (ownChain a ++ [ (a.name or "") ]); # True when an identity string refers to an anonymous/unresolved node. isAnonIdentity = @@ -136,6 +145,7 @@ let in { inherit + ownChain aspectPath pathKey key diff --git a/nix/lib/aspects/fx/trace.nix b/nix/lib/aspects/fx/trace.nix index 4378b9106..539e1ea56 100644 --- a/nix/lib/aspects/fx/trace.nix +++ b/nix/lib/aspects/fx/trace.nix @@ -4,7 +4,7 @@ ... }: let - inherit (den.lib.aspects.fx.identity) aspectPath pathKey; + inherit (den.lib.aspects.fx.identity) aspectPath pathKey ownChain; inherit (den.lib.aspects) isMeaningfulName; # Derive the entity kind for the current node by walking the includes @@ -65,11 +65,14 @@ let # Shared entry fields for both trace handlers. mkBaseEntry = class: param: { inherit class; - aspect-chain = param.meta.aspect-chain or [ ]; + # null (no chain set) and root ([ ]) both display as "no provider chain" + # here — trace output is a display/computation concern, not a place that + # distinguishes absence from root. + aspect-chain = ownChain param; excluded = param.meta.excluded or false; excludedFrom = param.meta.excludedFrom or null; replacedBy = param.meta.replacedBy or null; - isProvider = (param.meta.aspect-chain or [ ]) != [ ]; + isProvider = ownChain param != [ ]; handlers = param.meta.handleWith or [ ]; hasClass = param ? ${class}; isParametric = param.meta.isParametric or false; @@ -137,7 +140,7 @@ let { param, state }: let rawName = param.meta.originalName or param.name or ""; - provPath = lib.concatStringsSep "/" (param.meta.aspect-chain or [ ]); + provPath = lib.concatStringsSep "/" (ownChain param); entityKind = let direct = param.__entityKind or null; diff --git a/nix/lib/aspects/types.nix b/nix/lib/aspects/types.nix index c899c45d9..f41f68bf0 100644 --- a/nix/lib/aspects/types.nix +++ b/nix/lib/aspects/types.nix @@ -72,7 +72,7 @@ let let sub = aspectSubmodule typeCfg; in - sub // { merge = mergeWithAspectMeta sub; }; + sub // { merge = mergeWithAspectMeta typeCfg sub; }; # Resolve parametric includes in an aspect with the given args. # Used by __functor so aspects are callable: (aspect { host = ...; }). @@ -104,7 +104,7 @@ let }; mergeWithAspectMeta = - sub: loc: defs: + typeCfg: sub: loc: defs: let # Rescue explicit __functor from defs before the submodule merge # destroys it (freeform keys become deferred modules). @@ -115,7 +115,7 @@ let ++ [ { file = (lib.last defs).file; - value = aspectMeta loc defs; + value = aspectMeta typeCfg loc defs; } ] ); @@ -168,12 +168,23 @@ let }; aspectMeta = - loc: defs: + typeCfg: loc: defs: { config, ... }: { meta.name = lib.mkForce (locName config.meta.loc); meta.file = lib.mkForce (lib.last defs).file; meta.loc = lib.mkForce loc; + # mkDefault, not mkForce: a declared aspect (root, or one re-typed by + # providerType.merge's wrapperToAspect) sets its own chain at NORMAL + # priority, and that must win here so the chain travels with the + # aspect through re-inclusion the way meta.loc does not — nixpkgs + # drops a mkDefault def entirely once any normal-priority def exists, + # so this only ever supplies the value when nothing else does. + # typeCfg carries a providerPrefix for root/container declarations + # (`or [ ]` yields [ ]); includes elements get providerPrefix + # explicitly nulled (aspectSubmodule), so the present-but-null key + # bypasses `or` and this yields null there — absence, not root. + meta.aspect-chain = lib.mkDefault (typeCfg.providerPrefix or [ ]); }; # A parametric function reaching aspectSubmodule.merge is evaluated as a NixOS @@ -268,7 +279,7 @@ let { name = nameFromLoc; meta = { - provider = typeCfg.providerPrefix or [ ]; + aspect-chain = typeCfg.providerPrefix or [ ]; }; __fn = fn; __args = args; @@ -694,25 +705,34 @@ let # yields ["a" "a"] — a chain every descendant then inherits. Agreeing # definitions collapse; genuinely different ones are an ambiguity den # cannot resolve, so it says so rather than picking one. + # + # null means "no chain set" — distinct from [ ] ("root, chain is + # empty"). Without this distinction a root aspect and an inline + # literal both defaulted to [ ] and were indistinguishable. There is + # no default here: a declared aspect must set its own chain + # (aspectMeta's mkDefault) rather than inherit one, so the value + # travels with the aspect through re-inclusion instead of being + # re-derived at whatever site last merged it. type = lib.types.mkOptionType { name = "aspectChain"; description = "aspect provenance chain"; - check = v: builtins.isList v && builtins.all builtins.isString v; + check = v: v == null || (builtins.isList v && builtins.all builtins.isString v); merge = loc: defs: let distinct = lib.unique (map (d: d.value) defs); + render = c: if c == null then "null" else "[${lib.concatStringsSep " " c}]"; in if distinct == [ ] then - [ ] + null else if builtins.length distinct == 1 then builtins.head distinct else throw "den: conflicting provenance for ${locName loc}: ${ - lib.concatMapStringsSep " vs " (c: "[${lib.concatStringsSep " " c}]") distinct + lib.concatMapStringsSep " vs " render distinct }"; }; - default = typeCfg.providerPrefix or [ ]; + default = null; }; options.collisionPolicy = lib.mkOption { description = "Collision policy for flat-form class module arg/module-system arg overlap."; @@ -738,7 +758,14 @@ let # from __aspectChain). Reading the static typeCfg there truncates the chain # to the aspect's own name, so `alpha/tools` and `beta/tools` both hand # their children the prefix ["tools"] and the children collide. - childProviderPrefix = config.meta.aspect-chain ++ [ config.name ]; + # + # meta.aspect-chain can be null here (an inline includes literal that + # never had its own chain filled in). Naming this aspect's own + # descendants is a separate, computational concern from the chain + # value itself, so null falls back to [ ] purely for that purpose — + # this is not a place that reads absence as root. + ownChain = if config.meta.aspect-chain == null then [ ] else config.meta.aspect-chain; + childProviderPrefix = ownChain ++ [ config.name ]; in { freeformType = lib.types.lazyAttrsOf ( @@ -776,7 +803,12 @@ let }; includes = lib.mkOption { description = "Providers to ask aspects from"; - type = lib.types.listOf (providerType typeCfg); + # providerPrefix explicitly null (not omitted): `or [ ]` only + # falls back on a genuinely MISSING key, so a present-but-null + # key still yields null through aspectMeta's default. That is + # what makes an inline includes literal's chain read as + # "unknown" rather than silently defaulting to root's [ ]. + type = lib.types.listOf (providerType (typeCfg // { providerPrefix = null; })); default = [ ]; }; excludes = lib.mkOption { diff --git a/templates/ci/modules/internal-api/aspect-chain-absence.nix b/templates/ci/modules/internal-api/aspect-chain-absence.nix new file mode 100644 index 000000000..f77cf3b01 --- /dev/null +++ b/templates/ci/modules/internal-api/aspect-chain-absence.nix @@ -0,0 +1,34 @@ +# `meta.aspect-chain` must distinguish "no chain" (unknown/absent) from +# "chain is empty" (a root). Before this, both defaulted to [ ] and were +# indistinguishable, so an inline includes literal silently read as a root. +{ denTest, ... }: +{ + flake.tests.aspect-chain-absence = { + + test-root-chain-is-empty-list = denTest ( + { den, ... }: + { + den.aspects.foo.nixos = { }; + + expr = den.aspects.foo.meta.aspect-chain; + expected = [ ]; + } + ); + + test-inline-include-chain-is-null = denTest ( + { den, ... }: + { + den.aspects.igloo.includes = [ + { + name = "tools"; + nixos = { }; + } + ]; + + expr = (builtins.head den.aspects.igloo.includes).meta.aspect-chain; + expected = null; + } + ); + + }; +} From 29fd53bdad364ee54d840fa3d0890c732b6f7c23 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Wed, 2 Sep 2026 17:21:25 -0700 Subject: [PATCH 04/59] test: pin the identity of a bare parametric fn at a nested provides key types.nix's parametric-fn branch now writes meta.aspect-chain instead of the dead meta.provider key, activating a previously-inert widening of identity for bare parametric fns under `provides`. Nothing in the suite discriminated the two arms; add a cell that does. --- .../internal-api/aspect-chain-absence.nix | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/templates/ci/modules/internal-api/aspect-chain-absence.nix b/templates/ci/modules/internal-api/aspect-chain-absence.nix index f77cf3b01..1b13d76b3 100644 --- a/templates/ci/modules/internal-api/aspect-chain-absence.nix +++ b/templates/ci/modules/internal-api/aspect-chain-absence.nix @@ -30,5 +30,21 @@ } ); + # A bare parametric fn (no lib/config/options args) at a nested `provides` + # key returns a raw wrapper built directly in types.nix, bypassing the + # module system's option merging for `meta`. That wrapper must still carry + # its parent's provider prefix so identity.key gives it a scoped identity + # ("foo/bar") rather than colliding with every other aspect named "bar" — + # the gate dedups on this string. + test-parametric-fn-provides-inherits-provider-prefix = denTest ( + { den, ... }: + { + den.aspects.foo.provides.bar = { host, ... }: { }; + + expr = den.lib.aspects.fx.identity.key den.aspects.foo.provides.bar; + expected = "foo/bar"; + } + ); + }; } From a5374d33213d987a670845d14057d526d876a93f Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Wed, 2 Sep 2026 17:31:39 -0700 Subject: [PATCH 05/59] fix: stop a self-provide wrapper's stale meta from overriding its chain removeAttrs stripped "provider", a key name the aspect-chain rename left behind as a stranded string literal. It never matched the live "aspect-chain" key, so a parametric wrapper carrying its own meta leaked its chain past the filter and won over the freshly computed one. --- nix/lib/aspects/fx/aspect/provide.nix | 2 +- .../ci/modules/internal-api/fx-ctx-apply.nix | 44 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/nix/lib/aspects/fx/aspect/provide.nix b/nix/lib/aspects/fx/aspect/provide.nix index b8d56c16b..65dfe98a8 100644 --- a/nix/lib/aspects/fx/aspect/provide.nix +++ b/nix/lib/aspects/fx/aspect/provide.nix @@ -130,7 +130,7 @@ let // ( if isParamWrapper then builtins.removeAttrs (providerVal.meta or { }) [ - "provider" + "aspect-chain" "selfProvide" ] else diff --git a/templates/ci/modules/internal-api/fx-ctx-apply.nix b/templates/ci/modules/internal-api/fx-ctx-apply.nix index a9594a3ac..aef81c2a0 100644 --- a/templates/ci/modules/internal-api/fx-ctx-apply.nix +++ b/templates/ci/modules/internal-api/fx-ctx-apply.nix @@ -100,6 +100,50 @@ in } ); + # emitAspectPolicies: the wrapper's own stale meta must not survive past + # providerMeta (the freshly computed self-provide chain) — a parametric + # wrapper carrying its own meta.aspect-chain must not win the merge. + test-self-provide-wrapper-meta-does-not-override-chain = denTest ( + { den, ... }: + let + fx = den.lib.fx; + aspect = { + name = "host"; + meta = { }; + provides = { + host = { + __fn = ctx: { + name = "host-provider"; + meta = { }; + includes = [ ]; + }; + __args = { + ctx = false; + }; + meta = { + aspect-chain = [ + "wrong" + "chain" + ]; + selfProvide = false; + }; + }; + }; + includes = [ ]; + }; + comp = den.lib.aspects.fx.aspect.emitAspectPolicies aspect; + result = fx.handle { + handlers = collectHandlers; + state = { }; + } comp; + emitted = builtins.head result.value; + in + { + expr = den.lib.aspects.fx.identity.key emitted; + expected = "host/host"; + } + ); + # Into keys excluded from class emission by structuralKeys. test-into-not-class = denTest ( { den, ... }: From 71e27cc78ed4f697192227fa0fa0d6cb0e9693a5 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Wed, 2 Sep 2026 17:40:09 -0700 Subject: [PATCH 06/59] feat: push the walk chain as a segment list alongside its rendering nameIndexed and constraint/trace consumers key on scopedIncludesChain as rendered strings; a future fill of an absent meta.aspect-chain needs the same parent position as a segment list, not a string to split back apart. chain-push/chain-pop now maintain scopedIncludesChainSegments in lockstep with the existing string chain, fed by the segment list compile-static already builds before rendering it to chainIdentity. --- nix/lib/aspects/fx/aspect/children.nix | 13 +++++++----- nix/lib/aspects/fx/handlers/chain.nix | 20 ++++++++++++++----- .../fx/handlers/compile-conditional.nix | 2 +- .../aspects/fx/handlers/compile-static.nix | 8 ++++++-- .../aspects/fx/handlers/resolve-children.nix | 6 +++++- nix/lib/aspects/fx/pipeline.nix | 1 + 6 files changed, 36 insertions(+), 14 deletions(-) diff --git a/nix/lib/aspects/fx/aspect/children.nix b/nix/lib/aspects/fx/aspect/children.nix index e419bc8be..c2f9f6b3c 100644 --- a/nix/lib/aspects/fx/aspect/children.nix +++ b/nix/lib/aspects/fx/aspect/children.nix @@ -23,13 +23,16 @@ let # siblings don't dedup-collide at the gate. inherit (den.lib.aspects) isSyntheticName; - # Wrap a computation in chain-push/chain-pop of the given identity. + # Wrap a computation in chain-push/chain-pop of the given identity. Pushes + # the segment list alongside the rendered string — a child that needs to + # fill an absent chain reads the edge itself, not a string to re-parse. chainWrap = - nodeIdentity: shouldPush: comp: + nodeIdentity: nodeSegments: shouldPush: comp: if shouldPush then - fx.bind (fx.send "chain-push" { identity = nodeIdentity; }) ( - _: fx.bind comp (result: fx.bind (fx.send "chain-pop" null) (_: fx.pure result)) - ) + fx.bind (fx.send "chain-push" { + identity = nodeIdentity; + segments = nodeSegments; + }) (_: fx.bind comp (result: fx.bind (fx.send "chain-pop" null) (_: fx.pure result))) else comp; diff --git a/nix/lib/aspects/fx/handlers/chain.nix b/nix/lib/aspects/fx/handlers/chain.nix index 5783482d0..10f87dee6 100644 --- a/nix/lib/aspects/fx/handlers/chain.nix +++ b/nix/lib/aspects/fx/handlers/chain.nix @@ -9,25 +9,35 @@ let { param, state }: { resume = null; - state = scopedAppend state "scopedIncludesChain" state.currentScope param.identity; + # scopedIncludesChain carries the rendered string (unchanged — several + # consumers key on it directly); scopedIncludesChainSegments carries + # the same position as a segment list, pushed in lockstep. + state = scopedAppend (scopedAppend state "scopedIncludesChain" state.currentScope + param.identity + ) "scopedIncludesChainSegments" state.currentScope (param.segments or [ ]); }; "chain-pop" = { param, state }: let all = state.scopedIncludesChain null; scopeChain = all.${state.currentScope} or [ ]; + empty = scopeChain == [ ]; updated = all // { ${state.currentScope} = - if scopeChain == [ ] then - throw "fx: chain-pop on empty scopedIncludesChain" - else - lib.init scopeChain; + if empty then throw "fx: chain-pop on empty scopedIncludesChain" else lib.init scopeChain; + }; + allSegments = (state.scopedIncludesChainSegments or (_: { })) null; + segmentsChain = allSegments.${state.currentScope} or [ ]; + updatedSegments = allSegments // { + ${state.currentScope} = + if empty then throw "fx: chain-pop on empty scopedIncludesChain" else lib.init segmentsChain; }; in { resume = null; state = state // { scopedIncludesChain = _: updated; + scopedIncludesChainSegments = _: updatedSegments; }; }; }; diff --git a/nix/lib/aspects/fx/handlers/compile-conditional.nix b/nix/lib/aspects/fx/handlers/compile-conditional.nix index e4af2578c..68306cefd 100644 --- a/nix/lib/aspects/fx/handlers/compile-conditional.nix +++ b/nix/lib/aspects/fx/handlers/compile-conditional.nix @@ -138,7 +138,7 @@ let # anonymous payloads get distinct names instead of dedup-colliding. emitGuardedAspects = condNode: - chainWrap (identity.key condNode) true ( + chainWrap (identity.key condNode) (identity.aspectPath condNode) true ( emitIncludes { __parentScopeHandlers = condNode.__scopeHandlers or null; __parentCtxId = condNode.__ctxId or null; diff --git a/nix/lib/aspects/fx/handlers/compile-static.nix b/nix/lib/aspects/fx/handlers/compile-static.nix index df49839fa..0d96ac571 100644 --- a/nix/lib/aspects/fx/handlers/compile-static.nix +++ b/nix/lib/aspects/fx/handlers/compile-static.nix @@ -32,7 +32,11 @@ in raw = param.aspect; aspect = builtins.removeAttrs raw parametricInternalKeys; nodeIdentity = identity.key aspect; - chainIdentity = identity.pathKey (identity.ownChain aspect ++ [ (aspect.name or "") ]); + # The segment list IS this node's position; chainIdentity is just its + # rendering. Push both so a child needing to fill an absent chain + # gets the edge itself, not a string to re-parse. + chainSegments = identity.ownChain aspect ++ [ (aspect.name or "") ]; + chainIdentity = identity.pathKey chainSegments; isMeaningful = isMeaningfulName (aspect.name or ""); in { @@ -70,7 +74,7 @@ in _: fx.bind (fx.send "resolve-children" { aspect = tagged; - inherit isMeaningful chainIdentity; + inherit isMeaningful chainIdentity chainSegments; }) (resolved: fx.pure [ resolved ]) ) ) diff --git a/nix/lib/aspects/fx/handlers/resolve-children.nix b/nix/lib/aspects/fx/handlers/resolve-children.nix index b5c2f83ac..b896de667 100644 --- a/nix/lib/aspects/fx/handlers/resolve-children.nix +++ b/nix/lib/aspects/fx/handlers/resolve-children.nix @@ -51,6 +51,10 @@ in aspect = param.aspect; isMeaningful = param.isMeaningful; chainIdentity = param.chainIdentity; + # Defaulted: internal-api tests exercise this handler directly without + # a segment list, and isMeaningful = false skips chainWrap's push + # anyway. + chainSegments = param.chainSegments or [ ]; in { resume = @@ -77,7 +81,7 @@ in ) ); in - fx.bind (chainWrap chainIdentity isMeaningful (resolveChildSequence aspect)) ( + fx.bind (chainWrap chainIdentity chainSegments isMeaningful (resolveChildSequence aspect)) ( allChildren: fx.bind (maybeDrain allChildren) ( finalChildren: diff --git a/nix/lib/aspects/fx/pipeline.nix b/nix/lib/aspects/fx/pipeline.nix index c58f85954..fdee57b97 100644 --- a/nix/lib/aspects/fx/pipeline.nix +++ b/nix/lib/aspects/fx/pipeline.nix @@ -162,6 +162,7 @@ let scopedDeferredIncludes = _: { }; scopedDeferredConditionals = _: { }; scopedIncludesChain = _: { }; + scopedIncludesChainSegments = _: { }; scopedConstraintRegistry = _: { }; # Flat filter list only (excludes/substitutes are entity-scoped via # scopedConstraintRegistry; filters have no scoped registry). From 5936a439a95beebbc35ec6c07d16ded7a7c6b390 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Wed, 2 Sep 2026 17:57:25 -0700 Subject: [PATCH 07/59] fix: derive the pushed chain identity from its segment list chainWrap took the rendered string and the segment list as two independent parameters, so a third call site (or a change to identity.key) could push a string and a list naming different positions, read by disjoint consumers. chainWrap now takes the segment list alone and renders the string itself via identity.pathKey, and both producers (compile-static, compile-conditional) push identity.aspectPath so the derivation is sound at every call site. --- nix/lib/aspects/fx/aspect/children.nix | 11 ++++++----- nix/lib/aspects/fx/handlers/compile-conditional.nix | 2 +- nix/lib/aspects/fx/handlers/compile-static.nix | 12 ++++++------ nix/lib/aspects/fx/handlers/resolve-children.nix | 3 +-- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/nix/lib/aspects/fx/aspect/children.nix b/nix/lib/aspects/fx/aspect/children.nix index c2f9f6b3c..33e4aca5d 100644 --- a/nix/lib/aspects/fx/aspect/children.nix +++ b/nix/lib/aspects/fx/aspect/children.nix @@ -23,14 +23,15 @@ let # siblings don't dedup-collide at the gate. inherit (den.lib.aspects) isSyntheticName; - # Wrap a computation in chain-push/chain-pop of the given identity. Pushes - # the segment list alongside the rendered string — a child that needs to - # fill an absent chain reads the edge itself, not a string to re-parse. + # Wrap a computation in chain-push/chain-pop of the given position. Takes + # the segment list alone and derives the rendered string from it — a single + # carrier for one fact, so the string can never name a different position + # than the list it was rendered from. chainWrap = - nodeIdentity: nodeSegments: shouldPush: comp: + nodeSegments: shouldPush: comp: if shouldPush then fx.bind (fx.send "chain-push" { - identity = nodeIdentity; + identity = identity.pathKey nodeSegments; segments = nodeSegments; }) (_: fx.bind comp (result: fx.bind (fx.send "chain-pop" null) (_: fx.pure result))) else diff --git a/nix/lib/aspects/fx/handlers/compile-conditional.nix b/nix/lib/aspects/fx/handlers/compile-conditional.nix index 68306cefd..f7985318d 100644 --- a/nix/lib/aspects/fx/handlers/compile-conditional.nix +++ b/nix/lib/aspects/fx/handlers/compile-conditional.nix @@ -138,7 +138,7 @@ let # anonymous payloads get distinct names instead of dedup-colliding. emitGuardedAspects = condNode: - chainWrap (identity.key condNode) (identity.aspectPath condNode) true ( + chainWrap (identity.aspectPath condNode) true ( emitIncludes { __parentScopeHandlers = condNode.__scopeHandlers or null; __parentCtxId = condNode.__ctxId or null; diff --git a/nix/lib/aspects/fx/handlers/compile-static.nix b/nix/lib/aspects/fx/handlers/compile-static.nix index 0d96ac571..4c785e015 100644 --- a/nix/lib/aspects/fx/handlers/compile-static.nix +++ b/nix/lib/aspects/fx/handlers/compile-static.nix @@ -32,11 +32,11 @@ in raw = param.aspect; aspect = builtins.removeAttrs raw parametricInternalKeys; nodeIdentity = identity.key aspect; - # The segment list IS this node's position; chainIdentity is just its - # rendering. Push both so a child needing to fill an absent chain - # gets the edge itself, not a string to re-parse. - chainSegments = identity.ownChain aspect ++ [ (aspect.name or "") ]; - chainIdentity = identity.pathKey chainSegments; + # Pushed onto the walk's chain for descendants — identity.aspectPath, + # not ownChain ++ [name], so this equals the list identity.key itself + # renders (chainWrap derives its string the same way), and every + # producer of a chain-push agrees on what a segment list means. + chainSegments = identity.aspectPath aspect; isMeaningful = isMeaningfulName (aspect.name or ""); in { @@ -74,7 +74,7 @@ in _: fx.bind (fx.send "resolve-children" { aspect = tagged; - inherit isMeaningful chainIdentity chainSegments; + inherit isMeaningful chainSegments; }) (resolved: fx.pure [ resolved ]) ) ) diff --git a/nix/lib/aspects/fx/handlers/resolve-children.nix b/nix/lib/aspects/fx/handlers/resolve-children.nix index b896de667..f5dd44a4a 100644 --- a/nix/lib/aspects/fx/handlers/resolve-children.nix +++ b/nix/lib/aspects/fx/handlers/resolve-children.nix @@ -50,7 +50,6 @@ in let aspect = param.aspect; isMeaningful = param.isMeaningful; - chainIdentity = param.chainIdentity; # Defaulted: internal-api tests exercise this handler directly without # a segment list, and isMeaningful = false skips chainWrap's push # anyway. @@ -81,7 +80,7 @@ in ) ); in - fx.bind (chainWrap chainIdentity chainSegments isMeaningful (resolveChildSequence aspect)) ( + fx.bind (chainWrap chainSegments isMeaningful (resolveChildSequence aspect)) ( allChildren: fx.bind (maybeDrain allChildren) ( finalChildren: From d652b3ddadae66b0a19df8917b036355a0ea8f1c Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 4 Sep 2026 13:54:03 -0700 Subject: [PATCH 08/59] fix: make every non-inline aspect state its own chain meta.aspect-chain reads null both for an aspect genuinely written inline at an includes site and for one that reaches resolution through some other path never touched by the module system's meta injection: den's own top-level default (a bare submodule option, not an attrsOf element), raw fixtures fed straight to fxFullResolve/capture bypassing the module system, and hand-built closures used as hasAspect/includeIf/exclude guard targets. Filling absent chains from walk position (a later, separate change) can only be sound once null means exactly one thing, so each of these states its own chain explicitly instead. --- modules/aspects/defaults.nix | 10 ++++++ .../internal-api/fx-adapter-integration.nix | 1 + .../modules/internal-api/fx-diag-capture.nix | 19 ++++++----- templates/ci/modules/internal-api/fx-e2e.nix | 1 + .../ci/modules/internal-api/fx-includeIf.nix | 2 ++ .../ci/modules/internal-api/include-dedup.nix | 33 +++++++++++++++++-- 6 files changed, 56 insertions(+), 10 deletions(-) diff --git a/modules/aspects/defaults.nix b/modules/aspects/defaults.nix index a06660756..9faf8903a 100644 --- a/modules/aspects/defaults.nix +++ b/modules/aspects/defaults.nix @@ -5,6 +5,16 @@ type = den.lib.aspects.types.aspectType; }; + # aspectType's meta-injecting merge only fires for options built through a + # container that calls the element type's merge directly (den.aspects is + # attrsOf aspectType). den.default is a bare top-level submodule option: + # nixpkgs expands its nested-path definitions (den.default.includes = ...) + # via the type's getSubOptions instead, which never calls the overridden + # merge, so aspectMeta's mkDefault chain never lands and this reads null. + # den.default is unambiguously a root — it is broadcast, never included by + # anyone — so it states its own chain rather than relying on injection. + config.den.default.meta.aspect-chain = lib.mkDefault [ ]; + # Inject den.default as a schema include for all entity kinds so # default aspects are resolved automatically. This replaces the old # *-to-default policies (host-to-default, user-to-default, home-to-default) diff --git a/templates/ci/modules/internal-api/fx-adapter-integration.nix b/templates/ci/modules/internal-api/fx-adapter-integration.nix index b896d63d2..b2cfe4a18 100644 --- a/templates/ci/modules/internal-api/fx-adapter-integration.nix +++ b/templates/ci/modules/internal-api/fx-adapter-integration.nix @@ -202,6 +202,7 @@ in name = "sops"; meta = { provider = [ ]; + aspect-chain = [ ]; }; includes = [ ]; }; diff --git a/templates/ci/modules/internal-api/fx-diag-capture.nix b/templates/ci/modules/internal-api/fx-diag-capture.nix index 43eec9183..4f1d476e1 100644 --- a/templates/ci/modules/internal-api/fx-diag-capture.nix +++ b/templates/ci/modules/internal-api/fx-diag-capture.nix @@ -13,14 +13,14 @@ let root = { name = "root"; - meta = { }; + meta.aspect-chain = [ ]; nixos = { a = 1; }; includes = [ { name = "child"; - meta = { }; + meta.aspect-chain = [ ]; nixos = { b = 2; }; @@ -58,6 +58,7 @@ root = { name = "root"; meta = { + aspect-chain = [ ]; handleWith = fxLib.constraints.exclude target; }; includes = [ @@ -99,11 +100,11 @@ let root = { name = "root"; - meta = { }; + meta.aspect-chain = [ ]; includes = [ { name = "child"; - meta = { }; + meta.aspect-chain = [ ]; includes = [ ]; } ]; @@ -122,15 +123,15 @@ let root = { name = "root"; - meta = { }; + meta.aspect-chain = [ ]; includes = [ { name = "child"; - meta = { }; + meta.aspect-chain = [ ]; includes = [ { name = "grandchild"; - meta = { }; + meta.aspect-chain = [ ]; includes = [ ]; } ]; @@ -157,6 +158,7 @@ root = { name = "root"; meta = { + aspect-chain = [ ]; handleWith = fxLib.constraints.exclude target; }; includes = [ @@ -187,7 +189,7 @@ let root = { name = "root"; - meta = { }; + meta.aspect-chain = [ ]; nixos = { a = 1; }; @@ -226,6 +228,7 @@ root = { name = "root"; meta = { + aspect-chain = [ ]; handleWith = fxLib.constraints.exclude target; }; includes = [ ]; diff --git a/templates/ci/modules/internal-api/fx-e2e.nix b/templates/ci/modules/internal-api/fx-e2e.nix index 4ff44e0c9..e900f1ed3 100644 --- a/templates/ci/modules/internal-api/fx-e2e.nix +++ b/templates/ci/modules/internal-api/fx-e2e.nix @@ -153,6 +153,7 @@ name = "sops"; meta = { provider = [ ]; + aspect-chain = [ ]; }; includes = [ ]; }; diff --git a/templates/ci/modules/internal-api/fx-includeIf.nix b/templates/ci/modules/internal-api/fx-includeIf.nix index 169fec35f..44c79b9f9 100644 --- a/templates/ci/modules/internal-api/fx-includeIf.nix +++ b/templates/ci/modules/internal-api/fx-includeIf.nix @@ -90,6 +90,7 @@ name = "sops"; meta = { provider = [ ]; + aspect-chain = [ ]; }; includes = [ ]; }; @@ -184,6 +185,7 @@ name = "sops"; meta = { provider = [ ]; + aspect-chain = [ ]; }; includes = [ ]; }; diff --git a/templates/ci/modules/internal-api/include-dedup.nix b/templates/ci/modules/internal-api/include-dedup.nix index 0f7175851..1c7f50f18 100644 --- a/templates/ci/modules/internal-api/include-dedup.nix +++ b/templates/ci/modules/internal-api/include-dedup.nix @@ -148,7 +148,7 @@ let shared = { name = "shared"; - meta = { }; + meta.aspect-chain = [ ]; nixos = { networking.hostName = "test"; }; @@ -199,7 +199,7 @@ let shared = { name = "shared"; - meta = { }; + meta.aspect-chain = [ ]; nixos = { config, ... }: { @@ -245,6 +245,35 @@ } ); + # A hand-built aspect fed straight to fxFullResolve (bypassing the module + # system, which is what normally stamps a declared aspect's chain as + # `[ ]`) must state its own chain rather than reading null at resolution. + # Falsify by reverting `shared`'s `meta.aspect-chain = [ ]` to `meta = { }`. + test-raw-fixture-carries-its-own-chain = denTest ( + { den, ... }: + let + shared = { + name = "shared"; + meta.aspect-chain = [ ]; + includes = [ ]; + }; + result = den.lib.aspects.fx.pipeline.fxFullResolve { + class = "nixos"; + self = { + name = "root"; + meta = { }; + includes = [ shared ]; + }; + ctx = { }; + }; + sharedNode = ((result.state.resolvedNodes or (_: { })) null)."shared" or null; + in + { + expr = sharedNode.meta.aspect-chain or null; + expected = [ ]; + } + ); + # Same aspect with different __ctxId values — both should resolve (no dedup). test-no-dedup-different-contexts = denTest ( { den, ... }: From 6e880c7266d009f1d78bd55361e2c369c0429d7a Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 4 Sep 2026 14:25:18 -0700 Subject: [PATCH 09/59] test: discriminate the raw-fixture chain cell against defaults.nix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The acceptance cell for a raw fixture stating its own chain hardcoded the chain into the fixture itself, so deleting defaults.nix's den.default stamp left it green — nothing exercised the actual construction site. Add a cell that reads den.default's chain directly, and give the fixture's own root an explicit chain instead of leaving it another absent-chain node. --- .../ci/modules/internal-api/include-dedup.nix | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/templates/ci/modules/internal-api/include-dedup.nix b/templates/ci/modules/internal-api/include-dedup.nix index 1c7f50f18..dc01fbd0d 100644 --- a/templates/ci/modules/internal-api/include-dedup.nix +++ b/templates/ci/modules/internal-api/include-dedup.nix @@ -261,7 +261,7 @@ class = "nixos"; self = { name = "root"; - meta = { }; + meta.aspect-chain = [ ]; includes = [ shared ]; }; ctx = { }; @@ -274,6 +274,19 @@ } ); + # The cell above hardcodes `shared`'s own chain, so it can't discriminate + # the actual construction site (defaults.nix stamping den.default's own + # chain, since den.default is a bare top-level submodule option that + # bypasses aspectType's merge). Assert on that production value directly. + # Falsify by deleting defaults.nix's `config.den.default.meta.aspect-chain`. + test-den-default-states-its-own-chain = denTest ( + { den, ... }: + { + expr = den.default.meta.aspect-chain; + expected = [ ]; + } + ); + # Same aspect with different __ctxId values — both should resolve (no dedup). test-no-dedup-different-contexts = denTest ( { den, ... }: From c3529de37d18ec5804bba33f29c46bda731cf69b Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 4 Sep 2026 14:39:10 -0700 Subject: [PATCH 10/59] fix: fill an absent aspect-chain from the walk's current position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An inline `includes = [ { name = ...; ... } ]` literal never gets a declared chain, so two owners each writing their own inline aspect of the same name both read as root and collide at the gate. Fill from the walk's current parent position in compile-static, where this node's own meta is already forced — but only when the chain is absent, never overwriting a declared aspect's chain, so a shared reference included from two sites keeps one identity. Exclude names children.nix has already walk-stamped (nameIndexed/nameAnon): those already encode their ancestor position in the name itself, and filling their chain too would double-encode that position, compounding exponentially at every further level of nesting. --- .../aspects/fx/handlers/compile-static.nix | 52 +++++- .../deadbugs/inline-include-provenance.nix | 166 ++++++++++++++++++ 2 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 templates/ci/modules/features/deadbugs/inline-include-provenance.nix diff --git a/nix/lib/aspects/fx/handlers/compile-static.nix b/nix/lib/aspects/fx/handlers/compile-static.nix index 4c785e015..d68c6b56a 100644 --- a/nix/lib/aspects/fx/handlers/compile-static.nix +++ b/nix/lib/aspects/fx/handlers/compile-static.nix @@ -23,6 +23,20 @@ let "__parametricResolvedArgs" ]; + # nameIndexed/nameAnon (children.nix) already stamp an unnamed or + # synthetic-named sibling's walk position straight into its name — + # "/:" — precisely so it disambiguates without a + # chain at all. Filling the chain for one of these too would encode + # that same position twice (once in the name, once in + # meta.aspect-chain), and the two copies compound multiplicatively + # at every further level of nesting: each level's stamped name + # already contains the whole rendered chain so far, then that name + # becomes an element of the next level's filled chain, doubling it. + # Detect the stamp by its mechanical ":" suffix rather than by + # which synthetic marker produced it, since nameIndexed uses this + # same shape for every marker (, , ...). + isWalkStampedName = n: builtins.match ".*:[0-9]+(/.*)?" n != null; + in { compileStaticHandler = { @@ -30,7 +44,43 @@ in { param, state }: let raw = param.aspect; - aspect = builtins.removeAttrs raw parametricInternalKeys; + withoutParametricKeys = builtins.removeAttrs raw parametricInternalKeys; + # An inline `includes = [ { name = ...; ... } ]` literal never gets a + # declared chain (types.nix leaves meta.aspect-chain null there, + # distinct from a genuine root's `[ ]`) — so two owners each writing + # their own inline "tools" both read as root and collide at the gate. + # Fill from the walk's current position, read here rather than in + # the include walk: the walk's own stack frame can't force a child's + # meta without cascading into its includes and grandchildren + # synchronously, collapsing the fx trampoline; compile-static already + # forces this node's own meta (via identity.key) at a point the + # trampoline has deferred to, so filling here costs nothing extra. + parentStack = ((state.scopedIncludesChainSegments or (_: { })) null).${state.currentScope} or [ ]; + # aspectPath appends a "{ctxId}" segment for context-bound nodes — + # an instance marker, not an ancestor — so it must not end up inside + # a written chain. Mirrors trace.nix's stripping of the same marker + # shape on the rendered string form. + parentChainSegments = builtins.filter (s: builtins.match "\\{.*" s == null) ( + if parentStack == [ ] then [ ] else lib.last parentStack + ); + # Fill only where the chain is absent and the name is a real, + # walk-independent one. A declared aspect referenced from two + # different inclusion sites must keep its one identity — stamping + # the inclusion site here instead would give it two and double-emit + # it. + aspect = + if + (withoutParametricKeys.meta.aspect-chain or null) == null + && !(isWalkStampedName (withoutParametricKeys.name or "")) + then + withoutParametricKeys + // { + meta = (withoutParametricKeys.meta or { }) // { + aspect-chain = parentChainSegments; + }; + } + else + withoutParametricKeys; nodeIdentity = identity.key aspect; # Pushed onto the walk's chain for descendants — identity.aspectPath, # not ownChain ++ [name], so this equals the list identity.key itself diff --git a/templates/ci/modules/features/deadbugs/inline-include-provenance.nix b/templates/ci/modules/features/deadbugs/inline-include-provenance.nix new file mode 100644 index 000000000..13f0a0c9e --- /dev/null +++ b/templates/ci/modules/features/deadbugs/inline-include-provenance.nix @@ -0,0 +1,166 @@ +# An aspect written inline into `includes` belongs to the aspect that includes +# it, and must carry that owner's chain. Today it carries none, and a node with +# no chain answers ROOT, which is the same answer a genuine root gives: two +# owners writing `{ name = "tools"; ... }` collapse onto one identity and gate +# dedup drops one of them. +# +# Anonymous inline includes already get `/:`, so den already +# applies this rule; it just does not apply it to named values. +{ denTest, ... }: +{ + flake.tests.deadbugs.inline-include-provenance = { + + test-inline-includes-from-different-owners-both-deliver = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.igloo.includes = [ + den.aspects.alpha + den.aspects.beta + ]; + + den.aspects.alpha.includes = [ + { + name = "tools"; + nixos.environment.etc."alpha".text = "yes"; + } + ]; + + den.aspects.beta.includes = [ + { + name = "tools"; + nixos.environment.etc."beta".text = "yes"; + } + ]; + + expr = { + alpha = igloo.environment.etc ? "alpha"; + beta = igloo.environment.etc ? "beta"; + }; + expected = { + alpha = true; + beta = true; + }; + } + ); + + # Delivery alone would pass for a fix that just kept the two registrations + # apart without actually distinguishing identity (e.g. widening the gate's + # dedup key on some unrelated field). Assert at the membership seam + # instead: the two "tools" nodes must be genuinely distinct identities. + test-inline-includes-from-different-owners-have-distinct-identities = denTest ( + { den, ... }: + let + toolsNodes = builtins.filter (n: n.name == "tools") den.hosts.x86_64-linux.igloo.aspects; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.igloo.includes = [ + den.aspects.alpha + den.aspects.beta + ]; + + den.aspects.alpha.includes = [ + { + name = "tools"; + nixos.environment.etc."alpha".text = "yes"; + } + ]; + + den.aspects.beta.includes = [ + { + name = "tools"; + nixos.environment.etc."beta".text = "yes"; + } + ]; + + expr = { + count = builtins.length toolsNodes; + identities = builtins.sort builtins.lessThan (map (n: n.identity) toolsNodes); + }; + expected = { + count = 2; + identities = [ + "alpha/tools" + "beta/tools" + ]; + }; + } + ); + + # Two levels of unfilled inline literals: "mid" is itself an inline literal + # nested in "outer"'s includes, and "leaf" is an inline literal nested in + # "mid"'s includes. Both must be filled — "mid" from outer's position, then + # "leaf" from mid's (already-filled) position — so leaf's full chain + # reflects both ancestors, not just its immediate parent. + test-inline-include-nested-two-levels-gets-full-chain = denTest ( + { den, igloo, ... }: + let + leafNodes = builtins.filter (n: n.name == "leaf") den.hosts.x86_64-linux.igloo.aspects; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.igloo.includes = [ den.aspects.outer ]; + + den.aspects.outer.includes = [ + { + name = "mid"; + includes = [ + { + name = "leaf"; + nixos.environment.etc."leaf".text = "yes"; + } + ]; + } + ]; + + expr = { + delivered = igloo.environment.etc ? "leaf"; + identity = (builtins.head leafNodes).identity; + }; + expected = { + delivered = true; + identity = "outer/mid/leaf"; + }; + } + ); + + # CONTROL: a declared aspect (not an inline literal — its chain is already + # "[ ]", not null) referenced from two different owners must keep its ONE + # identity. Falsifies any fix that stamps the inclusion site onto every + # node instead of filling only where the chain is absent — that would give + # this aspect two identities and double-emit it. + test-control-shared-declared-aspect-keeps-one-identity = denTest ( + { den, igloo, ... }: + let + sharedNodes = builtins.filter (n: n.name == "shared") den.hosts.x86_64-linux.igloo.aspects; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.igloo.includes = [ + den.aspects.owner1 + den.aspects.owner2 + ]; + + den.aspects.owner1.includes = [ den.aspects.shared ]; + den.aspects.owner2.includes = [ den.aspects.shared ]; + + den.aspects.shared.nixos.environment.etc."shared".text = "yes"; + + expr = { + delivered = igloo.environment.etc ? "shared"; + count = builtins.length sharedNodes; + }; + expected = { + delivered = true; + count = 1; + }; + } + ); + + }; +} From c99f8bb7286697ccb6cb23661ac11618080f2152 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 4 Sep 2026 17:00:25 -0700 Subject: [PATCH 11/59] fix: state each shipped predicate battery's own chain segment insecure-predicate-builder.nix and unfree-predicate-builder.nix ship their parent aspect as a raw attrset (no meta) under den.default.includes, so the fill in compile-static gave it den.default's own position instead of staying a root. Their children carried author-written names that already encoded the parent's path ("insecure-predicate/os"), so filling the parent's now-inherited chain onto them doubled it. State every one of these aspects' own chains explicitly, splitting the "/"-named children into a real chain segment rather than leaving the path baked into the name string as a single segment. --- .../insecure/insecure-predicate-builder.nix | 16 +++++++-- .../unfree/unfree-predicate-builder.nix | 16 +++++++-- .../deadbugs/shipped-battery-chain.nix | 35 +++++++++++++++++++ 3 files changed, 61 insertions(+), 6 deletions(-) create mode 100644 templates/ci/modules/features/deadbugs/shipped-battery-chain.nix diff --git a/modules/aspects/batteries/insecure/insecure-predicate-builder.nix b/modules/aspects/batteries/insecure/insecure-predicate-builder.nix index beea6576d..5c3800b08 100644 --- a/modules/aspects/batteries/insecure/insecure-predicate-builder.nix +++ b/modules/aspects/batteries/insecure/insecure-predicate-builder.nix @@ -27,10 +27,14 @@ let }; }; + # Chain segments state each child's position under "insecure-predicate" + # explicitly rather than letting the name carry it — a name can never + # double-encode an ancestor that's already named as its own chain segment. osAspect = { host }: { - name = "insecure-predicate/os"; + name = "os"; + meta.aspect-chain = [ "insecure-predicate" ]; } # A synthetic host identity (from a `user@host` home with no declared host) # has no class output, so there is nothing to import into. Guard like @@ -42,7 +46,8 @@ let userAspect = { host, user }: { - name = "insecure-predicate/user"; + name = "user"; + meta.aspect-chain = [ "insecure-predicate" ]; } // lib.optionalAttrs (lib.elem "homeManager" user.classes) { homeManager.imports = [ insecureModule ]; @@ -51,7 +56,8 @@ let homeAspect = { home }: { - name = "insecure-predicate/home"; + name = "home"; + meta.aspect-chain = [ "insecure-predicate" ]; } // lib.optionalAttrs (home ? class) { ${home.class}.imports = [ insecureModule ]; @@ -59,6 +65,10 @@ let aspect = { name = "insecure-predicate"; + # Stated explicitly, not left to fill from the walk: this is included + # from den.default, and without its own chain it would inherit + # den.default's position instead of staying a root. + meta.aspect-chain = [ ]; inherit description; includes = [ osAspect diff --git a/modules/aspects/batteries/unfree/unfree-predicate-builder.nix b/modules/aspects/batteries/unfree/unfree-predicate-builder.nix index 38f494f4e..8966bdfb2 100644 --- a/modules/aspects/batteries/unfree/unfree-predicate-builder.nix +++ b/modules/aspects/batteries/unfree/unfree-predicate-builder.nix @@ -27,10 +27,14 @@ let }; }; + # Chain segments state each child's position under "unfree-predicate" + # explicitly rather than letting the name carry it — a name can never + # double-encode an ancestor that's already named as its own chain segment. osAspect = { host }: { - name = "unfree-predicate/os"; + name = "os"; + meta.aspect-chain = [ "unfree-predicate" ]; } # A synthetic host identity (from a `user@host` home with no declared host) # has no class output, so there is nothing to import into. Guard like @@ -42,7 +46,8 @@ let userAspect = { host, user }: { - name = "unfree-predicate/user"; + name = "user"; + meta.aspect-chain = [ "unfree-predicate" ]; } // lib.optionalAttrs (lib.elem "homeManager" user.classes) { homeManager.imports = [ unfreeModule ]; @@ -51,7 +56,8 @@ let homeAspect = { home }: { - name = "unfree-predicate/home"; + name = "home"; + meta.aspect-chain = [ "unfree-predicate" ]; } // lib.optionalAttrs (home ? class) { ${home.class}.imports = [ unfreeModule ]; @@ -59,6 +65,10 @@ let aspect = { name = "unfree-predicate"; + # Stated explicitly, not left to fill from the walk: this is included + # from den.default, and without its own chain it would inherit + # den.default's position instead of staying a root. + meta.aspect-chain = [ ]; inherit description; includes = [ osAspect diff --git a/templates/ci/modules/features/deadbugs/shipped-battery-chain.nix b/templates/ci/modules/features/deadbugs/shipped-battery-chain.nix new file mode 100644 index 000000000..20b1bdfc9 --- /dev/null +++ b/templates/ci/modules/features/deadbugs/shipped-battery-chain.nix @@ -0,0 +1,35 @@ +# insecure-predicate-builder.nix and unfree-predicate-builder.nix ship their +# parent aspect as a raw attrset (no meta) under den.default.includes, and +# their children carry author-written names that already encode the parent's +# path ("insecure-predicate/os", ...). Filling the parent's chain from +# den.default's walk position moved it to "default/insecure-predicate"; the +# same fill on a child then prepended that same "insecure-predicate" a second +# time, on top of the copy already embedded in the child's own name — every +# den configuration includes den.default, so every one was affected. +{ denTest, ... }: +{ + flake.tests.deadbugs.shipped-battery-chain = { + + test-insecure-and-unfree-batteries-keep-shipped-identities = denTest ( + { den, ... }: + let + wanted = [ + "insecure-predicate" + "insecure-predicate/os" + "insecure-predicate/user" + "unfree-predicate" + "unfree-predicate/os" + "unfree-predicate/user" + ]; + identities = map (n: n.identity) den.hosts.x86_64-linux.igloo.aspects; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + expr = builtins.sort builtins.lessThan (builtins.filter (i: builtins.elem i wanted) identities); + expected = builtins.sort builtins.lessThan wanted; + } + ); + + }; +} From a8b5d2925b24ee1e68699287cc6c97253d883078 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 4 Sep 2026 17:00:37 -0700 Subject: [PATCH 12/59] fix: mark a walk-stamped name at the point it is stamped isWalkStampedName tested a name's shape (".*:[0-9]+(/.*)?") to infer whether the walk had invented it, which also matches an author-written name in that shape ("gcc:14") and wrongly excludes it from the inline-chain fill. Set __walkStamped on the nameAnon/nameIndexed branches in children.nix, where the stamped name is actually produced, and have compile-static test that marker instead of pattern-matching the name. Add __walkStamped to key-classification.nix's structural keys so it doesn't reach classification as a class key. --- nix/lib/aspects/fx/aspect/children.nix | 18 ++- .../aspects/fx/handlers/compile-static.nix | 24 ++-- nix/lib/aspects/fx/key-classification.nix | 1 + .../colon-digit-named-inline-include.nix | 134 ++++++++++++++++++ 4 files changed, 160 insertions(+), 17 deletions(-) create mode 100644 templates/ci/modules/features/deadbugs/colon-digit-named-inline-include.nix diff --git a/nix/lib/aspects/fx/aspect/children.nix b/nix/lib/aspects/fx/aspect/children.nix index 33e4aca5d..26b28f6aa 100644 --- a/nix/lib/aspects/fx/aspect/children.nix +++ b/nix/lib/aspects/fx/aspect/children.nix @@ -97,13 +97,27 @@ let state: let childName = withScope.name or ""; + # __walkStamped records that the name below was invented from walk + # position, not authored — compile-static reads it to decide + # whether filling meta.aspect-chain here would double-encode the + # same position (once in the stamped name, once in the chain). + # A marker set here, rather than a shape compile-static infers from + # the name, can't be confused with an author's own name choice. child = if skipNameAnon then withScope else if !(isMeaningfulName childName) then - withScope // { name = nameAnon state idx (withScope.__ctxId or null); } + withScope + // { + name = nameAnon state idx (withScope.__ctxId or null); + __walkStamped = true; + } else if isSyntheticName childName then - withScope // { name = nameIndexed state childName idx (withScope.__ctxId or null); } + withScope + // { + name = nameIndexed state childName idx (withScope.__ctxId or null); + __walkStamped = true; + } else withScope; in diff --git a/nix/lib/aspects/fx/handlers/compile-static.nix b/nix/lib/aspects/fx/handlers/compile-static.nix index d68c6b56a..5bf368d39 100644 --- a/nix/lib/aspects/fx/handlers/compile-static.nix +++ b/nix/lib/aspects/fx/handlers/compile-static.nix @@ -23,20 +23,6 @@ let "__parametricResolvedArgs" ]; - # nameIndexed/nameAnon (children.nix) already stamp an unnamed or - # synthetic-named sibling's walk position straight into its name — - # "/:" — precisely so it disambiguates without a - # chain at all. Filling the chain for one of these too would encode - # that same position twice (once in the name, once in - # meta.aspect-chain), and the two copies compound multiplicatively - # at every further level of nesting: each level's stamped name - # already contains the whole rendered chain so far, then that name - # becomes an element of the next level's filled chain, doubling it. - # Detect the stamp by its mechanical ":" suffix rather than by - # which synthetic marker produced it, since nameIndexed uses this - # same shape for every marker (, , ...). - isWalkStampedName = n: builtins.match ".*:[0-9]+(/.*)?" n != null; - in { compileStaticHandler = { @@ -68,10 +54,18 @@ in # different inclusion sites must keep its one identity — stamping # the inclusion site here instead would give it two and double-emit # it. + # + # __walkStamped (set by children.nix's nameAnon/nameIndexed) marks a + # name invented from walk position rather than authored. Filling the + # chain for one of these too would encode that same position twice + # (once in the stamped name, once in meta.aspect-chain), and the two + # copies compound multiplicatively at every further level of + # nesting. Testing the marker, not the name's shape, means an + # author's own name (e.g. "gcc:14") can never be mistaken for one. aspect = if (withoutParametricKeys.meta.aspect-chain or null) == null - && !(isWalkStampedName (withoutParametricKeys.name or "")) + && !(withoutParametricKeys.__walkStamped or false) then withoutParametricKeys // { diff --git a/nix/lib/aspects/fx/key-classification.nix b/nix/lib/aspects/fx/key-classification.nix index a54b2437b..f1d29af9a 100644 --- a/nix/lib/aspects/fx/key-classification.nix +++ b/nix/lib/aspects/fx/key-classification.nix @@ -27,6 +27,7 @@ let "__contentValues" "__aspectChain" "__providesForwarded" + "__walkStamped" "_module" "_" ]; diff --git a/templates/ci/modules/features/deadbugs/colon-digit-named-inline-include.nix b/templates/ci/modules/features/deadbugs/colon-digit-named-inline-include.nix new file mode 100644 index 000000000..537d8dec1 --- /dev/null +++ b/templates/ci/modules/features/deadbugs/colon-digit-named-inline-include.nix @@ -0,0 +1,134 @@ +# isWalkStampedName tested a name's SHAPE (".*:[0-9]+(/.*)?") to decide +# whether the walk had stamped it, rather than testing whether the walk +# actually had. An author-written name in that shape — "gcc:14", the +# ordinary package-name:version convention — was mistaken for a walk stamp, +# excluded from the inline-chain fill, and read as root: two owners' "gcc:14" +# collide onto one identity and gate dedup drops one, the exact defect this +# task exists to close. "gcc14" (no colon) is the control and must keep +# working the whole time. +{ denTest, ... }: +{ + flake.tests.deadbugs.colon-digit-named-inline-include = { + + test-inline-includes-named-with-colon-digit-both-deliver = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.igloo.includes = [ + den.aspects.alpha + den.aspects.beta + ]; + + den.aspects.alpha.includes = [ + { + name = "gcc:14"; + nixos.environment.etc."alpha".text = "yes"; + } + ]; + + den.aspects.beta.includes = [ + { + name = "gcc:14"; + nixos.environment.etc."beta".text = "yes"; + } + ]; + + expr = { + alpha = igloo.environment.etc ? "alpha"; + beta = igloo.environment.etc ? "beta"; + }; + expected = { + alpha = true; + beta = true; + }; + } + ); + + test-inline-includes-named-with-colon-digit-have-distinct-identities = denTest ( + { den, ... }: + let + nodes = builtins.filter (n: n.name == "gcc:14") den.hosts.x86_64-linux.igloo.aspects; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.igloo.includes = [ + den.aspects.alpha + den.aspects.beta + ]; + + den.aspects.alpha.includes = [ + { + name = "gcc:14"; + nixos.environment.etc."alpha".text = "yes"; + } + ]; + + den.aspects.beta.includes = [ + { + name = "gcc:14"; + nixos.environment.etc."beta".text = "yes"; + } + ]; + + expr = { + count = builtins.length nodes; + identities = builtins.sort builtins.lessThan (map (n: n.identity) nodes); + }; + expected = { + count = 2; + identities = [ + "alpha/gcc:14" + "beta/gcc:14" + ]; + }; + } + ); + + # CONTROL: same shape of test, no colon in the name. Must stay green + # throughout — falsifies a fix that accidentally widens exclusion rather + # than narrowing it to an actual walk stamp. + test-control-colonless-name-stays-distinct = denTest ( + { den, ... }: + let + nodes = builtins.filter (n: n.name == "gcc14") den.hosts.x86_64-linux.igloo.aspects; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.igloo.includes = [ + den.aspects.alpha + den.aspects.beta + ]; + + den.aspects.alpha.includes = [ + { + name = "gcc14"; + nixos.environment.etc."alpha".text = "yes"; + } + ]; + + den.aspects.beta.includes = [ + { + name = "gcc14"; + nixos.environment.etc."beta".text = "yes"; + } + ]; + + expr = { + count = builtins.length nodes; + identities = builtins.sort builtins.lessThan (map (n: n.identity) nodes); + }; + expected = { + count = 2; + identities = [ + "alpha/gcc14" + "beta/gcc14" + ]; + }; + } + ); + + }; +} From 77be80280b87d56674cd82eb5042a5341aa4ce44 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 4 Sep 2026 17:35:13 -0700 Subject: [PATCH 13/59] fix: carry __walkStamped through the parametric-resolve round-trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mkParametricBase rebuilt a parametric-resolved aspect from an explicit carry-forward whitelist (name, meta, into, provides) instead of merging onto the original, silently dropping __walkStamped for any walk-stamped child that also compiled as parametric (including a plain static aspect promoted into parametric shape by compile.nix's descendant-arg router). The chain fill then re-fired on re-entry, double-encoding the segment already embedded in the walk-stamped name — live on the stock igloo/tux fixture with no user aspects at all. --- nix/lib/aspects/fx/aspect.nix | 3 +- .../walkstamp-parametric-roundtrip.nix | 35 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 templates/ci/modules/features/deadbugs/walkstamp-parametric-roundtrip.nix diff --git a/nix/lib/aspects/fx/aspect.nix b/nix/lib/aspects/fx/aspect.nix index 6501dcf88..e6204fe80 100644 --- a/nix/lib/aspects/fx/aspect.nix +++ b/nix/lib/aspects/fx/aspect.nix @@ -47,7 +47,8 @@ let }; } // lib.optionalAttrs (aspect ? into) { inherit (aspect) into; } - // lib.optionalAttrs (aspect ? provides) { inherit (aspect) provides; }; + // lib.optionalAttrs (aspect ? provides) { inherit (aspect) provides; } + // lib.optionalAttrs (aspect ? __walkStamped) { inherit (aspect) __walkStamped; }; # Merge the resolved value into the parametric base. mkParametricNext = diff --git a/templates/ci/modules/features/deadbugs/walkstamp-parametric-roundtrip.nix b/templates/ci/modules/features/deadbugs/walkstamp-parametric-roundtrip.nix new file mode 100644 index 000000000..d4f365a2b --- /dev/null +++ b/templates/ci/modules/features/deadbugs/walkstamp-parametric-roundtrip.nix @@ -0,0 +1,35 @@ +# mkParametricBase (nix/lib/aspects/fx/aspect.nix) rebuilt a parametric-resolved +# aspect from an explicit carry-forward whitelist (name, meta, into, provides) +# instead of merging onto the original, so a walk-stamped child that also went +# through compile-parametric (e.g. a class-content module naming a descendant +# entity kind, promoted by compile.nix's router) lost its __walkStamped marker +# while its walk-stamped name string survived. compile-static then re-fired the +# chain fill on re-entry, double-encoding the segment already embedded in the +# name. Live on the stock igloo/tux fixture with no user aspects at all. +{ denTest, lib, ... }: +{ + flake.tests.deadbugs.walkstamp-parametric-roundtrip = { + + test-walk-stamped-parametric-child-identity-does-not-double = denTest ( + { den, ... }: + let + identities = map (n: n.identity) den.hosts.x86_64-linux.igloo.aspects; + # A doubled identity repeats one segment back-to-back, e.g. + # "user/user/:5" — general shape, not tied to a specific index. + hasAdjacentDup = + segs: + builtins.any (i: i > 0 && builtins.elemAt segs i == builtins.elemAt segs (i - 1)) ( + builtins.genList (i: i) (builtins.length segs) + ); + doubled = builtins.filter (i: hasAdjacentDup (lib.splitString "/" i)) identities; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + expr = doubled; + expected = [ ]; + } + ); + + }; +} From 27a53236923afbb1a998692b8108a4d727fc2b25 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 4 Sep 2026 17:51:21 -0700 Subject: [PATCH 14/59] fix: key a definition-position chain registry so a shared let-bound aspect claims one identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An inline includes literal with no declared chain reads as root, same as any other unfilled null — so a value written once and included from two owners was indistinguishable from two separately-authored literals, and each inclusion site filled its own chain and split the value into two nodes that double-emitted (types.lines content, so the duplication reached delivered config, not just a diagnostic identity string). providerType.merge (types.nix) now stamps each raw def with its author- written definition position, read before wrapperToAspect rewrites `name`. compile-static's chain fill keys a per-scope registry on that position: the first node to fill at a position claims the walk's current chain, and every later node at the same position reuses the claimed chain instead of filling its own, so both render one identity and gate dedup collapses them to one emission. A node with no definition position (declared aspects, parametric wrappers) behaves exactly as before. --- .../aspects/fx/handlers/compile-static.nix | 29 +++- nix/lib/aspects/types.nix | 31 ++++- .../deadbugs/shared-raw-include-splits.nix | 124 ++++++++++++++++++ 3 files changed, 177 insertions(+), 7 deletions(-) create mode 100644 templates/ci/modules/features/deadbugs/shared-raw-include-splits.nix diff --git a/nix/lib/aspects/fx/handlers/compile-static.nix b/nix/lib/aspects/fx/handlers/compile-static.nix index 5bf368d39..c67a38a17 100644 --- a/nix/lib/aspects/fx/handlers/compile-static.nix +++ b/nix/lib/aspects/fx/handlers/compile-static.nix @@ -62,20 +62,37 @@ in # copies compound multiplicatively at every further level of # nesting. Testing the marker, not the name's shape, means an # author's own name (e.g. "gcc:14") can never be mistaken for one. + defPos = withoutParametricKeys.meta.__defPos or null; + chainRegistry = ((state.chainByDefPos or (_: { })) null); + claimedChain = if defPos == null then null else chainRegistry.${defPos} or null; + fillsChain = + (withoutParametricKeys.meta.aspect-chain or null) == null + && !(withoutParametricKeys.__walkStamped or false); + # A shared let-bound value reports the same __defPos at every inclusion + # site: the first node to fill here claims parentChainSegments for that + # position, and every later node at the same position reuses the + # claimed chain instead of filling its own — so both render one + # identity and gate dedup collapses them to one emission. A node with + # no __defPos (no author position, or the null the head-of-attrNames + # fallback would have destroyed — see types.nix) behaves exactly as + # the unregistered fill below. + filledChain = if claimedChain != null then claimedChain else parentChainSegments; aspect = - if - (withoutParametricKeys.meta.aspect-chain or null) == null - && !(withoutParametricKeys.__walkStamped or false) - then + if fillsChain then withoutParametricKeys // { meta = (withoutParametricKeys.meta or { }) // { - aspect-chain = parentChainSegments; + aspect-chain = filledChain; }; } else withoutParametricKeys; nodeIdentity = identity.key aspect; + nextState = + if fillsChain && defPos != null && claimedChain == null then + state // { chainByDefPos = _: chainRegistry // { ${defPos} = parentChainSegments; }; } + else + state; # Pushed onto the walk's chain for descendants — identity.aspectPath, # not ownChain ++ [name], so this equals the list identity.key itself # renders (chainWrap derives its string the same way), and every @@ -125,7 +142,7 @@ in ) ) ); - inherit state; + state = nextState; }; }; } diff --git a/nix/lib/aspects/types.nix b/nix/lib/aspects/types.nix index f41f68bf0..0d5f851d8 100644 --- a/nix/lib/aspects/types.nix +++ b/nix/lib/aspects/types.nix @@ -318,6 +318,35 @@ let merge = loc: defs: let + # Definition position of the value as the author wrote it, read from + # the ORIGINAL defs: wrapperToAspect below rewrites `name`, and a + # position read after that points at types.nix rather than the + # author's file. Two inclusion sites of one let-bound value report + # ONE position; two separately-written inline literals report two. + defPosOf = + v: + let + p = if v ? name then builtins.unsafeGetAttrPos "name" v else null; + in + if p == null then null else "${toString p.file}:${toString p.line}:${toString p.column}"; + stampDefPos = + d: + let + pos = defPosOf d.value; + in + if + builtins.isAttrs d.value && !(d.value.__isPolicy or false) && !(d.value ? __fn) && pos != null + then + d + // { + value = d.value // { + meta = (d.value.meta or { }) // { + __defPos = pos; + }; + }; + } + else + d; # Normalize __contentValues wrappers (from aspectContentType) that # contain parametric functions. Without this, the wrapper merges # through aspectSubmodule and the function is buried as a freeform @@ -368,7 +397,7 @@ let meta.aspect-chain = lib.init d.value.__aspectChain; }; }; - defs' = map (d: if isContentWrapper d then wrapperToAspect d else d) defs; + defs' = map (d: if isContentWrapper d then wrapperToAspect d else d) (map stampDefPos defs); listDefs = builtins.filter (d: builtins.isList d.value) defs'; policyDefs = builtins.filter (d: builtins.isAttrs d.value && d.value.__isPolicy or false) defs'; in diff --git a/templates/ci/modules/features/deadbugs/shared-raw-include-splits.nix b/templates/ci/modules/features/deadbugs/shared-raw-include-splits.nix new file mode 100644 index 000000000..299771600 --- /dev/null +++ b/templates/ci/modules/features/deadbugs/shared-raw-include-splits.nix @@ -0,0 +1,124 @@ +# A let-bound aspect value included by two owners is one value, written once, +# referenced twice — not two authored aspects. The inline-chain fill (compile- +# static.nix) sees `meta.aspect-chain == null` at both inclusion sites with no +# way to tell "one value, two references" from "two separately-written +# literals", so it stamps each site with its own inclusion-site chain and the +# shared value splits into two nodes and double-emits. Because +# `environment.etc..text` is `types.lines`, the duplication reaches +# delivered content, not just a diagnostic identity string. +{ denTest, ... }: +{ + flake.tests.deadbugs.shared-raw-include-splits = { + + # The defect: one node, one emission — `text` is the cell that fails on + # delivered content rather than on a diagnostic string. A cell asserting + # only `count` would pass a fix that deduped the node while still + # emitting the content twice. + test-shared-raw-value-included-by-two-owners-is-one-node = denTest ( + { den, igloo, ... }: + let + shared = { + name = "sharedraw"; + nixos.environment.etc."sharedraw".text = "yes"; + }; + nodes = builtins.filter (n: n.name == "sharedraw") den.hosts.x86_64-linux.igloo.aspects; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.igloo.includes = [ + den.aspects.o1 + den.aspects.o2 + ]; + + den.aspects.o1.includes = [ shared ]; + den.aspects.o2.includes = [ shared ]; + + expr = { + count = builtins.length nodes; + identities = builtins.sort builtins.lessThan (map (n: n.identity) nodes); + text = igloo.environment.etc."sharedraw".text; + }; + expected = { + count = 1; + identities = [ "o1/sharedraw" ]; + text = "yes"; + }; + } + ); + + # Same defect, inclusion order reversed. The surviving identity string is + # order-dependent (whichever owner is walked first claims the chain) — + # asserted here is only the property that does NOT depend on walk order: + # reordering must not resurrect the double emission. The identity string + # itself is deliberately not asserted; freezing it would turn a future + # order-independent fix into an apparent regression. + test-shared-raw-value-reordered-owners-still-one-node = denTest ( + { den, igloo, ... }: + let + sharedRev = { + name = "sharedrev"; + nixos.environment.etc."sharedrev".text = "yes"; + }; + nodes = builtins.filter (n: n.name == "sharedrev") den.hosts.x86_64-linux.igloo.aspects; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.igloo.includes = [ + den.aspects.r2 + den.aspects.r1 + ]; + + den.aspects.r1.includes = [ sharedRev ]; + den.aspects.r2.includes = [ sharedRev ]; + + expr = { + count = builtins.length nodes; + text = igloo.environment.etc."sharedrev".text; + }; + expected = { + count = 1; + text = "yes"; + }; + } + ); + + # CONTROL: a declared aspect (chain already `[ ]`, never null) referenced + # by two owners must keep its one identity, unaffected by any of this. + # Already green before this fix; its job is to stay green — it falsifies + # a fix that stamps the inclusion site onto every node rather than only + # where the chain is absent. + test-control-declared-shared-aspect-keeps-one-identity = denTest ( + { den, igloo, ... }: + let + nodes = builtins.filter (n: n.name == "shared") den.hosts.x86_64-linux.igloo.aspects; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.igloo.includes = [ + den.aspects.o3a + den.aspects.o3b + ]; + + den.aspects.o3a.includes = [ den.aspects.shared ]; + den.aspects.o3b.includes = [ den.aspects.shared ]; + + den.aspects.shared.nixos.environment.etc."shared".text = "yes"; + + expr = { + count = builtins.length nodes; + identities = builtins.sort builtins.lessThan (map (n: n.identity) nodes); + text = igloo.environment.etc."shared".text; + }; + expected = { + count = 1; + identities = [ "shared" ]; + text = "yes"; + }; + } + ); + + }; +} From f955cb82b6f6cc41c5986171e57e356df25c2bbb Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 4 Sep 2026 19:46:44 -0700 Subject: [PATCH 15/59] Revert "fix: key a definition-position chain registry so a shared let-bound aspect claims one identity" --- .../aspects/fx/handlers/compile-static.nix | 29 ++++------------- nix/lib/aspects/types.nix | 31 +------------------ 2 files changed, 7 insertions(+), 53 deletions(-) diff --git a/nix/lib/aspects/fx/handlers/compile-static.nix b/nix/lib/aspects/fx/handlers/compile-static.nix index c67a38a17..5bf368d39 100644 --- a/nix/lib/aspects/fx/handlers/compile-static.nix +++ b/nix/lib/aspects/fx/handlers/compile-static.nix @@ -62,37 +62,20 @@ in # copies compound multiplicatively at every further level of # nesting. Testing the marker, not the name's shape, means an # author's own name (e.g. "gcc:14") can never be mistaken for one. - defPos = withoutParametricKeys.meta.__defPos or null; - chainRegistry = ((state.chainByDefPos or (_: { })) null); - claimedChain = if defPos == null then null else chainRegistry.${defPos} or null; - fillsChain = - (withoutParametricKeys.meta.aspect-chain or null) == null - && !(withoutParametricKeys.__walkStamped or false); - # A shared let-bound value reports the same __defPos at every inclusion - # site: the first node to fill here claims parentChainSegments for that - # position, and every later node at the same position reuses the - # claimed chain instead of filling its own — so both render one - # identity and gate dedup collapses them to one emission. A node with - # no __defPos (no author position, or the null the head-of-attrNames - # fallback would have destroyed — see types.nix) behaves exactly as - # the unregistered fill below. - filledChain = if claimedChain != null then claimedChain else parentChainSegments; aspect = - if fillsChain then + if + (withoutParametricKeys.meta.aspect-chain or null) == null + && !(withoutParametricKeys.__walkStamped or false) + then withoutParametricKeys // { meta = (withoutParametricKeys.meta or { }) // { - aspect-chain = filledChain; + aspect-chain = parentChainSegments; }; } else withoutParametricKeys; nodeIdentity = identity.key aspect; - nextState = - if fillsChain && defPos != null && claimedChain == null then - state // { chainByDefPos = _: chainRegistry // { ${defPos} = parentChainSegments; }; } - else - state; # Pushed onto the walk's chain for descendants — identity.aspectPath, # not ownChain ++ [name], so this equals the list identity.key itself # renders (chainWrap derives its string the same way), and every @@ -142,7 +125,7 @@ in ) ) ); - state = nextState; + inherit state; }; }; } diff --git a/nix/lib/aspects/types.nix b/nix/lib/aspects/types.nix index 0d5f851d8..f41f68bf0 100644 --- a/nix/lib/aspects/types.nix +++ b/nix/lib/aspects/types.nix @@ -318,35 +318,6 @@ let merge = loc: defs: let - # Definition position of the value as the author wrote it, read from - # the ORIGINAL defs: wrapperToAspect below rewrites `name`, and a - # position read after that points at types.nix rather than the - # author's file. Two inclusion sites of one let-bound value report - # ONE position; two separately-written inline literals report two. - defPosOf = - v: - let - p = if v ? name then builtins.unsafeGetAttrPos "name" v else null; - in - if p == null then null else "${toString p.file}:${toString p.line}:${toString p.column}"; - stampDefPos = - d: - let - pos = defPosOf d.value; - in - if - builtins.isAttrs d.value && !(d.value.__isPolicy or false) && !(d.value ? __fn) && pos != null - then - d - // { - value = d.value // { - meta = (d.value.meta or { }) // { - __defPos = pos; - }; - }; - } - else - d; # Normalize __contentValues wrappers (from aspectContentType) that # contain parametric functions. Without this, the wrapper merges # through aspectSubmodule and the function is buried as a freeform @@ -397,7 +368,7 @@ let meta.aspect-chain = lib.init d.value.__aspectChain; }; }; - defs' = map (d: if isContentWrapper d then wrapperToAspect d else d) (map stampDefPos defs); + defs' = map (d: if isContentWrapper d then wrapperToAspect d else d) defs; listDefs = builtins.filter (d: builtins.isList d.value) defs'; policyDefs = builtins.filter (d: builtins.isAttrs d.value && d.value.__isPolicy or false) defs'; in From 5db69fd44ac73fd0f0b2935280590a48a85cceb7 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 4 Sep 2026 22:56:56 -0700 Subject: [PATCH 16/59] fix: key aspect identity by raw definition-value equality, not position alone Stamp meta.__defPos/__defValue at providerType.merge (the one point a definition is still raw) and register the first chain claimed per def-position in pipeline state. A second sighting at the same position reuses that chain only when its raw value is `==` the first's; a factory or `base // {...}` specialization producing a different value at the same token position claims its own chain instead of silently colliding with the first (#670 for that input class, the defect the reverted 38e1f725 mechanism reintroduced for a different class). Reverts 38e1f725 (its own commit, restoring shared-raw-include-splits.nix which the plain revert would have deleted) and replaces it with this sixth, value-equality-keyed mechanism. --- .../aspects/fx/handlers/compile-static.nix | 56 +++++- nix/lib/aspects/fx/pipeline.nix | 4 + nix/lib/aspects/types.nix | 37 +++- .../fixtures/aspect-equality-author-cycle.nix | 131 +++++++++++++ .../deadbugs/shared-raw-include-splits.nix | 177 ++++++++++++++++++ 5 files changed, 398 insertions(+), 7 deletions(-) create mode 100644 templates/ci/fixtures/aspect-equality-author-cycle.nix diff --git a/nix/lib/aspects/fx/handlers/compile-static.nix b/nix/lib/aspects/fx/handlers/compile-static.nix index 5bf368d39..d2b59b916 100644 --- a/nix/lib/aspects/fx/handlers/compile-static.nix +++ b/nix/lib/aspects/fx/handlers/compile-static.nix @@ -62,20 +62,64 @@ in # copies compound multiplicatively at every further level of # nesting. Testing the marker, not the name's shape, means an # author's own name (e.g. "gcc:14") can never be mistaken for one. - aspect = - if - (withoutParametricKeys.meta.aspect-chain or null) == null - && !(withoutParametricKeys.__walkStamped or false) + defPos = withoutParametricKeys.meta.__defPos or null; + defValue = withoutParametricKeys.meta.__defValue or null; + chainRegistry = ((state.chainByDefPos or (_: { })) null); + claimedEntry = if defPos == null then null else chainRegistry.${defPos} or null; + # A position identifies a token, so it over-merges: a factory called + # twice and `base // { ... }` specialised twice are two distinct + # aspects at one position. Reuse the claimed chain only when the raw + # authored value is the same value. One value included twice is + # pointer-identical, which `==` settles without descending; two + # distinct values stop at their first differing attribute. `meta` is + # dropped from the comparison because it carries this stamp itself. + claimedChain = + if claimedEntry == null then + null + else if + defValue != null + && claimedEntry.value != null + && builtins.removeAttrs defValue [ "meta" ] == builtins.removeAttrs claimedEntry.value [ "meta" ] then + claimedEntry.chain + else + null; + fillsChain = + (withoutParametricKeys.meta.aspect-chain or null) == null + && !(withoutParametricKeys.__walkStamped or false); + # A shared let-bound value reports the same __defPos at every inclusion + # site: the first node to fill here claims parentChainSegments for that + # position, and every later node at the same position reuses the + # claimed chain instead of filling its own — so both render one + # identity and gate dedup collapses them to one emission. A node with + # no __defPos (no author position, or the null the head-of-attrNames + # fallback would have destroyed — see types.nix) behaves exactly as + # the unregistered fill below. + filledChain = if claimedChain != null then claimedChain else parentChainSegments; + aspect = + if fillsChain then withoutParametricKeys // { meta = (withoutParametricKeys.meta or { }) // { - aspect-chain = parentChainSegments; + aspect-chain = filledChain; }; } else withoutParametricKeys; nodeIdentity = identity.key aspect; + nextState = + if fillsChain && defPos != null && claimedEntry == null then + let + updated = chainRegistry // { + ${defPos} = { + chain = parentChainSegments; + value = defValue; + }; + }; + in + state // { chainByDefPos = _: updated; } + else + state; # Pushed onto the walk's chain for descendants — identity.aspectPath, # not ownChain ++ [name], so this equals the list identity.key itself # renders (chainWrap derives its string the same way), and every @@ -125,7 +169,7 @@ in ) ) ); - inherit state; + state = nextState; }; }; } diff --git a/nix/lib/aspects/fx/pipeline.nix b/nix/lib/aspects/fx/pipeline.nix index fdee57b97..f4006974c 100644 --- a/nix/lib/aspects/fx/pipeline.nix +++ b/nix/lib/aspects/fx/pipeline.nix @@ -155,6 +155,10 @@ let pathSetByScope = _: { }; # Full resolved nodes keyed by unique identity, for entity.aspects. resolvedNodes = _: { }; + # Definition-position → { chain; value; } claims for the inline-chain fill + # (compile-static.nix). Flat by design: a shared value's identity must not + # depend on which scope happened to walk it first. + chainByDefPos = _: { }; # --- Scope-partitioned output state (handlers write here) --- scopedClassImports = _: { }; diff --git a/nix/lib/aspects/types.nix b/nix/lib/aspects/types.nix index f41f68bf0..5822804cc 100644 --- a/nix/lib/aspects/types.nix +++ b/nix/lib/aspects/types.nix @@ -322,6 +322,41 @@ let # contain parametric functions. Without this, the wrapper merges # through aspectSubmodule and the function is buried as a freeform # key. Extract the function so existing dispatch handles it. + # Definition position of the value as the author wrote it, read from + # the ORIGINAL defs: wrapperToAspect below rewrites `name`, and a + # position read after that points at types.nix rather than the + # author's file. Two inclusion sites of one let-bound value report + # ONE position; two separately-written inline literals report two. + defPosOf = + v: + let + p = if v ? name then builtins.unsafeGetAttrPos "name" v else null; + in + if p == null then null else "${toString p.file}:${toString p.line}:${toString p.column}"; + stampDefPos = + d: + let + pos = defPosOf d.value; + in + if + builtins.isAttrs d.value && !(d.value.__isPolicy or false) && !(d.value ? __fn) && pos != null + then + d + // { + value = d.value // { + meta = (d.value.meta or { }) // { + __defPos = pos; + # The value exactly as the author wrote it, captured before + # wrapperToAspect rewrites anything. A position is a token, + # not a value: one position carries as many distinct values + # as a factory is called times. This is what lets the + # registry tell those apart from one value seen twice. + __defValue = d.value; + }; + }; + } + else + d; isContentWrapper = d: builtins.isAttrs d.value @@ -368,7 +403,7 @@ let meta.aspect-chain = lib.init d.value.__aspectChain; }; }; - defs' = map (d: if isContentWrapper d then wrapperToAspect d else d) defs; + defs' = map (d: if isContentWrapper d then wrapperToAspect d else d) (map stampDefPos defs); listDefs = builtins.filter (d: builtins.isList d.value) defs'; policyDefs = builtins.filter (d: builtins.isAttrs d.value && d.value.__isPolicy or false) defs'; in diff --git a/templates/ci/fixtures/aspect-equality-author-cycle.nix b/templates/ci/fixtures/aspect-equality-author-cycle.nix new file mode 100644 index 000000000..c64bfec26 --- /dev/null +++ b/templates/ci/fixtures/aspect-equality-author-cycle.nix @@ -0,0 +1,131 @@ +# ACCEPTED COST of the value-equality identity guard (compile-static.nix). +# +# Two DISTINCT raw aspect values at one `name =` position, where one is +# self-referential at a key interned BEFORE the first differing key, make the +# guard's `==` diverge: `error: stack overflow; max-call-depth exceeded`. Nix +# compares attributes in symbol-interning (parse) order, not alphabetical +# order, so no short-circuit argument closes this. `builtins.tryEval` does not +# catch it — the divergence itself overflows under tryEval too. +# +# Accepted because it fails LOUD, never silent: the alternative (a depth- +# bounded equality) forfeits the pointer-identity fast path and re-opens the +# silent drop this guard exists to close. +# +# The two cells are written out longhand rather than through a shared helper: +# the hole depends on the order two keys are PARSED in, so factoring them +# together silently changes what is measured (both arms overflowed the one +# time this was tried). +# +# NOT collected under `templates/ci/modules/` — measured (this session, this +# tree): `nix develop -c just ci aspect-equality-author-cycle` produced NO +# summary line (no 🎉/😢/💥) on either stream and EXIT=1: the overflowing +# nix-eval-jobs worker crashes the whole collected run rather than surfacing +# as an ordinary ❌ row, indistinguishable from a crashed CI. Collecting this +# fixture would destroy the gate every other oracle in this suite depends on. +# `import-tree ./modules` (templates/ci/flake.nix) only walks +# `templates/ci/modules`, so this file living under `templates/ci/fixtures/` +# instead (the same precedent as `templates/ci/non-dendritic/`) is never +# collected. +# +# ORACLE — both arms, one run. If they agree, the fixture has measured +# nothing: +# +# cp templates/ci/fixtures/aspect-equality-author-cycle.nix \ +# templates/ci/modules/features/deadbugs/ +# git add templates/ci/modules/features/deadbugs/aspect-equality-author-cycle.nix +# +# nix eval --override-input den . \ +# './templates/ci#tests.deadbugs.aspect-equality-author-cycle.test-control-difference-before-cycle.expr' +# => { common = true; count = 2; } EXIT=0 +# +# nix eval --override-input den . \ +# './templates/ci#tests.deadbugs.aspect-equality-author-cycle.test-cycle-before-difference.expr' +# => error: stack overflow; max-call-depth exceeded EXIT=1 +# +# git rm --cached templates/ci/modules/features/deadbugs/aspect-equality-author-cycle.nix +# rm templates/ci/modules/features/deadbugs/aspect-equality-author-cycle.nix +{ denTest, ... }: +{ + flake.tests.deadbugs.aspect-equality-author-cycle = { + + # THE HOLE: `cyc` is written before `dif`, so it is interned first and + # compared first — everything before it is equal, so `==` descends into + # the cycle before it ever reaches the differing key. + test-cycle-before-difference = denTest ( + { den, igloo, ... }: + let + mkTool = + tag: + let + v = { + name = "tools"; + nixos.environment.etc."common".text = "yes"; + cyc.loop = v; + dif = tag; + }; + in + v; + nodes = builtins.filter (n: n.name == "tools") den.hosts.x86_64-linux.igloo.aspects; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.aspects.igloo.includes = [ + den.aspects.alpha + den.aspects.beta + ]; + den.aspects.alpha.includes = [ (mkTool "a") ]; + den.aspects.beta.includes = [ (mkTool "b") ]; + + expr = { + count = builtins.length nodes; + common = igloo.environment.etc ? "common"; + }; + expected = { + count = 2; + common = true; + }; + } + ); + + # LIVE CONTROL: identical construction, the two keys swapped so the + # differing key is parsed and compared first — must return a value, never + # an error. Its presence in the same run is what makes the hole a finding + # rather than a broken instrument. + test-control-difference-before-cycle = denTest ( + { den, igloo, ... }: + let + mkTool = + tag: + let + v = { + name = "tools"; + nixos.environment.etc."common".text = "yes"; + adif = tag; + bcyc.loop = v; + }; + in + v; + nodes = builtins.filter (n: n.name == "tools") den.hosts.x86_64-linux.igloo.aspects; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.aspects.igloo.includes = [ + den.aspects.alpha + den.aspects.beta + ]; + den.aspects.alpha.includes = [ (mkTool "a") ]; + den.aspects.beta.includes = [ (mkTool "b") ]; + + expr = { + count = builtins.length nodes; + common = igloo.environment.etc ? "common"; + }; + expected = { + count = 2; + common = true; + }; + } + ); + + }; +} diff --git a/templates/ci/modules/features/deadbugs/shared-raw-include-splits.nix b/templates/ci/modules/features/deadbugs/shared-raw-include-splits.nix index 299771600..bf45c4926 100644 --- a/templates/ci/modules/features/deadbugs/shared-raw-include-splits.nix +++ b/templates/ci/modules/features/deadbugs/shared-raw-include-splits.nix @@ -120,5 +120,182 @@ } ); + # O2: a factory called twice reports ONE __defPos (the call site inside + # the factory body never changes) but TWO distinct raw values — the + # rejected `38e1f725` mechanism keyed the registry on that position alone + # and merged them, silently dropping beta's delivery. The value-equality + # guard must see the two calls differ and let both through. + test-factory-samename-two-owners = denTest ( + { den, igloo, ... }: + let + mkTool = tag: { + name = "tools"; + nixos.environment.etc.${tag}.text = "yes"; + }; + toolsNodes = builtins.filter (n: n.name == "tools") den.hosts.x86_64-linux.igloo.aspects; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.igloo.includes = [ + den.aspects.alpha + den.aspects.beta + ]; + + den.aspects.alpha.includes = [ (mkTool "alphafile") ]; + den.aspects.beta.includes = [ (mkTool "betafile") ]; + + expr = { + alpha = igloo.environment.etc ? "alphafile"; + beta = igloo.environment.etc ? "betafile"; + count = builtins.length toolsNodes; + identities = builtins.sort builtins.lessThan (map (n: n.identity) toolsNodes); + }; + expected = { + alpha = true; + beta = true; + count = 2; + identities = [ + "alpha/tools" + "beta/tools" + ]; + }; + } + ); + + # O3: same class of defect as O2, reached via `base // { ... }` instead of + # a factory call — each specialisation is a distinct raw value at one + # position. + test-overlay-samename-two-owners = denTest ( + { den, igloo, ... }: + let + base = { + name = "tools"; + nixos.environment.etc."common".text = "yes"; + }; + toolsNodes = builtins.filter (n: n.name == "tools") den.hosts.x86_64-linux.igloo.aspects; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.igloo.includes = [ + den.aspects.alpha + den.aspects.beta + ]; + + den.aspects.alpha.includes = [ + (base // { nixos.environment.etc."alphaonly".text = "yes"; }) + ]; + den.aspects.beta.includes = [ + (base // { nixos.environment.etc."betaonly".text = "yes"; }) + ]; + + expr = { + alpha = igloo.environment.etc ? "alphaonly"; + beta = igloo.environment.etc ? "betaonly"; + count = builtins.length toolsNodes; + identities = builtins.sort builtins.lessThan (map (n: n.identity) toolsNodes); + }; + expected = { + alpha = true; + beta = true; + count = 2; + identities = [ + "alpha/tools" + "beta/tools" + ]; + }; + } + ); + + # O5: two BYTE-IDENTICAL inline literals at two source positions split + # into two nodes, and that is accepted — they never collide in the + # registry because they occupy two positions, so the equality guard is + # never consulted. Asserted so a future author does not read this split + # as a regression. + test-byte-identical-literals = denTest ( + { den, igloo, ... }: + let + twinNodes = builtins.filter (n: n.name == "twin") den.hosts.x86_64-linux.igloo.aspects; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.igloo.includes = [ + den.aspects.alpha + den.aspects.beta + ]; + + den.aspects.alpha.includes = [ + { + name = "twin"; + nixos.environment.etc."twin".text = "yes"; + } + ]; + den.aspects.beta.includes = [ + { + name = "twin"; + nixos.environment.etc."twin".text = "yes"; + } + ]; + + expr = { + count = builtins.length twinNodes; + identities = builtins.sort builtins.lessThan (map (n: n.identity) twinNodes); + text = igloo.environment.etc."twin".text; + }; + expected = { + count = 2; + identities = [ + "alpha/twin" + "beta/twin" + ]; + text = "yes\nyes"; + }; + } + ); + + # O6: a shared value that is itself cyclic. Two inclusion sites of one + # let-bound cyclic value must still collapse to one node — proving the + # equality guard's raw-value comparison takes the pointer-identical O(1) + # path rather than descending into the cycle (a distinct cyclic value + # would have to descend; see deadbugs/aspect-equality-author-cycle for + # that accepted cost). + test-shared-cyclic-value-across-two-owners-is-one-node = denTest ( + { den, igloo, ... }: + let + shared = + let + v = { + name = "loopy"; + carrier.loop = v; + nixos.environment.etc."loopy".text = "yes"; + }; + in + v; + nodes = builtins.filter (n: n.name == "loopy") den.hosts.x86_64-linux.igloo.aspects; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.igloo.includes = [ + den.aspects.o1 + den.aspects.o2 + ]; + + den.aspects.o1.includes = [ shared ]; + den.aspects.o2.includes = [ shared ]; + + expr = { + count = builtins.length nodes; + text = igloo.environment.etc."loopy".text; + }; + expected = { + count = 1; + text = "yes"; + }; + } + ); + }; } From 8e2c133eba73be228ffa56490ee7c30103765095 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 4 Sep 2026 23:27:31 -0700 Subject: [PATCH 17/59] fix: compare the whole raw value and keep a list of claims per def-position Two fix-round-1 findings on the value-equality guard (b049398e): - The claim comparison projected `meta` off both sides before `==`. Any two raw values differing only in `meta` therefore compared equal, merged, and dropped one owner's node -- the exact silent-drop class this task exists to remove, and meta is not comparison-inert (metaType's handleWith/ collisionPolicy are real fields). `__defValue` is captured pre-stamp, so there was nothing of the guard's own bookkeeping for the projection to strip. Fixed by comparing the values whole. - `chainByDefPos.` held a single claim, registered only once. A differing sibling from the same factory walked first permanently occupies the slot, so a genuinely shared value's own two sightings are compared against the wrong claimant forever and never find each other -- the mechanism becomes a silent no-op for that walk order. Fixed by keeping a list of `{ chain; value; }` claims per position and matching against whichever one equals. Adds three cells to shared-raw-include-splits.nix covering both findings (the sibling-then-shared case in both walk orders). Both-baseline and full suite re-verified unchanged (d61e1f45 4/7, 38e1f725 5/7 on the original set); just ci 1140/1140, just ci performance 29/29. --- .../aspects/fx/handlers/compile-static.nix | 40 +++--- nix/lib/aspects/fx/pipeline.nix | 10 +- .../deadbugs/shared-raw-include-splits.nix | 123 ++++++++++++++++++ 3 files changed, 149 insertions(+), 24 deletions(-) diff --git a/nix/lib/aspects/fx/handlers/compile-static.nix b/nix/lib/aspects/fx/handlers/compile-static.nix index d2b59b916..15f77010b 100644 --- a/nix/lib/aspects/fx/handlers/compile-static.nix +++ b/nix/lib/aspects/fx/handlers/compile-static.nix @@ -65,25 +65,21 @@ in defPos = withoutParametricKeys.meta.__defPos or null; defValue = withoutParametricKeys.meta.__defValue or null; chainRegistry = ((state.chainByDefPos or (_: { })) null); - claimedEntry = if defPos == null then null else chainRegistry.${defPos} or null; # A position identifies a token, so it over-merges: a factory called # twice and `base // { ... }` specialised twice are two distinct - # aspects at one position. Reuse the claimed chain only when the raw - # authored value is the same value. One value included twice is + # aspects at one position, and either can hold several distinct raw + # values over the run — so each position keeps a list of claims, not + # one. Reuse a claimed chain only when the raw authored value (the + # whole value, `meta` included: `__defValue` is captured pre-stamp, so + # there is nothing of the guard's own bookkeeping to strip) is the + # same value as that claim's. One value included twice is # pointer-identical, which `==` settles without descending; two - # distinct values stop at their first differing attribute. `meta` is - # dropped from the comparison because it carries this stamp itself. - claimedChain = - if claimedEntry == null then - null - else if - defValue != null - && claimedEntry.value != null - && builtins.removeAttrs defValue [ "meta" ] == builtins.removeAttrs claimedEntry.value [ "meta" ] - then - claimedEntry.chain - else - null; + # distinct values stop at their first differing attribute. + claimedEntries = if defPos == null then [ ] else chainRegistry.${defPos} or [ ]; + matchingClaim = lib.findFirst ( + e: defValue != null && e.value != null && defValue == e.value + ) null claimedEntries; + claimedChain = if matchingClaim == null then null else matchingClaim.chain; fillsChain = (withoutParametricKeys.meta.aspect-chain or null) == null && !(withoutParametricKeys.__walkStamped or false); @@ -108,13 +104,15 @@ in withoutParametricKeys; nodeIdentity = identity.key aspect; nextState = - if fillsChain && defPos != null && claimedEntry == null then + if fillsChain && defPos != null && matchingClaim == null then let updated = chainRegistry // { - ${defPos} = { - chain = parentChainSegments; - value = defValue; - }; + ${defPos} = claimedEntries ++ [ + { + chain = parentChainSegments; + value = defValue; + } + ]; }; in state // { chainByDefPos = _: updated; } diff --git a/nix/lib/aspects/fx/pipeline.nix b/nix/lib/aspects/fx/pipeline.nix index f4006974c..ecb3ac233 100644 --- a/nix/lib/aspects/fx/pipeline.nix +++ b/nix/lib/aspects/fx/pipeline.nix @@ -155,9 +155,13 @@ let pathSetByScope = _: { }; # Full resolved nodes keyed by unique identity, for entity.aspects. resolvedNodes = _: { }; - # Definition-position → { chain; value; } claims for the inline-chain fill - # (compile-static.nix). Flat by design: a shared value's identity must not - # depend on which scope happened to walk it first. + # Definition-position → list of { chain; value; } claims for the + # inline-chain fill (compile-static.nix). A list, not a single entry: one + # position can hold several distinct raw values (a factory called twice, + # or `base // { ... }` specialised twice), and each distinct value needs + # its own claim rather than losing to whichever sighting arrived first. + # Flat by design: a shared value's identity must not depend on which scope + # happened to walk it first. chainByDefPos = _: { }; # --- Scope-partitioned output state (handlers write here) --- diff --git a/templates/ci/modules/features/deadbugs/shared-raw-include-splits.nix b/templates/ci/modules/features/deadbugs/shared-raw-include-splits.nix index bf45c4926..99057b90d 100644 --- a/templates/ci/modules/features/deadbugs/shared-raw-include-splits.nix +++ b/templates/ci/modules/features/deadbugs/shared-raw-include-splits.nix @@ -297,5 +297,128 @@ } ); + # C1 (fix round 1): a factory call varying ONLY `meta` must not merge with + # its sibling — the guard's comparison must cover the whole raw value. + # `meta` is not comparison-inert: metaType declares real fields + # (handleWith, collisionPolicy), and __defValue is captured BEFORE this + # guard's own stamp, so there is nothing of the guard's bookkeeping to + # strip. A projection that drops `meta` from the comparison reads this as + # one value, silently drops one owner's node, and reads identically to + # the rejected 38e1f725 on this input. + test-p2-meta-only-difference-does-not-merge = denTest ( + { den, igloo, ... }: + let + mkProbe = tag: { + name = "probed"; + meta.probe = tag; + nixos.environment.etc."common".text = "yes"; + }; + probedNodes = builtins.filter (n: n.name == "probed") den.hosts.x86_64-linux.igloo.aspects; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.igloo.includes = [ + den.aspects.alpha + den.aspects.beta + ]; + + den.aspects.alpha.includes = [ (mkProbe "a") ]; + den.aspects.beta.includes = [ (mkProbe "b") ]; + + expr = { + count = builtins.length probedNodes; + probes = builtins.sort builtins.lessThan (map (n: n.meta.probe) probedNodes); + text = igloo.environment.etc."common".text; + }; + expected = { + count = 2; + probes = [ + "a" + "b" + ]; + text = "yes\nyes"; + }; + } + ); + + # C2 (fix round 1): a def-position registry that keeps a single claim lets + # a differing sibling (gamma, its own factory call) occupy the position + # first and permanently block a genuinely shared value (two owners of the + # same `mkTool "sh"` pointer) from ever registering its own claim — so the + # shared value's two sightings never find each other and the mechanism is + # a no-op for this walk order (identical to the pre-mechanism reading). + test-p1-sibling-claims-position-then-shared-value-splits = denTest ( + { den, igloo, ... }: + let + mkTool = tag: { + name = "tools"; + nixos.environment.etc.${tag}.text = "yes"; + }; + shared = mkTool "sh"; + toolsNodes = builtins.filter (n: n.name == "tools") den.hosts.x86_64-linux.igloo.aspects; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.igloo.includes = [ + den.aspects.gamma + den.aspects.alpha + den.aspects.beta + ]; + + den.aspects.gamma.includes = [ (mkTool "gammafile") ]; + den.aspects.alpha.includes = [ shared ]; + den.aspects.beta.includes = [ shared ]; + + expr = { + count = builtins.length toolsNodes; + shText = igloo.environment.etc."sh".text; + }; + expected = { + count = 2; + shText = "yes"; + }; + } + ); + + # Same probe, shared walked before the differing sibling: shared claims + # the position's only slot first, so it merges with itself correctly — + # this ordering happens to work even under the single-claim bug, which is + # exactly why the bug needs the reordered cell above to be caught at all. + test-p1b-shared-value-walked-first-merges = denTest ( + { den, igloo, ... }: + let + mkTool = tag: { + name = "tools"; + nixos.environment.etc.${tag}.text = "yes"; + }; + shared = mkTool "sh"; + toolsNodes = builtins.filter (n: n.name == "tools") den.hosts.x86_64-linux.igloo.aspects; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.igloo.includes = [ + den.aspects.alpha + den.aspects.beta + den.aspects.gamma + ]; + + den.aspects.alpha.includes = [ shared ]; + den.aspects.beta.includes = [ shared ]; + den.aspects.gamma.includes = [ (mkTool "gammafile") ]; + + expr = { + count = builtins.length toolsNodes; + shText = igloo.environment.etc."sh".text; + }; + expected = { + count = 2; + shText = "yes"; + }; + } + ); + }; } From 430e16c9b9d48b6266515d0c0277e2fffcd7e2a9 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 4 Sep 2026 23:36:33 -0700 Subject: [PATCH 18/59] docs: record the claim-list guard's true cost, superlinear in distinct values The C2 fix (a560f3b4) turned a single claim per definition-position into a list, and findFirst walks the whole list on every arrival -- K(K-1)/2 in the count of distinct raw values sharing one position, not the linear bound recorded before the list existed. --- nix/lib/aspects/fx/handlers/compile-static.nix | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/nix/lib/aspects/fx/handlers/compile-static.nix b/nix/lib/aspects/fx/handlers/compile-static.nix index 15f77010b..6fe4ce8fb 100644 --- a/nix/lib/aspects/fx/handlers/compile-static.nix +++ b/nix/lib/aspects/fx/handlers/compile-static.nix @@ -76,6 +76,13 @@ in # pointer-identical, which `==` settles without descending; two # distinct values stop at their first differing attribute. claimedEntries = if defPos == null then [ ] else chainRegistry.${defPos} or [ ]; + # K(K-1)/2 in K, the count of distinct raw values sharing one + # position: the Nth arrival walks up to N-1 earlier claims before + # adding its own. Bounded in practice because the registry is + # per-run, so K only grows where one factory body is called many + # times inside a single entity's resolve. (A prior figure recording + # this as linear in K was measured against the single-claim form, + # before distinct values got a claim each.) matchingClaim = lib.findFirst ( e: defValue != null && e.value != null && defValue == e.value ) null claimedEntries; From 72590a5a551299d190c2f0d0356a6d99dd9c67a7 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Sat, 5 Sep 2026 00:36:16 -0700 Subject: [PATCH 19/59] fix: split colliding same-named policy identities per scope Two policy records sharing a bare name (two mkPolicy "tools" owned by different aspects, or two aspects each declaring their own policies.tools) silently overwrote each other in scopedAspectPolicies, whose registry key was always the bare name with no way to tell them apart. registerPolicy now claims a name within the current scope on first sight and keeps it bare; a second, distinct raw value claiming that same name in the same scope gets displaced to a chain-qualified identity instead of overwriting the first. A shared den.policies.foo referenced twice compares == to its own earlier claim and reuses its identity unchanged, so it still fires once. Bucketing is by (scope, name), not definition position: traced empirically, two aspects' own same-named policies. merge at different option paths (each submodule eval bakes its own aspect name into loc), so a def-position bucket would put them in separate buckets and miss the very collision this exists to catch, even though both land in one scope's scopedAspectPolicies. --- nix/lib/aspects/fx/aspect/children.nix | 77 +++++++++- nix/lib/aspects/fx/pipeline.nix | 11 ++ .../deadbugs/policy-record-provenance.nix | 144 ++++++++++++++++++ 3 files changed, 227 insertions(+), 5 deletions(-) create mode 100644 templates/ci/modules/features/deadbugs/policy-record-provenance.nix diff --git a/nix/lib/aspects/fx/aspect/children.nix b/nix/lib/aspects/fx/aspect/children.nix index 26b28f6aa..4f85a8b5d 100644 --- a/nix/lib/aspects/fx/aspect/children.nix +++ b/nix/lib/aspects/fx/aspect/children.nix @@ -55,13 +55,80 @@ let ctx = { }; }; - # Route a single __isPolicy value to the policy registry. + # Route a single __isPolicy value to the policy registry. A policy's bare + # `name` is the identity every other consumer already relies on (excludes, + # cross-scope fired-tracking, broadcast dedup) — changing it unconditionally + # would fix the collision below at the cost of every one of those. So the + # bare name stays the identity in the overwhelming common case (one + # registration per name per scope), and only a genuine collision — a + # second, DIFFERENT record claiming a name already taken IN THIS SCOPE — + # gets displaced to a chain-qualified identity instead of silently + # overwriting the first (scopedAspectPolicies merges by overwrite, one dict + # per scope). + # + # Claims are bucketed by bare name and scoped by state.currentScope, not by + # definition position (Task 4c's registry for aspects): traced empirically, + # two aspects each declaring their own "policies.tools" merge at DIFFERENT + # option paths — each aspect's own submodule eval bakes its own name into + # `loc` — so a def-position bucket puts them in separate buckets and misses + # the collision entirely, even though both still land in the SAME scope's + # scopedAspectPolicies and one overwrites the other. Whole-value equality + # within one scope is what actually tells "one shared policy referenced + # twice" (O5, must merge into one identity) apart from "two distinct + # same-named policies" (O4, must split); two different scopes never + # collide in scopedAspectPolicies to begin with, so entries from another + # scope are excluded from the comparison rather than forcing a needless + # qualification. registerPolicy = p: - fx.send "register-aspect-policy" { - inherit (p) fn; - ownerIdentity = identity.key p; - }; + fx.bind fx.effects.state.get ( + state: + let + scope = state.currentScope; + bucketKey = "name:${p.name}"; + claimRegistry = (state.policyClaimsByName or (_: { })) null; + claimedEntries = claimRegistry.${bucketKey} or [ ]; + sameScopeEntries = builtins.filter (e: e.scope == scope) claimedEntries; + # Whole-record comparison, nothing projected out: a record differing + # only in, say, an attached label must not be read as the same + # registration as one that lacks it. + matchingClaim = lib.findFirst (e: p == e.value) null sameScopeEntries; + parentStack = ((state.scopedIncludesChainSegments or (_: { })) null).${scope} or [ ]; + parentChainSegments = if parentStack == [ ] then [ ] else lib.last parentStack; + ownerIdentity = + if matchingClaim != null then + matchingClaim.identity + else if sameScopeEntries == [ ] then + p.name + else + identity.pathKey (parentChainSegments ++ [ p.name ]); + registerEffect = fx.send "register-aspect-policy" { + inherit (p) fn; + inherit ownerIdentity; + }; + in + if matchingClaim != null then + registerEffect + else + fx.bind (fx.effects.state.modify ( + st: + st + // { + policyClaimsByName = + _: + claimRegistry + // { + ${bucketKey} = claimedEntries ++ [ + { + value = p; + identity = ownerIdentity; + inherit scope; + } + ]; + }; + } + )) (_: registerEffect) + ); isPolicy = v: builtins.isAttrs v && v.__isPolicy or false; diff --git a/nix/lib/aspects/fx/pipeline.nix b/nix/lib/aspects/fx/pipeline.nix index ecb3ac233..851b954ca 100644 --- a/nix/lib/aspects/fx/pipeline.nix +++ b/nix/lib/aspects/fx/pipeline.nix @@ -163,6 +163,17 @@ let # Flat by design: a shared value's identity must not depend on which scope # happened to walk it first. chainByDefPos = _: { }; + # Policy identity claims: bare name → list of { scope; value; identity; } + # claims, consulted by children.nix's registerPolicy to tell a shared + # den.policies. (one registration, included twice into the same + # scope) apart from two distinct same-named policies registering into + # that same scope (which must each keep their own, chain-qualified, + # identity). Bucketed by name rather than definition position: a + # position-keyed bucket puts two aspects' own same-named + # `.policies.` in separate buckets (each merges at a different + # option path) even though both still land in one scope's + # scopedAspectPolicies, which is exactly where they'd collide. + policyClaimsByName = _: { }; # --- Scope-partitioned output state (handlers write here) --- scopedClassImports = _: { }; diff --git a/templates/ci/modules/features/deadbugs/policy-record-provenance.nix b/templates/ci/modules/features/deadbugs/policy-record-provenance.nix new file mode 100644 index 000000000..6cebaa54d --- /dev/null +++ b/templates/ci/modules/features/deadbugs/policy-record-provenance.nix @@ -0,0 +1,144 @@ +# Policy records carry no provenance today: `identity.key` reads a policy's +# bare `name` (it has no `meta.aspect-chain` to build on), so two `mkPolicy +# "tools"` records owned by different aspects both compute the identity +# "tools" and collide in scopedAspectPolicies — one owner's registration +# silently overwrites the other's (O4). +# +# The tempting wrong fix is to key a policy's identity off its inclusion +# site unconditionally. That passes O4 but fails its control: a single +# `den.policies.foo` referenced from two aspects is ONE registration, not +# two, and must still fire once (O5). +{ denTest, ... }: +{ + flake.tests.deadbugs.policy-record-provenance = { + + # O4: two inline mkPolicy records sharing the bare name "tools", each + # owned by a different aspect, must both deliver. Today only one + # survives — whichever owner's registration is walked last. + test-o4-two-inline-mkpolicy-same-name-both-fire = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.igloo.includes = [ + den.aspects.alpha + den.aspects.beta + ]; + + den.aspects.alpha.includes = [ + (den.lib.policy.mkPolicy "tools" ( + { host, ... }: + [ + (den.lib.policy.provide { + class = host.class; + module.environment.etc."alpha-tools".text = "yes"; + }) + ] + )) + ]; + + den.aspects.beta.includes = [ + (den.lib.policy.mkPolicy "tools" ( + { host, ... }: + [ + (den.lib.policy.provide { + class = host.class; + module.environment.etc."beta-tools".text = "yes"; + }) + ] + )) + ]; + + expr = { + alpha = igloo.environment.etc ? "alpha-tools"; + beta = igloo.environment.etc ? "beta-tools"; + }; + expected = { + alpha = true; + beta = true; + }; + } + ); + + # O4 variant: the same collision reached through an aspect's own nested + # `.policies.` registry instead of an inline mkPolicy literal. Two + # different aspects each declare their OWN "tools" policy (different + # bodies) — a registry-key-only fix (bucketing purely by the module + # system's `loc`, which is submodule-local and identical for both + # aspects' "policies.tools" option) must still tell them apart by raw + # value, not by the bucket key alone. + test-o4-two-aspect-own-policies-same-name-both-fire = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.igloo.includes = [ + den.aspects.gamma + den.aspects.delta + ]; + + den.aspects.gamma.policies.tools = + { host, ... }: + [ + (den.lib.policy.provide { + class = host.class; + module.environment.etc."gamma-tools".text = "yes"; + }) + ]; + den.aspects.gamma.includes = [ den.aspects.gamma.policies.tools ]; + + den.aspects.delta.policies.tools = + { host, ... }: + [ + (den.lib.policy.provide { + class = host.class; + module.environment.etc."delta-tools".text = "yes"; + }) + ]; + den.aspects.delta.includes = [ den.aspects.delta.policies.tools ]; + + expr = { + gamma = igloo.environment.etc ? "gamma-tools"; + delta = igloo.environment.etc ? "delta-tools"; + }; + expected = { + gamma = true; + delta = true; + }; + } + ); + + # O5 (O4's control): a SHARED `den.policies.foo`, included from two + # different aspects, is one registration referenced twice — not two + # authored policies. A fix that splits identity by inclusion site + # unconditionally makes it fire twice; `environment.etc` is `types.lines` + # so a double fire is visible as "yes\nyes" rather than "yes". + test-o5-shared-registry-policy-fires-once = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.policies.shared-tool = + { host, ... }: + [ + (den.lib.policy.provide { + class = host.class; + module.environment.etc.shared-tool.text = "yes"; + }) + ]; + + den.aspects.igloo.includes = [ + den.aspects.epsilon + den.aspects.zeta + ]; + + den.aspects.epsilon.includes = [ den.policies.shared-tool ]; + den.aspects.zeta.includes = [ den.policies.shared-tool ]; + + expr = igloo.environment.etc.shared-tool.text; + expected = "yes"; + } + ); + + }; +} From 0334b860157b88a495b3e62214a7b5ac5e1c33ac Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Sat, 5 Sep 2026 01:27:51 -0700 Subject: [PATCH 20/59] fix: qualify displaced policy identity by claim index and self-or-ancestor scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit children.nix's registerPolicy displaced-identity path qualified only by parent chain, which is constant across every claimant sharing one chain — distinguishing claimant #1 from the rest but not #2 from #3 at N>=3. Qualify with the claim's own index within its bucket instead. The same-scope collision check also used e.scope == scope, but policy/schema.nix's emitLateForSibling merges an ancestor scope's registrations with a descendant sibling's by this same ownerIdentity (allAspectPolicies = scopedAspectPolicies.${parentScope} // scopedAspectPolicies.${sib.scopeId}). Widen the filter to self-or-ancestor via the shared foldScopeAncestors walk already used by the constraint registry, so a host-scope and descendant-scope policy sharing a bare name are recognized as distinct claimants there too. Covers both with dedicated regression cells: a three-claimant factory shape for the index qualification, and an ancestor/descendant scope pair (with a second sibling as control) for the late-dispatch merge. --- nix/lib/aspects/fx/aspect/children.nix | 65 +++++++--- .../deadbugs/policy-record-provenance.nix | 115 +++++++++++++++++- 2 files changed, 157 insertions(+), 23 deletions(-) diff --git a/nix/lib/aspects/fx/aspect/children.nix b/nix/lib/aspects/fx/aspect/children.nix index 4f85a8b5d..f1818e259 100644 --- a/nix/lib/aspects/fx/aspect/children.nix +++ b/nix/lib/aspects/fx/aspect/children.nix @@ -7,6 +7,10 @@ let inherit (den.lib) fx; inherit (den.lib.aspects.fx) identity; inherit (import ./normalize.nix { inherit lib den; }) wrapChild isMeaningfulName; + # foldScopeAncestors: the shared cycle-guarded self-or-ancestor walk over + # scopeParent (also used by the constraint registry). registerPolicy reuses + # it rather than a same-scope-only filter — see its comment for why. + inherit (import ../handlers/constraint.nix { inherit lib den; }) foldScopeAncestors; nameIndexed = state: base: idx: ctxId: @@ -61,47 +65,70 @@ let # would fix the collision below at the cost of every one of those. So the # bare name stays the identity in the overwhelming common case (one # registration per name per scope), and only a genuine collision — a - # second, DIFFERENT record claiming a name already taken IN THIS SCOPE — - # gets displaced to a chain-qualified identity instead of silently - # overwriting the first (scopedAspectPolicies merges by overwrite, one dict - # per scope). + # second, DIFFERENT record claiming a name already taken — gets displaced + # to a chain-qualified identity instead of silently overwriting the first + # (scopedAspectPolicies merges by overwrite, one dict per scope). # - # Claims are bucketed by bare name and scoped by state.currentScope, not by - # definition position (Task 4c's registry for aspects): traced empirically, - # two aspects each declaring their own "policies.tools" merge at DIFFERENT - # option paths — each aspect's own submodule eval bakes its own name into - # `loc` — so a def-position bucket puts them in separate buckets and misses - # the collision entirely, even though both still land in the SAME scope's - # scopedAspectPolicies and one overwrites the other. Whole-value equality - # within one scope is what actually tells "one shared policy referenced - # twice" (O5, must merge into one identity) apart from "two distinct - # same-named policies" (O4, must split); two different scopes never - # collide in scopedAspectPolicies to begin with, so entries from another - # scope are excluded from the comparison rather than forcing a needless - # qualification. + # Claims are bucketed by bare name, not by definition position (Task 4c's + # registry for aspects): traced empirically, two aspects each declaring + # their own "policies.tools" merge at DIFFERENT option paths — each + # aspect's own submodule eval bakes its own name into `loc` — so a + # def-position bucket puts them in separate buckets and misses the + # collision entirely, even though both still land in one scope's + # scopedAspectPolicies. Whole-value equality is what actually tells "one + # shared policy referenced twice" (O5, must merge into one identity) apart + # from "two distinct same-named policies" (O4, must split). + # + # "Same scope" for that comparison means self-or-ancestor (foldScopeAncestors + # over scopeParent), not `e.scope == scope`: the late-policy dispatch + # (policy/schema.nix emitLateForSibling) merges a parent scope's + # registrations with a descendant sibling's BY THIS SAME ownerIdentity + # (`allAspectPolicies = scopedAspectPolicies.${parentScope} // + # scopedAspectPolicies.${sib.scopeId}`) — a same-scope-only filter let a + # host-scope "tools" and a distinct descendant-scope "tools" both keep the + # bare name and collide at that merge. + # + # A displaced identity is qualified with the claim's own index within its + # bucket, not just the parent chain: every claimant sharing one parent + # chain (e.g. three factory-built policies included as siblings) shares + # the SAME chain segments, so without the index the second and third (and + # every later) distinct claimant would collide with EACH OTHER under one + # identical qualified string. registerPolicy = p: fx.bind fx.effects.state.get ( state: let scope = state.currentScope; + scopeParentMap = (state.scopeParent or (_: { })) null; bucketKey = "name:${p.name}"; claimRegistry = (state.policyClaimsByName or (_: { })) null; claimedEntries = claimRegistry.${bucketKey} or [ ]; - sameScopeEntries = builtins.filter (e: e.scope == scope) claimedEntries; + # foldScopeAncestors's accumulator is an attrset (its other caller + # merges constraint-registry dicts); key each step by its own scope + # id — foldScopeAncestors visits each scope at most once (cycle + # guard), so those keys never collide — then flatten to the list + # matchingClaim/ownerIdentity actually want. + entriesByAncestorScope = foldScopeAncestors (a: b: a // b) scopeParentMap (s: { + ${s} = builtins.filter (e: e.scope == s) claimedEntries; + }) scope; + sameScopeEntries = builtins.concatLists (builtins.attrValues entriesByAncestorScope); # Whole-record comparison, nothing projected out: a record differing # only in, say, an attached label must not be read as the same # registration as one that lacks it. matchingClaim = lib.findFirst (e: p == e.value) null sameScopeEntries; parentStack = ((state.scopedIncludesChainSegments or (_: { })) null).${scope} or [ ]; parentChainSegments = if parentStack == [ ] then [ ] else lib.last parentStack; + # Taken before this claim is appended, so the first displaced claim + # gets 1, the second 2, etc. — unique per claim, not just per parent. + claimIndex = builtins.length sameScopeEntries; ownerIdentity = if matchingClaim != null then matchingClaim.identity else if sameScopeEntries == [ ] then p.name else - identity.pathKey (parentChainSegments ++ [ p.name ]); + identity.pathKey (parentChainSegments ++ [ "${p.name}#${toString claimIndex}" ]); registerEffect = fx.send "register-aspect-policy" { inherit (p) fn; inherit ownerIdentity; diff --git a/templates/ci/modules/features/deadbugs/policy-record-provenance.nix b/templates/ci/modules/features/deadbugs/policy-record-provenance.nix index 6cebaa54d..855da04b2 100644 --- a/templates/ci/modules/features/deadbugs/policy-record-provenance.nix +++ b/templates/ci/modules/features/deadbugs/policy-record-provenance.nix @@ -63,10 +63,11 @@ # O4 variant: the same collision reached through an aspect's own nested # `.policies.` registry instead of an inline mkPolicy literal. Two # different aspects each declare their OWN "tools" policy (different - # bodies) — a registry-key-only fix (bucketing purely by the module - # system's `loc`, which is submodule-local and identical for both - # aspects' "policies.tools" option) must still tell them apart by raw - # value, not by the bucket key alone. + # bodies) at genuinely DIFFERENT module `loc`s — each aspect's own + # submodule eval bakes its own name into `loc`, so a fix bucketing by + # def-position/loc would put them in separate buckets and MISS this + # collision entirely. They still collide because both land in the same + # scope's scopedAspectPolicies keyed by the bare name "tools". test-o4-two-aspect-own-policies-same-name-both-fire = denTest ( { den, igloo, ... }: { @@ -108,6 +109,112 @@ } ); + # O4 at N=3: the same collision as above, with three sibling same-named + # claimants instead of two — the exact factory shape + # `map (t: mkPolicy "tools" (bodyFor t)) [ ... ]`. A qualified identity + # built only from the parent chain is constant across every claimant + # sharing that chain, so it distinguishes claimant #1 from the rest but + # not #2 from #3 — safe at N=2, silent drop at N>=3. + test-o4-three-same-named-claimants-all-fire = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.igloo.includes = + map + ( + t: + den.lib.policy.mkPolicy "tools" ( + { host, ... }: + [ + (den.lib.policy.provide { + class = host.class; + module.environment.etc."${t}-tools".text = "yes"; + }) + ] + ) + ) + [ + "a" + "b" + "c" + ]; + + expr = { + a = igloo.environment.etc ? "a-tools"; + b = igloo.environment.etc ? "b-tools"; + c = igloo.environment.etc ? "c-tools"; + }; + expected = { + a = true; + b = true; + c = true; + }; + } + ); + + # O4 across an ancestor/descendant scope pair: a host-scope "tools" policy + # requiring `{ user, ... }` — unbound at the host's own dispatch, so it + # only fires via policy/schema.nix's late-dispatch pass — and a DIFFERENT + # user-scope "tools" owned by tux directly must both deliver. Late-dispatch + # merges the host scope's registrations with a sibling user's BY + # ownerIdentity (`scopedAspectPolicies.${parentScope} // + # scopedAspectPolicies.${sib.scopeId}` in emitLateForSibling) — a + # same-scope-only identity comparison let both keep the bare name "tools", + # so the merge picked tux's own (already-fired) claim and the host's late + # policy was filtered out as "already fired" under that name, never + # reaching tux. Two users are required: the late-dispatch pass only runs + # when there's more than one sibling (isFanOut); pingu (with no own + # "tools") is the control showing the host's policy is undisturbed where + # there's nothing to collide with. + test-o4-ancestor-descendant-scopes-same-name-both-fire = denTest ( + { + den, + tuxHm, + pinguHm, + ... + }: + { + den.hosts.x86_64-linux.igloo.users = { + tux = { }; + pingu = { }; + }; + + den.aspects.igloo.includes = [ + (den.lib.policy.mkPolicy "tools" ( + { user, ... }: + [ + (den.lib.policy.include { + homeManager.home.sessionVariables.HOST_TOOLS = user.name; + }) + ] + )) + ]; + + den.aspects.tux.includes = [ + (den.lib.policy.mkPolicy "tools" ( + { ... }: + [ + (den.lib.policy.include { + homeManager.home.sessionVariables.TUX_OWN_TOOLS = "yes"; + }) + ] + )) + ]; + + expr = { + tuxHost = tuxHm.home.sessionVariables.HOST_TOOLS or "absent"; + tuxOwn = tuxHm.home.sessionVariables.TUX_OWN_TOOLS or "absent"; + pinguHost = pinguHm.home.sessionVariables.HOST_TOOLS or "absent"; + }; + expected = { + tuxHost = "tux"; + tuxOwn = "yes"; + pinguHost = "pingu"; + }; + } + ); + # O5 (O4's control): a SHARED `den.policies.foo`, included from two # different aspects, is one registration referenced twice — not two # authored policies. A fix that splits identity by inclusion site From d2c27ae42239994d3fc12c46b43cd4df34fd2055 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 8 Sep 2026 10:28:32 -0700 Subject: [PATCH 21/59] fix: resolve policy excludes by raw claim value, not bare name excludeIdentity resolved a policy exclude to its bare registration name, but same-named claimants no longer all share that name -- only whichever claimant registers first keeps it (children.nix registerPolicy); a later claimant gets a chain-qualified identity instead. `excludes = [ betaTools ]` names a specific record, so an exclude authored against it must resolve to whichever identity betaTools itself ends up holding, not to whatever currently occupies the bare name. Raw-value equality against the claim registry is comparable at dispatch time (confirmed empirically), but not at register-constraint time: an aspect's own excludes register before its own includes walk, so the claim registry a raw lookup needs is still empty then. Constraint entries now carry the raw policy reference (rawRef) alongside the bare-name fallback key; resolution happens later, at dispatch, via the shared isPolicyExcluded (constraint.nix) -- the single entry point for both the initial per-scope dispatch (dispatch-policies.nix) and the late-sibling re-dispatch (policy/schema.nix emitLateForSibling), which previously ran its own bare-name-only exclude check and let a rawRef-excluded claimant fire anyway. --- nix/lib/aspects/fx/aspect/children.nix | 48 +++++++----- nix/lib/aspects/fx/handlers/constraint.nix | 73 +++++++++++++++++++ .../aspects/fx/handlers/dispatch-policies.nix | 14 +--- nix/lib/aspects/fx/policy/schema.nix | 10 ++- .../ci/modules/public-api/policy-excludes.nix | 73 +++++++++++++++++++ 5 files changed, 186 insertions(+), 32 deletions(-) diff --git a/nix/lib/aspects/fx/aspect/children.nix b/nix/lib/aspects/fx/aspect/children.nix index f1818e259..bee8ed6ad 100644 --- a/nix/lib/aspects/fx/aspect/children.nix +++ b/nix/lib/aspects/fx/aspect/children.nix @@ -10,7 +10,11 @@ let # foldScopeAncestors: the shared cycle-guarded self-or-ancestor walk over # scopeParent (also used by the constraint registry). registerPolicy reuses # it rather than a same-scope-only filter — see its comment for why. - inherit (import ../handlers/constraint.nix { inherit lib den; }) foldScopeAncestors; + # resolveClaim: the same self-or-ancestor raw-value claim lookup, shared + # with dispatch-policies.nix's raw-ref exclude resolution — see + # registerConstraints's excludeList comment for why that's deferred there + # rather than resolved here. + inherit (import ../handlers/constraint.nix { inherit lib den; }) foldScopeAncestors resolveClaim; nameIndexed = state: base: idx: ctxId: @@ -104,19 +108,8 @@ let bucketKey = "name:${p.name}"; claimRegistry = (state.policyClaimsByName or (_: { })) null; claimedEntries = claimRegistry.${bucketKey} or [ ]; - # foldScopeAncestors's accumulator is an attrset (its other caller - # merges constraint-registry dicts); key each step by its own scope - # id — foldScopeAncestors visits each scope at most once (cycle - # guard), so those keys never collide — then flatten to the list - # matchingClaim/ownerIdentity actually want. - entriesByAncestorScope = foldScopeAncestors (a: b: a // b) scopeParentMap (s: { - ${s} = builtins.filter (e: e.scope == s) claimedEntries; - }) scope; - sameScopeEntries = builtins.concatLists (builtins.attrValues entriesByAncestorScope); - # Whole-record comparison, nothing projected out: a record differing - # only in, say, an attached label must not be read as the same - # registration as one that lacks it. - matchingClaim = lib.findFirst (e: p == e.value) null sameScopeEntries; + claimResult = resolveClaim scopeParentMap scope claimedEntries p; + inherit (claimResult) sameScopeEntries matchingClaim; parentStack = ((state.scopedIncludesChainSegments or (_: { })) null).${scope} or [ ]; parentChainSegments = if parentStack == [ ] then [ ] else lib.last parentStack; # Taken before this claim is appended, so the first displaced claim @@ -280,11 +273,28 @@ let } else identity.key ref; - excludeList = map (ref: { - type = "exclude"; - scope = "subtree"; - identity = excludeIdentity ref; - }) rawExcludes; + # A policy exclude's `identity` is still only a bare-name guess (kept + # as a fallback storage key — see the __isPolicy branch above): this + # aspect's own registerConstraints runs BEFORE its includes are + # walked (compile-static sequences registerConstraints ahead of + # resolve-children's emitIncludes), so the claim registry a raw-value + # lookup would need is measurably still empty here — traced empirically, + # `state.policyClaimsByName."name:"` reads `[ ]` at this exact + # point even though the excluded record's own self-equality already + # compares true (`ref == ref`). rawRef carries the record itself so + # constraint.nix's isPolicyExcluded (shared by dispatch-policies.nix's + # initial dispatch and policy/schema.nix's late-sibling re-dispatch) + # can resolve it later, once dispatch has run past this aspect's own + # includes and the registry actually holds the claim. + excludeList = map ( + ref: + { + type = "exclude"; + scope = "subtree"; + identity = excludeIdentity ref; + } + // lib.optionalAttrs (builtins.isAttrs ref && ref.__isPolicy or false) { rawRef = ref; } + ) rawExcludes; allConstraints = handleWithList ++ excludeList; owner = aspect.name or ""; in diff --git a/nix/lib/aspects/fx/handlers/constraint.nix b/nix/lib/aspects/fx/handlers/constraint.nix index d6ce25210..5b665bb10 100644 --- a/nix/lib/aspects/fx/handlers/constraint.nix +++ b/nix/lib/aspects/fx/handlers/constraint.nix @@ -53,6 +53,27 @@ let in go { } scope { }; + # Self-or-ancestor raw-value claim lookup (foldScopeAncestors over one + # policyClaimsByName bucket). Shared by registerPolicy (children.nix, + # write path: appends a new claim when nothing matches) and raw-ref + # exclude resolution (dispatch-policies.nix, read path). Both must judge + # "is this the same registration" by the identical rule, so an exclude and + # its target are matched exactly as registerPolicy would have matched + # them. Whole-value `==`, not a projection: two claims differing only in + # an attached label must not read as one registration. + resolveClaim = + scopeParentMap: scope: claimedEntries: target: + let + entriesByAncestorScope = foldScopeAncestors (a: b: a // b) scopeParentMap (s: { + ${s} = builtins.filter (e: e.scope == s) claimedEntries; + }) scope; + sameScopeEntries = builtins.concatLists (builtins.attrValues entriesByAncestorScope); + in + { + inherit sameScopeEntries; + matchingClaim = lib.findFirst (e: target == e.value) null sameScopeEntries; + }; + # The constraint registry relevant to a scope, as one identity→entries map — # the merge of the scope's own + ANCESTOR scopes' entries (cycle-guarded walk # up scopeParent). Replaces the fleet-wide flat registry: it is the SINGLE @@ -93,6 +114,52 @@ let # The common case: scope to the state's currentScope. scopedConstraintsFor = state: scopedConstraintsForScope state (state.currentScope or null); + # A raw-ref exclude entry's OWN target identity, resolved the same way + # registerPolicy assigned it: self-or-ancestor lookup in the claim + # registry by raw value, from `scope`. null when the referenced policy + # never actually registered anywhere reachable from `scope`. `scope` is + # taken explicitly rather than read off `state.currentScope`: the + # late-sibling caller resolves FOR a sibling scope while running AT its + # parent's, and a claim registered only at the sibling's own scope is a + # descendant of, not an ancestor of, the parent — invisible to a walk that + # started there instead. + resolveRawRefIdentity = + state: scope: e: + let + scopeParentMap = (state.scopeParent or (_: { })) null; + claimRegistry = (state.policyClaimsByName or (_: { })) null; + claimedEntries = claimRegistry."name:${e.rawRef.name}" or [ ]; + resolved = resolveClaim scopeParentMap scope claimedEntries e.rawRef; + in + if resolved.matchingClaim != null then resolved.matchingClaim.identity else null; + + # Is `name` excluded by `registry` (a constraint registry already scoped to + # `scope` — see scopedConstraintsFor/scopedConstraintsForScope)? Two arms, + # mirroring registerPolicy's own identity assignment (children.nix): (a) a + # direct match under name's own bucket, where a rawRef-tagged entry counts + # only as a FALLBACK — when its raw-value resolution fails because the + # referenced policy never registered — since its bare-name storage key is + # otherwise just a guess, displaced by (b); (b) a rawRef-tagged entry + # anywhere in the registry whose raw-value resolution names `name` + # precisely. The single entry point for both the initial per-scope + # dispatch (dispatch-policies.nix, scope = state.currentScope) and the + # late-sibling re-dispatch (policy/schema.nix emitLateForSibling, scope = + # sib.scopeId) — both must exclude the SAME claimant, or a claimant + # filtered from one still fires through the other. + isPolicyExcluded = + state: scope: registry: name: + let + directEntries = registry.${name} or [ ]; + directApplies = + e: + e.type == "exclude" && ((e.rawRef or null) == null || resolveRawRefIdentity state scope e == null); + rawRefEntries = builtins.filter (e: e.type == "exclude" && (e.rawRef or null) != null) ( + builtins.concatLists (builtins.attrValues registry) + ); + in + builtins.any directApplies directEntries + || builtins.any (e: resolveRawRefIdentity state scope e == name) rawRefEntries; + entryToResume = entry: if entry.type == "exclude" then @@ -137,6 +204,10 @@ let inherit (param) type; getReplacement = param.getReplacement or (_: null); owner = param.owner or ""; + # Carried for dispatch-policies.nix's raw-ref exclude resolution + # (null for every non-policy constraint — see children.nix's + # excludeList). + rawRef = param.rawRef or null; inherit scope ownerChain; }; in @@ -208,6 +279,8 @@ in lookupEntries isAncestorChain foldScopeAncestors + resolveClaim + isPolicyExcluded collectScopedConstraints scopedConstraintsFor scopedConstraintsForScope diff --git a/nix/lib/aspects/fx/handlers/dispatch-policies.nix b/nix/lib/aspects/fx/handlers/dispatch-policies.nix index 00f97ce80..a379b300d 100644 --- a/nix/lib/aspects/fx/handlers/dispatch-policies.nix +++ b/nix/lib/aspects/fx/handlers/dispatch-policies.nix @@ -10,15 +10,7 @@ }: let inherit (den.lib) fx; - inherit (import ./constraint.nix { inherit lib den; }) scopedConstraintsFor; - - # Check if a policy name is excluded by any constraint in the registry. - isExcluded = - registry: name: - let - entries = registry.${name} or [ ]; - in - builtins.any (e: e.type == "exclude") entries; + inherit (import ./constraint.nix { inherit lib den; }) scopedConstraintsFor isPolicyExcluded; in { mkDispatchPoliciesHandler = mkDispatch: { @@ -28,7 +20,9 @@ in # Entity-scoped (scope + ancestors, NOT fleet-wide) — a sibling entity's # policy-exclude must not filter this scope's policies (#613 analog). registry = scopedConstraintsFor state; - filteredPolicies = lib.filterAttrs (name: _: !isExcluded registry name) param.aspectPolicies; + filteredPolicies = lib.filterAttrs ( + name: _: !isPolicyExcluded state state.currentScope registry name + ) param.aspectPolicies; in { resume = mkDispatch filteredPolicies param.firedPolicies param.resolveCtx; diff --git a/nix/lib/aspects/fx/policy/schema.nix b/nix/lib/aspects/fx/policy/schema.nix index 4a805d910..e234fa5d5 100644 --- a/nix/lib/aspects/fx/policy/schema.nix +++ b/nix/lib/aspects/fx/policy/schema.nix @@ -16,7 +16,10 @@ mkSupplementalResolution, }: let - inherit (import ../handlers/constraint.nix { inherit lib den; }) scopedConstraintsForScope; + inherit (import ../handlers/constraint.nix { inherit lib den; }) + scopedConstraintsForScope + isPolicyExcluded + ; # Determine target entity kind from a schema effect. resolveTargetKind = @@ -209,8 +212,9 @@ let # child sibling, and the relevant excludes (e.g. den.schema.flake-system. # excludes) register at the sibling/descendant scope, not an ancestor. constraintRegistry = scopedConstraintsForScope state sib.scopeId; - isExcluded = name: builtins.any (e: e.type == "exclude") (constraintRegistry.${name} or [ ]); - filteredPolicies = lib.filterAttrs (name: _: !isExcluded name) latePolicies; + filteredPolicies = lib.filterAttrs ( + name: _: !isPolicyExcluded state sib.scopeId constraintRegistry name + ) latePolicies; resolveCtx = sib.scopedCtx // { __entityKind = sib.targetKind; }; diff --git a/templates/ci/modules/public-api/policy-excludes.nix b/templates/ci/modules/public-api/policy-excludes.nix index db5426a0a..557fb271f 100644 --- a/templates/ci/modules/public-api/policy-excludes.nix +++ b/templates/ci/modules/public-api/policy-excludes.nix @@ -63,6 +63,79 @@ } ); + # PE / PE3: excludeIdentity resolves a policy exclude to its bare name, + # but post-Task-5 same-named claimants are no longer all addressable by + # that name — only whichever one registered FIRST keeps it; later + # claimants get a chain-qualified identity (children.nix registerPolicy). + # `excludes = [ betaTools ]` names a SPECIFIC record, but the bare-name + # identity it resolves to belongs to whichever claimant registered + # first — alpha here, since it's first in `includes`. PE3 is + # byte-identical with `includes` reversed: same `excludes`, but now beta + # registers first and the bare name happens to land on the right + # claimant. The pair together is the order-dependence proof: an authored + # exclude must mean the same thing regardless of its target's position. + test-pe-exclude-targets-wrong-same-named-claimant = denTest ( + { den, igloo, ... }: + let + alphaTools = den.lib.policy.mkPolicy "tools" (_: [ + (den.lib.policy.include { nixos.environment.variables.ALPHA_MARKER = "yes"; }) + ]); + betaTools = den.lib.policy.mkPolicy "tools" (_: [ + (den.lib.policy.include { nixos.environment.variables.BETA_MARKER = "yes"; }) + ]); + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.aspects.igloo = { + includes = [ + alphaTools + betaTools + ]; + excludes = [ betaTools ]; + }; + + expr = { + alpha-fires = igloo.environment.variables ? ALPHA_MARKER; + beta-excluded = !(igloo.environment.variables ? BETA_MARKER); + }; + expected = { + alpha-fires = true; + beta-excluded = true; + }; + } + ); + + test-pe3-same-scenario-includes-order-reversed = denTest ( + { den, igloo, ... }: + let + alphaTools = den.lib.policy.mkPolicy "tools" (_: [ + (den.lib.policy.include { nixos.environment.variables.ALPHA_MARKER = "yes"; }) + ]); + betaTools = den.lib.policy.mkPolicy "tools" (_: [ + (den.lib.policy.include { nixos.environment.variables.BETA_MARKER = "yes"; }) + ]); + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.aspects.igloo = { + includes = [ + betaTools + alphaTools + ]; + excludes = [ betaTools ]; + }; + + expr = { + alpha-fires = igloo.environment.variables ? ALPHA_MARKER; + beta-excluded = !(igloo.environment.variables ? BETA_MARKER); + }; + expected = { + alpha-fires = true; + beta-excluded = true; + }; + } + ); + # Parent excludes are authoritative — child includes cannot override. test-parent-excludes-authoritative = denTest ( { den, igloo, ... }: From cebe19e1e3ce32e56e8c4ff066b15f160c39b192 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 8 Sep 2026 10:43:58 -0700 Subject: [PATCH 22/59] fix: drop the absence-case exclude fallback, pin the bound in isPolicyExcluded An exclude naming a record that never registered must exclude nothing, not fall back to a bare-name match that kills an unrelated policy sharing that name. Also records isPolicyExcluded's per-call cost now that it flattens the scoped registry instead of one lookup. --- nix/lib/aspects/fx/aspect/children.nix | 11 +++-- nix/lib/aspects/fx/handlers/constraint.nix | 42 ++++++++++++------- .../ci/modules/public-api/policy-excludes.nix | 33 +++++++++++++++ 3 files changed, 65 insertions(+), 21 deletions(-) diff --git a/nix/lib/aspects/fx/aspect/children.nix b/nix/lib/aspects/fx/aspect/children.nix index bee8ed6ad..441d908fc 100644 --- a/nix/lib/aspects/fx/aspect/children.nix +++ b/nix/lib/aspects/fx/aspect/children.nix @@ -7,14 +7,13 @@ let inherit (den.lib) fx; inherit (den.lib.aspects.fx) identity; inherit (import ./normalize.nix { inherit lib den; }) wrapChild isMeaningfulName; - # foldScopeAncestors: the shared cycle-guarded self-or-ancestor walk over - # scopeParent (also used by the constraint registry). registerPolicy reuses - # it rather than a same-scope-only filter — see its comment for why. - # resolveClaim: the same self-or-ancestor raw-value claim lookup, shared - # with dispatch-policies.nix's raw-ref exclude resolution — see + # resolveClaim: the shared cycle-guarded self-or-ancestor raw-value claim + # lookup (also used by the constraint registry). registerPolicy reuses it + # rather than a same-scope-only filter — see its comment for why. Also + # shared with dispatch-policies.nix's raw-ref exclude resolution — see # registerConstraints's excludeList comment for why that's deferred there # rather than resolved here. - inherit (import ../handlers/constraint.nix { inherit lib den; }) foldScopeAncestors resolveClaim; + inherit (import ../handlers/constraint.nix { inherit lib den; }) resolveClaim; nameIndexed = state: base: idx: ctxId: diff --git a/nix/lib/aspects/fx/handlers/constraint.nix b/nix/lib/aspects/fx/handlers/constraint.nix index 5b665bb10..10c99377e 100644 --- a/nix/lib/aspects/fx/handlers/constraint.nix +++ b/nix/lib/aspects/fx/handlers/constraint.nix @@ -134,25 +134,37 @@ let if resolved.matchingClaim != null then resolved.matchingClaim.identity else null; # Is `name` excluded by `registry` (a constraint registry already scoped to - # `scope` — see scopedConstraintsFor/scopedConstraintsForScope)? Two arms, - # mirroring registerPolicy's own identity assignment (children.nix): (a) a - # direct match under name's own bucket, where a rawRef-tagged entry counts - # only as a FALLBACK — when its raw-value resolution fails because the - # referenced policy never registered — since its bare-name storage key is - # otherwise just a guess, displaced by (b); (b) a rawRef-tagged entry - # anywhere in the registry whose raw-value resolution names `name` - # precisely. The single entry point for both the initial per-scope - # dispatch (dispatch-policies.nix, scope = state.currentScope) and the - # late-sibling re-dispatch (policy/schema.nix emitLateForSibling, scope = - # sib.scopeId) — both must exclude the SAME claimant, or a claimant - # filtered from one still fires through the other. + # `scope` — see scopedConstraintsFor/scopedConstraintsForScope)? Two arms: + # (a) a direct match under name's own bucket, for entries with no rawRef + # (schema/aspect-content excludes, and the dead string-policy-exclude route + # — both key on bare identity already); (b) a rawRef-tagged entry anywhere + # in the registry whose raw-value resolution names `name` precisely. A + # rawRef entry whose target never registered resolves to null and excludes + # nothing — naming a record that was never included must not fall back to + # matching some unrelated policy that happens to share its bare name. The + # single entry point for both the initial per-scope dispatch + # (dispatch-policies.nix, scope = state.currentScope) and the late-sibling + # re-dispatch (policy/schema.nix emitLateForSibling, scope = sib.scopeId) — + # both must exclude the SAME claimant, or a claimant filtered from one + # still fires through the other. + # + # Cost: registry is already scoped to one entity's self+ancestors (bounded + # by include-nesting depth, not fleet-wide), but within that scope this + # flattens EVERY identity bucket to find rawRef entries and re-walks + # ancestor scopes (resolveClaim) per rawRef entry — replacing what was a + # single `registry.${name} or []` lookup. Per call: O(E + R × D), E = total + # constraint entries in scope, R = rawRef excludes in scope, D = ancestor + # depth per resolveClaim walk. Called once per policy name per dispatch, so + # a dispatch over P policies is O(P × (E + R × D)). Bounded in practice by + # how many excludes/policies one aspect tree declares — not by fleet size, + # since scope is per-entity. den's performance suite (perf 29/29) declares + # zero hosts and does not exercise this path at entity scale; unmeasured + # there. isPolicyExcluded = state: scope: registry: name: let directEntries = registry.${name} or [ ]; - directApplies = - e: - e.type == "exclude" && ((e.rawRef or null) == null || resolveRawRefIdentity state scope e == null); + directApplies = e: e.type == "exclude" && (e.rawRef or null) == null; rawRefEntries = builtins.filter (e: e.type == "exclude" && (e.rawRef or null) != null) ( builtins.concatLists (builtins.attrValues registry) ); diff --git a/templates/ci/modules/public-api/policy-excludes.nix b/templates/ci/modules/public-api/policy-excludes.nix index 557fb271f..00bcfce4a 100644 --- a/templates/ci/modules/public-api/policy-excludes.nix +++ b/templates/ci/modules/public-api/policy-excludes.nix @@ -136,6 +136,39 @@ } ); + # X1: excludes naming a same-named record that was never included must + # exclude nothing — not fall back to a bare-name match that kills an + # unrelated, legitimately-included policy sharing that name. ghostTools + # is never in `includes`, so its raw-value resolution fails; realTools + # (the only "tools" claimant that actually registered) must still fire. + test-x1-absent-exclude-target-excludes-nothing = denTest ( + { den, igloo, ... }: + let + realTools = den.lib.policy.mkPolicy "tools" (_: [ + (den.lib.policy.include { nixos.environment.variables.REAL_MARKER = "yes"; }) + ]); + ghostTools = den.lib.policy.mkPolicy "tools" (_: [ + (den.lib.policy.include { nixos.environment.variables.GHOST_MARKER = "yes"; }) + ]); + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.aspects.igloo = { + includes = [ realTools ]; + excludes = [ ghostTools ]; + }; + + expr = { + real-still-fires = igloo.environment.variables ? REAL_MARKER; + ctrl = !(igloo.environment.variables ? GHOST_MARKER); + }; + expected = { + real-still-fires = true; + ctrl = true; + }; + } + ); + # Parent excludes are authoritative — child includes cannot override. test-parent-excludes-authoritative = denTest ( { den, igloo, ... }: From ac67a84841b2494279c5a9b09f0e2cde0c440350 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 8 Sep 2026 10:57:47 -0700 Subject: [PATCH 23/59] =?UTF-8?q?fix:=20correct=20isPolicyExcluded's=20rec?= =?UTF-8?q?orded=20cost=20bound=20to=20O(P=C3=97(E+R=C3=97D=C3=97C))?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior bound omitted C, the size of the claim bucket resolveClaim scans at each visited ancestor scope — and that bucket is fleet-wide (policyClaimsByName), not per-entity, so the R×D×C term is linear in fleet size once multiple entities declare a same-named excluded policy. Comment only; no behavior change. --- nix/lib/aspects/fx/handlers/constraint.nix | 36 ++++++++++++++-------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/nix/lib/aspects/fx/handlers/constraint.nix b/nix/lib/aspects/fx/handlers/constraint.nix index 10c99377e..5e6e86d94 100644 --- a/nix/lib/aspects/fx/handlers/constraint.nix +++ b/nix/lib/aspects/fx/handlers/constraint.nix @@ -148,18 +148,30 @@ let # both must exclude the SAME claimant, or a claimant filtered from one # still fires through the other. # - # Cost: registry is already scoped to one entity's self+ancestors (bounded - # by include-nesting depth, not fleet-wide), but within that scope this - # flattens EVERY identity bucket to find rawRef entries and re-walks - # ancestor scopes (resolveClaim) per rawRef entry — replacing what was a - # single `registry.${name} or []` lookup. Per call: O(E + R × D), E = total - # constraint entries in scope, R = rawRef excludes in scope, D = ancestor - # depth per resolveClaim walk. Called once per policy name per dispatch, so - # a dispatch over P policies is O(P × (E + R × D)). Bounded in practice by - # how many excludes/policies one aspect tree declares — not by fleet size, - # since scope is per-entity. den's performance suite (perf 29/29) declares - # zero hosts and does not exercise this path at entity scale; unmeasured - # there. + # Cost: registry is already scoped to one entity's self+ancestors, but + # within that scope this flattens EVERY identity bucket to find rawRef + # entries and re-walks ancestor scopes (resolveClaim) per rawRef entry — + # replacing what was a single `registry.${name} or []` lookup. Per call: + # O(E + R × D × C). Per dispatch over P policies: O(P × (E + R × D × C)). + # E = constraint entries in the scoped registry — per-entity, bounded by + # include-nesting depth, not fleet-wide. + # R = rawRef excludes in scope. + # D = scopes visited per resolveClaim ancestor walk — small (e.g. a + # user-scope walk visits 4: self, host, system, and the root "" + # scope), bounded by scope-tree depth, not fleet-wide. + # C = size of the claim bucket resolveClaim scans at each visited scope + # (claimRegistry."name:" — builtins.filter + findFirst over it). + # This bucket is FLEET-WIDE, not per-entity: policyClaimsByName + # accumulates every claim under that bare name across the whole run, + # regardless of scope. Traced: N entities each declaring their own + # "tools" policy grows C to N while the entity-scoped walk still + # keeps exactly 1 matching claim. So with R > 0 and multiple entities + # declaring a same-named policy, the R × D × C term is linear in + # fleet size N — do not read E's per-entity bound as covering the + # whole cost; E and C are scoped oppositely and must not be merged + # under one "bounded per-entity" claim. + # den's performance suite (perf 29/29) declares zero hosts and does not + # exercise this path at entity scale; unmeasured there. isPolicyExcluded = state: scope: registry: name: let From 3ccc828cfa49876640569c1db0167c268cf62ee4 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 8 Sep 2026 11:05:31 -0700 Subject: [PATCH 24/59] fix: describe isPolicyExcluded's E bound as a count, not a depth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E is bounded by how many excludes/handleWith one aspect tree declares — a count, not the include-nesting depth. Comment only. --- nix/lib/aspects/fx/handlers/constraint.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/lib/aspects/fx/handlers/constraint.nix b/nix/lib/aspects/fx/handlers/constraint.nix index 5e6e86d94..a7b179a06 100644 --- a/nix/lib/aspects/fx/handlers/constraint.nix +++ b/nix/lib/aspects/fx/handlers/constraint.nix @@ -154,7 +154,7 @@ let # replacing what was a single `registry.${name} or []` lookup. Per call: # O(E + R × D × C). Per dispatch over P policies: O(P × (E + R × D × C)). # E = constraint entries in the scoped registry — per-entity, bounded by - # include-nesting depth, not fleet-wide. + # how many excludes/handleWith one aspect tree declares, not fleet-wide. # R = rawRef excludes in scope. # D = scopes visited per resolveClaim ancestor walk — small (e.g. a # user-scope walk visits 4: self, host, system, and the root "" From ac5c50f9ac1bf2db4aa422fa7ae23515a5d27fbb Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 8 Sep 2026 11:41:48 -0700 Subject: [PATCH 25/59] fix: fold `_` into `provides` once, before every construction site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nix/lib/aspects/types.nix built the `_`-as-provides alias three separate ways: root via mkAliasOptionModule (merges, errors on conflict), nested via a `//` fold (silently overwrites, spelling-priority not order-priority so reordering never recovered the dropped definition), and mergeFunctions' battery branch (only ever read fn.provides, ignored fn._ entirely). Add foldUnderscoreIntoProvides, a single helper that folds a `_` write into `provides` before flatDefs/mergeFunctions see it, so both spellings of one provides key become defs of the same key and merge through the existing per-site machinery instead of one silently dropping the other. Root is unchanged: mkAliasOptionModule already normalizes `_` before mergeWithAspectMeta runs, so calling the helper there would be a no-op. This unifies spelling only (O8, the weak reading), not root's and nested's differing conflict semantics — root still errors on a genuine scalar conflict, nested still last-wins. test-q4-nested-conflict-is-error guards that boundary and is expected to stay red. --- nix/lib/aspects/types.nix | 81 +++++++---- .../underscore-provides-spelling-merge.nix | 136 ++++++++++++++++++ 2 files changed, 188 insertions(+), 29 deletions(-) create mode 100644 templates/ci/modules/features/deadbugs/underscore-provides-spelling-merge.nix diff --git a/nix/lib/aspects/types.nix b/nix/lib/aspects/types.nix index 5822804cc..4be51e220 100644 --- a/nix/lib/aspects/types.nix +++ b/nix/lib/aspects/types.nix @@ -67,6 +67,26 @@ let # sites must share this predicate or naming and dedup silently desync. isSyntheticName = name: lib.hasPrefix "<" name && lib.hasSuffix ">" name; + # Fold a `_` write into `provides` so both spellings of one provides key + # are indistinguishable to every construction site from here on — the + # normalization root already gets for free from mkAliasOptionModule + # (aspectSubmodule's imports), applied by hand for the two sites that + # build their own provides view outside the module system. + # A `_` read back off another wrapper carries __functor (the read + # shorthand, not a write, e.g. `bar._ = otherAspect._;`) and must stay + # out of provides, matching aspectSubmodule's module-system alias, which + # only ever sees genuine definitions. + foldUnderscoreIntoProvides = + attrs: + let + u = if builtins.isAttrs attrs then attrs._ or null else null; + isWrite = builtins.isAttrs u && !(u ? __functor); + in + if isWrite then + (builtins.removeAttrs attrs [ "_" ]) // { provides = (attrs.provides or { }) // u; } + else + attrs; + aspectType = typeCfg: let @@ -243,12 +263,13 @@ let # Forward provides children and add _ alias so aspect._.child and # aspect.child both work, matching mergeWithAspectMeta behavior. let - providesChildren = builtins.removeAttrs (fn.provides or { }) [ "_module" ]; + normalizedFn = foldUnderscoreIntoProvides fn; + providesChildren = builtins.removeAttrs (normalizedFn.provides or { }) [ "_module" ]; classReg = den.classes or { }; pipeReg = den.quirks or { }; inherit (den.lib.aspects.fx.keyClassification) structuralKeysSet; forwardedSet = lib.genAttrs (builtins.attrNames providesChildren) (_: true); - result = providesChildren // fn; + result = providesChildren // normalizedFn; aspectName = fn.name or (lib.last loc); childKeys = builtins.filter ( k: @@ -495,13 +516,20 @@ let # value would discard the aspect and the includes with it, leaving only # the static half. A raw wrapper has no name and a converted one always # does, which is what tells them apart. - flatDefs = lib.concatMap ( - d: - if builtins.isAttrs d.value && d.value ? __contentValues && !(d.value ? name) then - d.value.__contentValues - else - [ { inherit (d) value file; } ] - ) defs; + # _ folded into provides here, once, before any per-key merge below + # sees them: both spellings become defs of the same key, so the + # existing multi-def merge (deepMerge — recurse attrsets, concat + # lists, last-wins on scalars) treats them exactly like two defs of + # `provides` itself, regardless of which spelling each file used. + flatDefs = map (d: d // { value = foldUnderscoreIntoProvides d.value; }) ( + lib.concatMap ( + d: + if builtins.isAttrs d.value && d.value ? __contentValues && !(d.value ? name) then + d.value.__contentValues + else + [ { inherit (d) value file; } ] + ) defs + ); # Merge attrset definition values per-key. Single-def keys are # forwarded directly; multi-def attrset keys get a __contentValues # wrapper so downstream consumers (emit-classes) collect all @@ -600,27 +628,22 @@ let # the rule that a name the wrapper defines itself keeps its own value # and stays classified. # `_` is the write alias for `provides`. aspectSubmodule wires it with - # mkAliasOptionModule, but a nested key never reaches that submodule: - # `_` is structural, so an alias write arrives here as a plain key and - # is then discarded by the `_` this wrapper publishes below. Fold it - # into the provides source so both spellings mean the same thing at - # every depth. A `_` read back off another wrapper carries __functor; - # that is the read shorthand, not a write, and stays out of provides. - writtenUnderscore = - let - v = merged._ or null; - in - lib.optionalAttrs (builtins.isAttrs v && !(v ? __functor)) v; - # Both spellings arrive as a content wrapper when the key is defined in - # more than one file, carrying `__contentValues` / `__aspectChain` / `_` - # alongside the real children. Those are wrapper machinery, not - # provides children: unfiltered they surface as `provides` keys, enter - # `__providesForwarded`, and `_` (not `__`-prefixed) registers an inert - # cross-provide policy. Filtering here covers `provides` and `_` at - # once, and the single-def path is unaffected because a raw attrset - # carries none of these keys. + # mkAliasOptionModule, but a nested key never reaches that submodule — + # `_` is structural, so an alias write would otherwise arrive here as + # a plain key of its own. foldUnderscoreIntoProvides already folded + # every `_` write into `provides` on flatDefs above, so `merged` never + # carries a `_` key for genuine writes and `merged.provides` alone is + # the complete source, at every depth. + # + # Both spellings arrive as a content wrapper when the key is defined + # in more than one file, carrying `__contentValues` / `__aspectChain` + # / `_` alongside the real children. Those are wrapper machinery, not + # provides children: unfiltered they surface as `provides` keys and + # enter `__providesForwarded`. Filtering here covers both, and the + # single-def path is unaffected because a raw attrset carries none of + # these keys. providesChildren = lib.filterAttrs (k: _: !(structuralKeysSet ? ${k}) && !(lib.hasPrefix "__" k)) ( - (merged.provides or { }) // writtenUnderscore + merged.provides or { } ); unshadowedProvides = builtins.filter (k: !(merged ? ${k})) (builtins.attrNames providesChildren); provider = (typeCfg.providerPrefix or [ ]) ++ [ keyName ]; diff --git a/templates/ci/modules/features/deadbugs/underscore-provides-spelling-merge.nix b/templates/ci/modules/features/deadbugs/underscore-provides-spelling-merge.nix new file mode 100644 index 000000000..b8f9d41e6 --- /dev/null +++ b/templates/ci/modules/features/deadbugs/underscore-provides-spelling-merge.nix @@ -0,0 +1,136 @@ +# `_` and `provides` are two spellings of one namespace, built at three +# separate sites in types.nix. Root already merges both spellings of one key +# (mkAliasOptionModule folds `_` into `provides` before the freeform type +# ever sees it); nested used to overwrite instead, and spelling-priority at +# that — `_` always won regardless of which file came first, so reordering +# never recovered the dropped definition. +# +# Each mixed-spelling test uses two separate `imports` fragments so the two +# definitions genuinely come from different files, matching how an author +# would actually split `provides.x` and `_.x` across modules — a single +# literal setting both keys never exercised the cross-file path. +# +# test-q4-nested-conflict-is-error is a guard, not a regression check: it +# asserts nested's genuine scalar conflict IS an error, which is false under +# the intended fix (unifying spelling only, not root's and nested's separate +# merge semantics) and must stay red. A green there means nested started +# erroring like root — a larger change than this file's own scope. +{ denTest, ... }: +{ + flake.tests.deadbugs.underscore-provides-spelling-merge = { + + test-o8-root-mixed-spellings-merge = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + imports = [ + { den.aspects.mixedRoot.provides.x.nixos.boot.kernelParams = [ "kpA" ]; } + { den.aspects.mixedRoot._.x.nixos.boot.kernelParams = [ "kpB" ]; } + ]; + + den.aspects.igloo.includes = [ den.aspects.mixedRoot.x ]; + + expr = { + hasA = builtins.elem "kpA" igloo.boot.kernelParams; + hasB = builtins.elem "kpB" igloo.boot.kernelParams; + }; + expected = { + hasA = true; + hasB = true; + }; + } + ); + + test-o8-root-mixed-spellings-merge-reversed = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + imports = [ + { den.aspects.mixedRootRev._.x.nixos.boot.kernelParams = [ "kpB" ]; } + { den.aspects.mixedRootRev.provides.x.nixos.boot.kernelParams = [ "kpA" ]; } + ]; + + den.aspects.igloo.includes = [ den.aspects.mixedRootRev.x ]; + + expr = { + hasA = builtins.elem "kpA" igloo.boot.kernelParams; + hasB = builtins.elem "kpB" igloo.boot.kernelParams; + }; + expected = { + hasA = true; + hasB = true; + }; + } + ); + + test-o8-nested-mixed-spellings-merge = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + imports = [ + { den.aspects.mixedNested.sub.provides.x.nixos.boot.kernelParams = [ "kpA" ]; } + { den.aspects.mixedNested.sub._.x.nixos.boot.kernelParams = [ "kpB" ]; } + ]; + + den.aspects.igloo.includes = [ den.aspects.mixedNested.sub.x ]; + + expr = { + hasA = builtins.elem "kpA" igloo.boot.kernelParams; + hasB = builtins.elem "kpB" igloo.boot.kernelParams; + }; + expected = { + hasA = true; + hasB = true; + }; + } + ); + + test-o8-nested-mixed-spellings-merge-reversed = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + imports = [ + { den.aspects.mixedNestedRev.sub._.x.nixos.boot.kernelParams = [ "kpB" ]; } + { den.aspects.mixedNestedRev.sub.provides.x.nixos.boot.kernelParams = [ "kpA" ]; } + ]; + + den.aspects.igloo.includes = [ den.aspects.mixedNestedRev.sub.x ]; + + expr = { + hasA = builtins.elem "kpA" igloo.boot.kernelParams; + hasB = builtins.elem "kpB" igloo.boot.kernelParams; + }; + expected = { + hasA = true; + hasB = true; + }; + } + ); + + # Guard — see file header. Must stay red. + test-q4-nested-conflict-is-error = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + imports = [ + { den.aspects.conflictNested.sub.provides.hn.nixos.networking.hostName = "hA"; } + { den.aspects.conflictNested.sub._.hn.nixos.networking.hostName = "hB"; } + ]; + + den.aspects.igloo.includes = [ den.aspects.conflictNested.sub.hn ]; + + expr = + let + attempt = builtins.tryEval igloo.networking.hostName; + in + if attempt.success then attempt.value else "ERROR"; + expected = "ERROR"; + } + ); + }; +} From 8ef892acef9169791b752f7550718512a63e62e0 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 8 Sep 2026 11:48:05 -0700 Subject: [PATCH 26/59] test: invert the nested-conflict guard to a green divergence pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test-q4-nested-conflict-is-error asserted nested's genuine scalar conflict IS an error and was designed to stay red — correct during review, but wrong to land: a collected cell that's expected to stay red breaks the "clean just ci means nothing's broken" property every other check here depends on. Replace it with test-q4-root-nested-conflict-diverges: a green cell that pins the same fact (root errors on conflict, nested last-wins) as an assertion instead of a permanent failure. Targets different NixOS options per position (networking.hostName vs time.timeZone) so root's raw conflicting defs can't also poison nested's already-collapsed value in the same host evaluation. --- .../underscore-provides-spelling-merge.nix | 45 +++++++++++++------ 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/templates/ci/modules/features/deadbugs/underscore-provides-spelling-merge.nix b/templates/ci/modules/features/deadbugs/underscore-provides-spelling-merge.nix index b8f9d41e6..8cb3c5065 100644 --- a/templates/ci/modules/features/deadbugs/underscore-provides-spelling-merge.nix +++ b/templates/ci/modules/features/deadbugs/underscore-provides-spelling-merge.nix @@ -10,11 +10,12 @@ # would actually split `provides.x` and `_.x` across modules — a single # literal setting both keys never exercised the cross-file path. # -# test-q4-nested-conflict-is-error is a guard, not a regression check: it -# asserts nested's genuine scalar conflict IS an error, which is false under -# the intended fix (unifying spelling only, not root's and nested's separate -# merge semantics) and must stay red. A green there means nested started -# erroring like root — a larger change than this file's own scope. +# test-q4-root-nested-conflict-diverges pins a fact, not a preference: root +# and nested deliberately still disagree on a genuine scalar conflict (root +# errors, nested last-wins) — closing that gap is the strong reading of O8 +# and out of scope for this task. It must read green; if it goes red, either +# position's conflict behaviour changed and that's a bigger change than this +# file's own scope. { denTest, ... }: { flake.tests.deadbugs.underscore-provides-spelling-merge = { @@ -111,25 +112,43 @@ } ); - # Guard — see file header. Must stay red. - test-q4-nested-conflict-is-error = denTest ( + # See file header. Root and nested target different options so one + # side's raw conflicting defs can't also poison the other's already- + # collapsed value in the same host evaluation. + test-q4-root-nested-conflict-diverges = denTest ( { den, igloo, ... }: { den.hosts.x86_64-linux.igloo.users.tux = { }; imports = [ - { den.aspects.conflictNested.sub.provides.hn.nixos.networking.hostName = "hA"; } - { den.aspects.conflictNested.sub._.hn.nixos.networking.hostName = "hB"; } + { den.aspects.conflictRoot.provides.hn.nixos.networking.hostName = "hA"; } + { den.aspects.conflictRoot._.hn.nixos.networking.hostName = "hB"; } + { den.aspects.conflictNested.sub.provides.tz.nixos.time.timeZone = "hA"; } + { den.aspects.conflictNested.sub._.tz.nixos.time.timeZone = "hB"; } ]; - den.aspects.igloo.includes = [ den.aspects.conflictNested.sub.hn ]; + den.aspects.igloo.includes = [ + den.aspects.conflictRoot.hn + den.aspects.conflictNested.sub.tz + ]; expr = let - attempt = builtins.tryEval igloo.networking.hostName; + tryOr = + v: + let + a = builtins.tryEval v; + in + if a.success then a.value else "ERROR"; in - if attempt.success then attempt.value else "ERROR"; - expected = "ERROR"; + { + root = tryOr igloo.networking.hostName; + nested = tryOr igloo.time.timeZone; + }; + expected = { + root = "ERROR"; + nested = "hB"; + }; } ); }; From 487c54ad6d6fd2de168e197d94a747f9daafd2ca Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 8 Sep 2026 12:22:15 -0700 Subject: [PATCH 27/59] fix: pin the mergeFunctions battery fix, correct root's no-op basis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Land a structural probe (providerType.merge called directly, no host eval) for the fix that lets mergeFunctions' battery branch forward a _ write on a __functor-carrying def. Falsified: reverting that one line drops direct/viaUnderscore/viaProvides all to false while the rest of the suite still passes, so nothing else pinned it. Correct foldUnderscoreIntoProvides' docstring: leaving root untouched isn't a no-op (measured false — helper-at-root flips the root conflict test from "ERROR" to "hB"), it's necessary because the helper is a // overwrite and would replace root's priority-preserving, conflict-detecting alias with the same spelling-priority behaviour this fold exists to fix at nested. --- nix/lib/aspects/types.nix | 13 ++++--- .../underscore-provides-spelling-merge.nix | 36 +++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/nix/lib/aspects/types.nix b/nix/lib/aspects/types.nix index 4be51e220..fd99558f7 100644 --- a/nix/lib/aspects/types.nix +++ b/nix/lib/aspects/types.nix @@ -68,10 +68,15 @@ let isSyntheticName = name: lib.hasPrefix "<" name && lib.hasSuffix ">" name; # Fold a `_` write into `provides` so both spellings of one provides key - # are indistinguishable to every construction site from here on — the - # normalization root already gets for free from mkAliasOptionModule - # (aspectSubmodule's imports), applied by hand for the two sites that - # build their own provides view outside the module system. + # are indistinguishable to the two sites that build their own provides + # view outside the module system. + # + # Root must NOT call this : it is a `//` overwrite, whereas root's alias + # (mkAliasOptionModule, in aspectSubmodule's imports) is priority-preserving + # and conflict-detecting. Wiring this helper in at root would replace root's + # genuine conflict error with the same spelling-priority overwrite this fold + # exists to fix at nested — a regression, not a no-op. + # # A `_` read back off another wrapper carries __functor (the read # shorthand, not a write, e.g. `bar._ = otherAspect._;`) and must stay # out of provides, matching aspectSubmodule's module-system alias, which diff --git a/templates/ci/modules/features/deadbugs/underscore-provides-spelling-merge.nix b/templates/ci/modules/features/deadbugs/underscore-provides-spelling-merge.nix index 8cb3c5065..1b111816f 100644 --- a/templates/ci/modules/features/deadbugs/underscore-provides-spelling-merge.nix +++ b/templates/ci/modules/features/deadbugs/underscore-provides-spelling-merge.nix @@ -151,5 +151,41 @@ }; } ); + + # mergeFunctions' battery branch (import-tree/forward-style attrsets + # carrying __functor) used to read only fn.provides and ignore fn._ + # outright — no host eval needed, this calls providerType.merge + # directly on a battery-shaped def to pin the forwarding itself. + test-q4-battery-underscore-write-forwards = denTest ( + { den, ... }: + let + merge = den.lib.aspects.types.providerType.merge; + battery = { + __functor = self: args: { }; + _.child.nixos.environment.etc."x".text = "y"; + }; + merged = + merge + [ "probe" ] + [ + { + file = ""; + value = battery; + } + ]; + in + { + expr = { + direct = merged ? child; + viaUnderscore = merged ? _ && merged._ ? child; + viaProvides = merged ? provides && merged.provides ? child; + }; + expected = { + direct = true; + viaUnderscore = true; + viaProvides = true; + }; + } + ); }; } From 776e3cf1656505f28ee285f15469859b44e1b279 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 8 Sep 2026 12:35:33 -0700 Subject: [PATCH 28/59] test: pin the battery underscore fold through a real aspect declaration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add test-rvb-consumer-battery-underscore alongside the existing direct providerType.merge probe: same three fields, declared via den.aspects.battHolder.provides.batt instead of a hand-minted def, so the pin survives a change in how a declaration reaches merge, not just the merge signature itself. Falsified against the same one-line revert — real red on both cells together. Also fixes a stray triple-space before the colon in the foldUnderscoreIntoProvides docstring (nixfmt does not reformat comment prose, so it survived the prior round's fmt pass unnoticed). --- nix/lib/aspects/types.nix | 2 +- .../underscore-provides-spelling-merge.nix | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/nix/lib/aspects/types.nix b/nix/lib/aspects/types.nix index fd99558f7..cbcb129ec 100644 --- a/nix/lib/aspects/types.nix +++ b/nix/lib/aspects/types.nix @@ -71,7 +71,7 @@ let # are indistinguishable to the two sites that build their own provides # view outside the module system. # - # Root must NOT call this : it is a `//` overwrite, whereas root's alias + # Root must NOT call this: it is a `//` overwrite, whereas root's alias # (mkAliasOptionModule, in aspectSubmodule's imports) is priority-preserving # and conflict-detecting. Wiring this helper in at root would replace root's # genuine conflict error with the same spelling-priority overwrite this fold diff --git a/templates/ci/modules/features/deadbugs/underscore-provides-spelling-merge.nix b/templates/ci/modules/features/deadbugs/underscore-provides-spelling-merge.nix index 1b111816f..38229453d 100644 --- a/templates/ci/modules/features/deadbugs/underscore-provides-spelling-merge.nix +++ b/templates/ci/modules/features/deadbugs/underscore-provides-spelling-merge.nix @@ -187,5 +187,31 @@ }; } ); + + # Same fields as the cell above, but through a real declaration + # (den.aspects.battHolder.provides.batt) rather than a hand-minted def + # handed straight to merge — pins the behaviour a consumer actually sees, + # not just the internal signature. + test-rvb-consumer-battery-underscore = denTest ( + { den, ... }: + { + den.aspects.battHolder.provides.batt = { + __functor = self: args: { }; + _.child.nixos.environment.etc."x".text = "y"; + }; + + expr = { + direct = den.aspects.battHolder.batt ? child; + viaUnderscore = den.aspects.battHolder.batt ? _ && den.aspects.battHolder.batt._ ? child; + viaProvides = + den.aspects.battHolder.batt ? provides && den.aspects.battHolder.batt.provides ? child; + }; + expected = { + direct = true; + viaUnderscore = true; + viaProvides = true; + }; + } + ); }; } From e544361319ac4d5e30ccd30fc305c7254c747cc0 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 8 Sep 2026 12:50:10 -0700 Subject: [PATCH 29/59] refactor: split providerPrefix into origin (seed) and chain (accumulation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit typeCfg.providerPrefix conflated a container's fixed seed (den.aspects [] den.ful [name], den.batteries ["den" "batteries"]) with the per-level accumulated definition chain. The two coincide at a container root, which made the conflation invisible anywhere anyone would test it. Split into `origin` (list, stated once per container, never accumulated) and `chain` (list | null, threaded definition chain; null keeps its present meaning of "absent, fill from the walk"). Consumers read `typeCfg.chain or typeCfg.origin` — `or` fires on a missing key, not a present null, so the null sentinel used by options.includes is preserved. `origin` deliberately mirrors gen-aspects' own `providerPrefix` field (bound to `origin` there), easing the eventual den-on-gen rewrite; `chain` names the accumulating half gen has no equivalent for. Pure key rename at the three declaration sites and two CI fixture files — zero value changes. --- modules/aspects/batteries.nix | 2 +- nix/lib/aspects/default.nix | 2 +- nix/lib/aspects/types.nix | 35 ++++++++++--------- nix/lib/namespace-types.nix | 2 +- .../internal-api/aspect-content-type.nix | 6 ++-- .../modules/internal-api/aspect-key-type.nix | 8 ++--- 6 files changed, 28 insertions(+), 27 deletions(-) diff --git a/modules/aspects/batteries.nix b/modules/aspects/batteries.nix index abbc8941c..5b1ecc85b 100644 --- a/modules/aspects/batteries.nix +++ b/modules/aspects/batteries.nix @@ -13,7 +13,7 @@ type = lib.types.submodule { freeformType = lib.types.attrsOf ( (config.den.lib.aspects.mkAspectsType { - providerPrefix = [ + origin = [ "den" "batteries" ]; diff --git a/nix/lib/aspects/default.nix b/nix/lib/aspects/default.nix index e90b39960..dd6b49b4e 100644 --- a/nix/lib/aspects/default.nix +++ b/nix/lib/aspects/default.nix @@ -99,7 +99,7 @@ let self = wrapped; }; - types = lib.mapAttrs (_: v: v { }) rawTypes; + types = lib.mapAttrs (_: v: v { origin = [ ]; }) rawTypes; in { inherit diff --git a/nix/lib/aspects/types.nix b/nix/lib/aspects/types.nix index cbcb129ec..3e404ffd9 100644 --- a/nix/lib/aspects/types.nix +++ b/nix/lib/aspects/types.nix @@ -205,11 +205,12 @@ let # aspect through re-inclusion the way meta.loc does not — nixpkgs # drops a mkDefault def entirely once any normal-priority def exists, # so this only ever supplies the value when nothing else does. - # typeCfg carries a providerPrefix for root/container declarations - # (`or [ ]` yields [ ]); includes elements get providerPrefix - # explicitly nulled (aspectSubmodule), so the present-but-null key - # bypasses `or` and this yields null there — absence, not root. - meta.aspect-chain = lib.mkDefault (typeCfg.providerPrefix or [ ]); + # typeCfg.origin is the container's fixed seed; typeCfg.chain is the + # threaded definition chain, explicitly nulled for includes elements + # (aspectSubmodule). `or` only falls back on a MISSING key, so a + # present-but-null chain bypasses it and this yields null there — + # absence, not root. + meta.aspect-chain = lib.mkDefault (typeCfg.chain or typeCfg.origin); }; # A parametric function reaching aspectSubmodule.merge is evaluated as a NixOS @@ -305,7 +306,7 @@ let { name = nameFromLoc; meta = { - aspect-chain = typeCfg.providerPrefix or [ ]; + aspect-chain = typeCfg.chain or typeCfg.origin; }; __fn = fn; __args = args; @@ -604,7 +605,7 @@ let bv ) b; subForwarded = builtins.foldl' deepMerge { } subAttrVals; - provBase = (typeCfg.providerPrefix or [ ]) ++ [ + provBase = (typeCfg.chain or typeCfg.origin) ++ [ keyName k ]; @@ -651,7 +652,7 @@ let merged.provides or { } ); unshadowedProvides = builtins.filter (k: !(merged ? ${k})) (builtins.attrNames providesChildren); - provider = (typeCfg.providerPrefix or [ ]) ++ [ keyName ]; + provider = (typeCfg.chain or typeCfg.origin) ++ [ keyName ]; # A key names a candidate child aspect when it is neither structural, # internal, class nor pipe. Provides children are reached through # `provides`/`_`, which are structural — ._ never collects them. @@ -816,7 +817,7 @@ let { name, config, ... }: let # The chain this aspect's children hang off. `meta.aspect-chain` defaults to - # `typeCfg.providerPrefix`, but providerType.merge overrides it when it + # `typeCfg.chain or typeCfg.origin`, but providerType.merge overrides it when it # re-types an included nested aspect (wrapperToAspect injects the chain # from __aspectChain). Reading the static typeCfg there truncates the chain # to the aspect's own name, so `alpha/tools` and `beta/tools` both hand @@ -835,7 +836,7 @@ let aspectKeyType ( typeCfg // { - providerPrefix = childProviderPrefix; + chain = childProviderPrefix; } ) ); @@ -866,12 +867,12 @@ let }; includes = lib.mkOption { description = "Providers to ask aspects from"; - # providerPrefix explicitly null (not omitted): `or [ ]` only - # falls back on a genuinely MISSING key, so a present-but-null - # key still yields null through aspectMeta's default. That is - # what makes an inline includes literal's chain read as - # "unknown" rather than silently defaulting to root's [ ]. - type = lib.types.listOf (providerType (typeCfg // { providerPrefix = null; })); + # chain explicitly null (not omitted): `or origin` only falls + # back on a genuinely MISSING key, so a present-but-null chain + # still yields null through aspectMeta's default. That is what + # makes an inline includes literal's chain read as "unknown" + # rather than silently defaulting to the container's origin. + type = lib.types.listOf (providerType (typeCfg // { chain = null; })); default = [ ]; }; excludes = lib.mkOption { @@ -887,7 +888,7 @@ let providerType ( typeCfg // { - providerPrefix = childProviderPrefix; + chain = childProviderPrefix; } ) ); diff --git a/nix/lib/namespace-types.nix b/nix/lib/namespace-types.nix index 94ed42afb..7c62a84bf 100644 --- a/nix/lib/namespace-types.nix +++ b/nix/lib/namespace-types.nix @@ -44,7 +44,7 @@ let ); }; }; - freeformType = (mkAspectsType { providerPrefix = [ name ]; }).aspectsType; + freeformType = (mkAspectsType { origin = [ name ]; }).aspectsType; } ); in diff --git a/templates/ci/modules/internal-api/aspect-content-type.nix b/templates/ci/modules/internal-api/aspect-content-type.nix index 9f0c2e3e4..602580b75 100644 --- a/templates/ci/modules/internal-api/aspect-content-type.nix +++ b/templates/ci/modules/internal-api/aspect-content-type.nix @@ -133,7 +133,7 @@ in test-content-wrapper-shape = denTest ( { den, ... }: let - contentType = (den.lib.aspects.mkAspectsType { providerPrefix = [ "test" ]; }).aspectContentType; + contentType = (den.lib.aspects.mkAspectsType { origin = [ "test" ]; }).aspectContentType; evaluated = lib.evalModules { modules = [ { freeformType = lib.types.lazyAttrsOf contentType; } @@ -165,7 +165,7 @@ in test-multi-site-merge = denTest ( { den, ... }: let - contentType = (den.lib.aspects.mkAspectsType { providerPrefix = [ ]; }).aspectContentType; + contentType = (den.lib.aspects.mkAspectsType { origin = [ ]; }).aspectContentType; evaluated = lib.evalModules { modules = [ { freeformType = lib.types.lazyAttrsOf contentType; } @@ -194,7 +194,7 @@ in test-function-value = denTest ( { den, ... }: let - contentType = (den.lib.aspects.mkAspectsType { providerPrefix = [ ]; }).aspectContentType; + contentType = (den.lib.aspects.mkAspectsType { origin = [ ]; }).aspectContentType; evaluated = lib.evalModules { modules = [ { freeformType = lib.types.lazyAttrsOf contentType; } diff --git a/templates/ci/modules/internal-api/aspect-key-type.nix b/templates/ci/modules/internal-api/aspect-key-type.nix index 595401fa1..0ae385617 100644 --- a/templates/ci/modules/internal-api/aspect-key-type.nix +++ b/templates/ci/modules/internal-api/aspect-key-type.nix @@ -59,7 +59,7 @@ in test-class-key-shape = denTest ( { den, ... }: let - types = den.lib.aspects.mkAspectsType { providerPrefix = [ ]; }; + types = den.lib.aspects.mkAspectsType { origin = [ ]; }; evaluated = lib.evalModules { modules = [ { freeformType = lib.types.lazyAttrsOf types.aspectKeyType; } @@ -95,7 +95,7 @@ in test-unregistered-key-shape = denTest ( { den, ... }: let - types = den.lib.aspects.mkAspectsType { providerPrefix = [ ]; }; + types = den.lib.aspects.mkAspectsType { origin = [ ]; }; evaluated = lib.evalModules { modules = [ { freeformType = lib.types.lazyAttrsOf types.aspectKeyType; } @@ -128,7 +128,7 @@ in test-parametric-unregistered-key-shape = denTest ( { den, ... }: let - types = den.lib.aspects.mkAspectsType { providerPrefix = [ ]; }; + types = den.lib.aspects.mkAspectsType { origin = [ ]; }; evaluated = lib.evalModules { modules = [ { freeformType = lib.types.lazyAttrsOf types.aspectKeyType; } @@ -160,7 +160,7 @@ in test-multi-def-class-key = denTest ( { den, ... }: let - types = den.lib.aspects.mkAspectsType { providerPrefix = [ ]; }; + types = den.lib.aspects.mkAspectsType { origin = [ ]; }; evaluated = lib.evalModules { modules = [ { freeformType = lib.types.lazyAttrsOf types.aspectKeyType; } From 4000f443e218c930a3760106c0bed204deeb8c40 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 8 Sep 2026 12:50:23 -0700 Subject: [PATCH 30/59] test: pin battery and namespace root chains directly No cell in the corpus asserted a battery or namespace meta.aspect-chain literal, so O4/O5 of the origin/chain split rested on suite totals alone. Add two cells in the shape of this file's existing direct meta.aspect-chain read: a battery root's chain is ["den" "batteries"], a namespace aspect's chain is []. Green today, so a regression in either origin seed shows up as a real red instead of a passing total. --- .../internal-api/aspect-chain-doubling.nix | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/templates/ci/modules/internal-api/aspect-chain-doubling.nix b/templates/ci/modules/internal-api/aspect-chain-doubling.nix index e1858a4d7..58f914ba3 100644 --- a/templates/ci/modules/internal-api/aspect-chain-doubling.nix +++ b/templates/ci/modules/internal-api/aspect-chain-doubling.nix @@ -36,5 +36,36 @@ } ); + # Container roots other than den.aspects declare their own `origin` seed + # (batteries.nix, namespace-types.nix) rather than inheriting the [ ] + # default. This pins those two seeds directly rather than relying on + # suite totals, so a regression here is caught even though no other + # cell in the corpus asserts a battery or namespace chain literal. + test-battery-root-chain = denTest ( + { den, ... }: + { + expr = den.batteries.hostname.meta.aspect-chain or [ ]; + expected = [ + "den" + "batteries" + ]; + } + ); + + test-namespace-root-chain = denTest ( + { + inputs, + ns, + ... + }: + { + imports = [ (inputs.den.namespace "ns" false) ]; + ns.probe.nixos.environment.etc."t".text = "y"; + + expr = ns.probe.meta.aspect-chain or [ ]; + expected = [ "ns" ]; + } + ); + }; } From d74c4f480f9f13947d18caeec192ef90b9d6aa17 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 8 Sep 2026 14:07:21 -0700 Subject: [PATCH 31/59] fix: replace mkParametricBase's explicit whitelist with structural carry-forward The whitelist (name, meta, into, provides, __walkStamped) has to be hand-extended for every new structural marker or it silently vanishes mid-pipeline; __walkStamped was dropped this way earlier this week. Carry forward every structural key present on the aspect except the ones the parametric round-trip itself re-derives (name/meta handled explicitly here; __fn/__args/__scopeHandlers/__ctxId/__parametricResolvedArgs rebuilt by mkParametricNext/tagParametricResult; __functor/__functionArgs already normalized away; __aspectChain/__providesForwarded re-derived by the walk on re-entry). This also fixes a live silent drop: a parametric wrapper's sibling `includes` (preserved by normalize.nix's wrapFunctorChild) were rebuilt away by mkParametricBase whenever the invoked function's result didn't carry its own `includes`. Pinned by the new deadbugs cell. --- nix/lib/aspects/fx/aspect.nix | 33 ++++++++++++++--- .../deadbugs/parametric-sibling-includes.nix | 35 +++++++++++++++++++ 2 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 templates/ci/modules/features/deadbugs/parametric-sibling-includes.nix diff --git a/nix/lib/aspects/fx/aspect.nix b/nix/lib/aspects/fx/aspect.nix index e6204fe80..41d80987d 100644 --- a/nix/lib/aspects/fx/aspect.nix +++ b/nix/lib/aspects/fx/aspect.nix @@ -33,7 +33,34 @@ let # --- Parametric resolution --- - # Build the base attrset for a parametric resolution result. + # Structural keys the parametric round-trip itself re-derives, so a general + # carry-forward must not copy them from the pre-resolution `aspect`: + # name/meta are handled explicitly below; __fn/__args/__scopeHandlers/ + # __ctxId/__parametricResolvedArgs are rebuilt by mkParametricNext / + # tagParametricResult from `resolved` (or `aspect` directly); __functor/ + # __functionArgs are function-representation machinery already normalized + # into __fn/__args before an aspect reaches here; __aspectChain and + # __providesForwarded are identity/classification state the walk re-derives + # when the result re-enters `resolve` — carrying a stale copy forward risks + # reintroducing a doubled identity. + parametricOwnedKeysSet = lib.genAttrs [ + "name" + "meta" + "__fn" + "__args" + "__functor" + "__functionArgs" + "__scopeHandlers" + "__ctxId" + "__parametricResolvedArgs" + "__aspectChain" + "__providesForwarded" + ] (_: true); + + # Build the base attrset for a parametric resolution result. Every other + # structural key present on `aspect` (includes, provides, into, + # __walkStamped, ...) survives the rebuild unchanged unless the resolved + # value overrides it downstream in mkParametricNext. mkParametricBase = aspect: resolved: { @@ -46,9 +73,7 @@ let fnArgNames = builtins.attrNames (aspect.__args or { }); }; } - // lib.optionalAttrs (aspect ? into) { inherit (aspect) into; } - // lib.optionalAttrs (aspect ? provides) { inherit (aspect) provides; } - // lib.optionalAttrs (aspect ? __walkStamped) { inherit (aspect) __walkStamped; }; + // lib.filterAttrs (k: _: (structuralKeysSet ? ${k}) && !(parametricOwnedKeysSet ? ${k})) aspect; # Merge the resolved value into the parametric base. mkParametricNext = diff --git a/templates/ci/modules/features/deadbugs/parametric-sibling-includes.nix b/templates/ci/modules/features/deadbugs/parametric-sibling-includes.nix new file mode 100644 index 000000000..a2097b7c4 --- /dev/null +++ b/templates/ci/modules/features/deadbugs/parametric-sibling-includes.nix @@ -0,0 +1,35 @@ +# mkParametricBase (nix/lib/aspects/fx/aspect.nix) rebuilt a parametric-resolved +# aspect from an explicit carry-forward whitelist (name, meta, into, provides, +# __walkStamped) instead of merging structural keys onto the original, so a +# sibling `includes` declared alongside a functor-shaped aspect body — already +# preserved through normalize.nix's wrapFunctorChild — was silently dropped the +# moment the aspect went through parametric resolution. No existing cell pinned +# this; it was only found by survey. +{ denTest, ... }: +{ + flake.tests.deadbugs.parametric-sibling-includes = { + + test-parametric-sibling-includes-survive-resolution = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.marker.nixos.networking.hostName = "marker-fired"; + + # A single functor-shaped definition: __fn/__args come from __functor, + # `includes` sits as a sibling key on the same attrset rather than + # inside the function's returned value. + den.aspects.foo = { + __functor = _self: { host, ... }: { }; + includes = [ den.aspects.marker ]; + }; + + den.aspects.igloo.includes = [ den.aspects.foo ]; + + expr = igloo.networking.hostName; + expected = "marker-fired"; + } + ); + + }; +} From dec61330690c797f3a8261a1ca471799a9f6cc2c Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 8 Sep 2026 14:28:26 -0700 Subject: [PATCH 32/59] fix: retire the delivery-edge equivalence oracle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Task-17/18.2 equivalence proof between materializeUnified and the old phase2(provides)∘phase3(routes) pipeline is finished. Delete the oracle arm: unifiedEdges, legacyEdgeTrace, materializeEquiv (resolve.nix), edges/parity.nix, and the five suites that forced them (fx-materialize-unified, fx-unified-edges, fx-edge-parity, fx-edge-unification-gate, fx-oracle-production-differential). A retired v1 phase-fold that still executes would otherwise still appear in den's live trace results the gen rewrite is built against. --- nix/lib/aspects/fx/default.nix | 1 - nix/lib/aspects/fx/edges/parity.nix | 25 -- nix/lib/aspects/fx/resolve.nix | 133 ------ .../modules/internal-api/fx-edge-parity.nix | 220 ---------- .../internal-api/fx-edge-unification-gate.nix | 410 ------------------ .../internal-api/fx-materialize-unified.nix | 381 ---------------- .../fx-oracle-production-differential.nix | 231 ---------- .../modules/internal-api/fx-unified-edges.nix | 237 ---------- 8 files changed, 1638 deletions(-) delete mode 100644 nix/lib/aspects/fx/edges/parity.nix delete mode 100644 templates/ci/modules/internal-api/fx-edge-parity.nix delete mode 100644 templates/ci/modules/internal-api/fx-edge-unification-gate.nix delete mode 100644 templates/ci/modules/internal-api/fx-materialize-unified.nix delete mode 100644 templates/ci/modules/internal-api/fx-oracle-production-differential.nix delete mode 100644 templates/ci/modules/internal-api/fx-unified-edges.nix diff --git a/nix/lib/aspects/fx/default.nix b/nix/lib/aspects/fx/default.nix index a6b6098d9..395da58f5 100644 --- a/nix/lib/aspects/fx/default.nix +++ b/nix/lib/aspects/fx/default.nix @@ -18,7 +18,6 @@ edgeTrace = import ./edge-trace.nix { inherit lib den; }; edges = { edge = import ./edges/edge.nix { inherit lib; }; - parity = import ./edges/parity.nix { inherit lib; }; pi = import ./edges/pi.nix { inherit lib; }; toposort = import ./edges/toposort.nix { inherit lib; }; materialize = import ./edges/materialize.nix { inherit lib den; }; diff --git a/nix/lib/aspects/fx/edges/parity.nix b/nix/lib/aspects/fx/edges/parity.nix deleted file mode 100644 index 6c1c28aad..000000000 --- a/nix/lib/aspects/fx/edges/parity.nix +++ /dev/null @@ -1,25 +0,0 @@ -{ lib }: -let - inherit (import ./edge.nix { inherit lib; }) edgeSortKey; -in -{ - # assertEdgeParity — the cross-pipeline parity diff. Diffs two delivery-edge - # traces by normalized identity key (T,P,S,M; annotations EXCLUDED — the parity - # contract is STRUCTURAL, spec §4). Returns matched + the asymmetric differences - # + a boolean. The §5.1 deviation classification (bug-in-hoag | bug-in-v1 | - # intentional-v2) is a HUMAN step over this diff (parity/edge-schema.md runbook), - # not automated here. - assertEdgeParity = - { expected, actual }: - let - keyOf = edgeSortKey; - expKeys = lib.genAttrs (map keyOf expected) (_: true); - actKeys = lib.genAttrs (map keyOf actual) (_: true); - in - rec { - matched = lib.filter (e: actKeys ? ${keyOf e}) expected; - missingFromActual = lib.filter (e: !(actKeys ? ${keyOf e})) expected; - extraInActual = lib.filter (e: !(expKeys ? ${keyOf e})) actual; - parity = missingFromActual == [ ] && extraInActual == [ ]; - }; -} diff --git a/nix/lib/aspects/fx/resolve.nix b/nix/lib/aspects/fx/resolve.nix index dfe66dee1..afd5c6697 100644 --- a/nix/lib/aspects/fx/resolve.nix +++ b/nix/lib/aspects/fx/resolve.nix @@ -1042,139 +1042,6 @@ let inherit scopeContexts scopeEntityKind; # The production edge object (see productionEdgeTrace above). edgeTrace = productionEdgeTrace; - # One representation: unifiedEdges is an alias for the production edgeTrace. - unifiedEdges = productionEdgeTrace; - # The LEGACY end-state re-derivation (edge-trace.nix), WITH its `spawnEdges` - # rewalk arm (the spawn undercount). Kept as a distinct field so the - # differential suites can diff the production object against it. Nix attrs - # are lazy, so this is a thunk — forced only by the differential suites / - # debug inspection, never by normal resolve consumers. - legacyEdgeTrace = 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; - }; - - # The Task-17 equivalence surface: BOTH the current phase2∘phase3 result AND - # the materializeUnified result over the SAME live seed (phase1) + the SAME - # provides/routes/spawn inputs the production phase folds consume. A lazy - # thunk (like edgeTrace / unifiedEdges) — forced only by the - # fx-materialize-unified suite, never by normal resolve consumers. This is the - # byte-equivalence proof for the ordered-dispatch engine: the suite deep- - # compares `.phaseFold` to `.unified` per topology. Not consumed by production. - materializeEquiv = - let - piTop = pi; - unifiedInputs = { - inherit pi; - seed = phase1; - inherit - ctx - scopedProvides - scopedRoutes - spawnNode - ; - inherit (handlers) buildForwardAspect; - }; - # The OLD production path (phase2 provides ∘ phase3 routes), recomputed - # locally over the SAME live seed. Production now folds materializeUnified - # directly (above), so this independent recomputation is the equivalence - # ORACLE the fx-materialize-unified suite deep-compares against `.unified`. - oraclePhase2 = applyProvidesEdges ctx scopedProvides phase1; - oraclePhase3 = - applyRoutes spawnNode ctx augmentedScopeContexts result.state.rootScopeId scopeParent scopeIsolated - scopeEntityKind - scopedRoutes - oraclePhase2; - in - let - # The production dispatch order: ALL provides (dedup order) THEN ALL kept - # routes (orderedKeptRoutes order) — the phase2∘phase3 sequence. - provideId = - spec: - "provide:${spec.__providePolicyName or ""}/${spec.class}/${ - lib.concatStringsSep "/" (spec.path or [ ]) - }"; - routeId = - spec: - "route:${spec.fromClass or "?"}>${spec.intoClass or "?"}@${spec.sourceScopeId or "?"}/${ - lib.concatStringsSep "/" (spec.path or [ ]) - }${lib.optionalString (spec.__complexForward or false) "#complex"}"; - dispatchId = d: if d.kind == "provide" then provideId d.spec else routeId d.spec; - orderedProvideSpecs = dedupProvides (lib.concatLists (lib.attrValues scopedProvides)); - orderedRouteSpecs = routeEdges.orderedKeptRoutes result.state.rootScopeId ( - lib.concatLists (lib.attrValues scopedRoutes) - ); - # Production dispatch: all provides (dedup order) then all kept routes. - phaseFoldDispatch = (map provideId orderedProvideSpecs) ++ (map routeId orderedRouteSpecs); - # The unified engine's dispatch order via the SAME identity functions. - unifiedDispatch = map dispatchId (materializeUnified unifiedInputs { exposeDispatch = true; }); - - # Task-18 capture: the SAME no-merge call with exposeEdges = true. The - # accumulator is byte-identical to `unified` (the `// { edges; }` only - # adds the capture key), and `.edges` carries the folded trace edges the - # fold dispatched. The suite proves capture fidelity by comparing these - # to the constructor-built provides+route edges over the same inputs. - unifiedWithEdges = materializeUnified unifiedInputs { - doFinalMerge = false; - exposeEdges = true; - }; - # The constructor-built oracle: provides trace edges (dedup order) ++ - # route trace edges (kept+ordered), the SAME edges materializeUnified - # builds internally before its toposort. Same SET as `.edges`, so a - # sort-key comparison proves capture fidelity. - piRoot = result.state.rootScopeId; - edgeName = scopeName { - scopeEntityKind = pi.scopeEntityKind or { }; - inherit (pi) scopeContexts; - }; - oracleEdges = - providesEdges { - name = edgeName; - inherit scopedProvides; - } - ++ routeEdges.routeEdges { - name = edgeName; - inherit (pi) scopeParent; - rootScopeId = piRoot; - rawRoutes = routeEdges.orderedKeptRoutes piRoot (lib.concatLists (lib.attrValues scopedRoutes)); - }; - in - { - inherit phaseFoldDispatch unifiedDispatch; - # phase2 ∘ phase3 over the live seed (the production order: all provides - # then all routes) — recomputed by the local oracle, since production now - # folds materializeUnified directly. - phaseFold = oraclePhase3; - # materializeUnified over the SAME seed, doFinalMerge = false (returns the - # raw accumulator, byte-comparable to phaseFold). This is the SAME call - # production uses (`materialized`). - unified = materializeUnified unifiedInputs { doFinalMerge = false; }; - # Task-18 edge capture surface: the folded edges (with their accumulator) - # and the constructor-built oracle, for the fx-materialize-unified proof. - inherit unifiedWithEdges oracleEdges; - # The doFinalMerge = true variant, comparable to assembleSubtree over the - # phaseFold result (the final-extraction merge step, unchanged). - unifiedMerged = materializeUnified unifiedInputs { doFinalMerge = true; }; - phaseFoldMerged = assembleSubtree { - root = result.state.rootScopeId; - pi = piTop // { - perScope = oraclePhase3.perScope; - classImports = oraclePhase3.classImports; - provides = scopedProvides; - routes = scopedRoutes; - }; - }; - }; }; # Back-compatible projection: imports only. Protects deferredModule consumers diff --git a/templates/ci/modules/internal-api/fx-edge-parity.nix b/templates/ci/modules/internal-api/fx-edge-parity.nix deleted file mode 100644 index 23538f96a..000000000 --- a/templates/ci/modules/internal-api/fx-edge-parity.nix +++ /dev/null @@ -1,220 +0,0 @@ -# fx-edge-parity — the Task 19 cross-pipeline parity gate. Exercises the -# `assertEdgeParity` helper (nix/lib/aspects/fx/edges/parity.nix) over a corpus of -# the parity-critical delivery-edge topologies (spawn, instantiate/fleet, -# isolated-guest, plain host+user — the same topologies the unification gate and -# the oracle-production differential cover). -# -# Two flavours of assertion: -# -# (1) IDENTITY GATE (per corpus topology) — diff a trace against ITSELF. A trace -# is trivially parity-equal to itself, so this is NOT testing edge logic; it -# proves three things at once: -# - the harness is sound (a self-diff yields parity == true, empty deltas); -# - the corpus topologies resolve (r.edgeTrace evaluates); -# - each trace is NON-EMPTY (matched != [] → there is real content to diff, -# so the gate is not vacuously green on an empty trace). -# -# (2) NEGATIVE CONTROL — on a spawn topology, diff the production `edgeTrace` -# against the legacy `legacyEdgeTrace` (the rewalk + suppressed-twin -# re-derivation). These genuinely diverge (the spawn rewalk arm vs the real -# surfaced fold edges), so `parity == false`. This proves `assertEdgeParity` -# actually DETECTS divergence — without it, the identity gate alone could pass -# on a helper that always returns parity == true. -# -# `just ci fx-edge-parity` runs this suite. -{ denTest, lib, ... }: -let - # The fleet → hosts include policy shared by the spawn + instantiate topologies - # (verbatim from fx-edge-unification-gate.nix): a flake-level resolve that fans - # out to each host with an instantiate spec. - fleetSetup = den: lib: { - 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 - ]; - }; - - # The identity-gate assertion, shared by every corpus topology. `edges` is the - # trace under test; a trace is parity-equal to itself with empty asymmetric - # deltas, and matched must be non-empty (proving the trace carries content). - identityGate = - den: edges: - let - diff = den.lib.aspects.fx.edges.parity.assertEdgeParity { - expected = edges; - actual = edges; - }; - in - { - parity = diff.parity; - matchedNonEmpty = diff.matched != [ ]; - noMissing = diff.missingFromActual == [ ]; - noExtra = diff.extraInActual == [ ]; - }; - - identityExpected = { - parity = true; - matchedNonEmpty = true; - noMissing = true; - noExtra = true; - }; -in -{ - flake.tests.fx-edge-parity = { - - # ===== IDENTITY GATE: SPAWN topology (flake-level, host-aspects battery) == - test-identity-spawn = denTest ( - { den, lib, ... }: - let - r = den.lib.aspects.resolveWithPaths "flake" (den.lib.resolveEntity "flake" { }); - in - fleetSetup den lib - // { - den.hosts.x86_64-linux.igloo.users.tux = { }; - den.aspects.igloo.homeManager.home.sessionVariables.X = "y"; - den.aspects.tux.includes = [ den.batteries.host-aspects ]; - den.aspects.igloo.nixos.networking.hostName = "igloo"; - - expr = identityGate den r.edgeTrace; - expected = identityExpected; - } - ); - - # ===== IDENTITY GATE: INSTANTIATE / fleet topology (flake-level) ========== - test-identity-instantiate = denTest ( - { den, lib, ... }: - let - r = den.lib.aspects.resolveWithPaths "flake" (den.lib.resolveEntity "flake" { }); - in - fleetSetup den lib - // { - den.hosts.x86_64-linux.igloo.users.tux = { }; - den.aspects.igloo.nixos.networking.hostName = "igloo"; - - expr = identityGate den r.edgeTrace; - expected = identityExpected; - } - ); - - # ===== IDENTITY GATE: ISOLATED-GUEST topology (host-level route) ========== - test-identity-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" - ]; - }) - ] - ); - r = den.lib.aspects.resolveWithPaths "nixos" ( - den.lib.resolveEntity "host" { host = den.hosts.x86_64-linux.igloo; } - ); - 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 = identityGate den r.edgeTrace; - expected = identityExpected; - } - ); - - # ===== IDENTITY GATE: PLAIN host+user (no spawn, host-level) ============== - test-identity-plain = denTest ( - { den, ... }: - let - r = den.lib.aspects.resolveWithPaths "nixos" ( - den.lib.resolveEntity "host" { host = den.hosts.x86_64-linux.igloo; } - ); - in - { - den.hosts.x86_64-linux.igloo.users.tux = { }; - den.aspects.igloo.nixos.networking.hostName = "igloo"; - - expr = identityGate den r.edgeTrace; - expected = identityExpected; - } - ); - - # ===== NEGATIVE CONTROL: spawn production vs legacy diverges ============== - # Diffing the production edgeTrace against the legacy legacyEdgeTrace on a spawn - # topology MUST yield parity == false (the rewalk arm + suppressed twins vs the - # real surfaced fold edges) — proving the helper detects divergence and the - # identity gate is not vacuous. - test-negative-control-spawn = denTest ( - { den, lib, ... }: - let - r = den.lib.aspects.resolveWithPaths "flake" (den.lib.resolveEntity "flake" { }); - diff = den.lib.aspects.fx.edges.parity.assertEdgeParity { - expected = r.edgeTrace; - actual = r.legacyEdgeTrace; - }; - in - fleetSetup den lib - // { - den.hosts.x86_64-linux.igloo.users.tux = { }; - den.aspects.igloo.homeManager.home.sessionVariables.X = "y"; - den.aspects.tux.includes = [ den.batteries.host-aspects ]; - den.aspects.igloo.nixos.networking.hostName = "igloo"; - - expr = diff.parity; - expected = false; - } - ); - }; -} diff --git a/templates/ci/modules/internal-api/fx-edge-unification-gate.nix b/templates/ci/modules/internal-api/fx-edge-unification-gate.nix deleted file mode 100644 index 9573b57d5..000000000 --- a/templates/ci/modules/internal-api/fx-edge-unification-gate.nix +++ /dev/null @@ -1,410 +0,0 @@ -# fx-edge-unification-gate — the lighter Task-16 gate. For the parity-critical -# topologies it proves the unified delivery-edge set is BOTH complete and validly -# orderable, and that the shared toposort entry is loud on a real cycle: -# -# (1) COMPLETENESS — unifiedEdges ⊇ (edgeTrace MINUS its rewalk-source edges), -# i.e. every non-rewalk oracle edge survives, PLUS the newly-surfaced edges -# (the spawn route/default-fold edges for spawn topologies; the per-host -# default-fold + route edges for instantiate topologies). -# -# (2) VALID ORDER — `topoSortEdges unifiedEdges` SUCCEEDS (does not throw → the -# unified set is acyclic and every dep is satisfiable) AND is a permutation -# of unifiedEdges (identical edge multiset by the normalized sort key). Plus -# a producer-before-merge spot-check: a route/provides producer edge appears -# at a SMALLER index than the default-fold merge edge of the same class+root -# that reads it. -# -# (3) CYCLE THROWS — a deliberately-cyclic edge set (a synthesize 2-cycle, as in -# fx-toposort-edges.nix) passed through the SAME `topoSortEdges` entry the -# unified set uses THROWS the loud cycle error. -# -# `unifiedEdges` is reached the way fx-unified-edges.nix reaches it: it sits beside -# `edgeTrace` on the resolveWithPaths result. The spawn + instantiate topologies -# resolve at FLAKE level (the drain-fold spawn + mkInstantiateEdges projections -# only surface there — at host level the host is the ctx-seeded root, so those arms -# are no-ops, spec 16.3). -# -# `just ci fx-edge-unification-gate` runs this suite. -{ denTest, lib, ... }: -let - # Stable sort key mirroring edges/edge.nix edgeSortKey (T, P, S, M), so two edge - # lists are compared as normalized MULTISETS regardless of construction order. - 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"; - edgeKey = - e: - lib.concatStringsSep " | " [ - (targetKey e.target) - (pathKey e.path) - (sourceKey e.source) - e.mode - ]; - - # Completeness: every edge in `sub` is present in `super` (by normalized key). - keySet = edges: lib.genAttrs (map edgeKey edges) (_: true); - isSubset = sub: super: lib.all (e: (keySet super) ? ${edgeKey e}) sub; - - # Multiset equality by sorted key lists (a permutation has the same edges in any - # order). Counts duplicates correctly (sorted-list compare, not set compare). - sameMultiset = - a: b: lib.sort (x: y: x < y) (map edgeKey a) == lib.sort (x: y: x < y) (map edgeKey b); - - # The fleet → hosts include policy shared by the spawn + instantiate topologies - # (verbatim from fx-unified-edges.nix / delivery-edges.nix): a flake-level resolve - # that fans out to each host with an instantiate spec. - fleetSetup = den: lib: { - 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 - ]; - }; - - # The valid-order assertion, shared by every topology. `unified` is the unified - # edge set under test. Returns the booleans the gate pins: - # sortSucceeds — topoSortEdges did not throw (acyclic + satisfiable). - # sortIsPermutation — the sorted output is the same multiset as the input. - # producerBeforeMerge — at least one producer (route/provides nest edge) - # precedes the default-fold merge of the same root+class - # that READS its cell, and none of those producer/merge - # pairs is mis-ordered. - validOrder = - den: unified: - let - inherit (den.lib.aspects.fx.edges) toposort; - ordered = toposort.topoSortEdges unified; - sortSucceeds = builtins.deepSeq ordered true; - indexByKey = lib.listToAttrs (lib.imap0 (i: e: lib.nameValuePair (edgeKey e) i) ordered); - # Producer = a nest/nest-verbatim edge whose target is a root (not a flake - # output): it WRITES the (root, class) cell. - producers = lib.filter ( - e: (e.mode == "nest" || e.mode == "nest-verbatim") && e.target ? root - ) ordered; - # Reading default folds = merge edges with collectedScopes (the final - # extraction at a root reads every subtree-scope bucket at its class). - readingFolds = lib.filter ( - e: e.mode == "merge" && e.target ? root && e.annotations ? collectedScopes - ) ordered; - # A (producer, fold) pair where the fold READS the producer's cell: the - # producer's target root is among the fold's collectedScopes AND the classes - # match. The producer must come strictly before the fold. - pairs = builtins.concatLists ( - map ( - p: - lib.filter (f: f != null) ( - map ( - f: - if - f.target.class == p.target.class - && builtins.elem p.target.root (f.annotations.collectedScopes or [ ]) - then - { - producer = p; - fold = f; - } - else - null - ) readingFolds - ) - ) producers - ); - pairOk = pr: indexByKey.${edgeKey pr.producer} < indexByKey.${edgeKey pr.fold}; - in - { - inherit sortSucceeds; - sortIsPermutation = sameMultiset ordered unified; - # At least one real producer→reading-fold pair, and EVERY such pair correctly - # ordered (producer strictly before the merge that reads it). - producerBeforeMerge = pairs != [ ] && lib.all pairOk pairs; - }; -in -{ - flake.tests.fx-edge-unification-gate = { - - # ===== SPAWN topology (flake-level, host-aspects battery) ============ - # A user under a host re-applies a host-schema homeManager projection; the - # host-aspects battery emits a spawn marker. The oracle renders ONE rewalk edge; - # the unified set drops it and surfaces the spawn's real delivered edges. We - # prove: oracle-minus-rewalk ⊆ unified, the surfaced spawn HM fold is present, - # the unified set has no rewalk arm, AND the unified set sorts to a valid - # permutation. - test-spawn-complete-and-ordered = denTest ( - { den, lib, ... }: - let - r = den.lib.aspects.resolveWithPaths "flake" (den.lib.resolveEntity "flake" { }); - oracle = r.legacyEdgeTrace; - unified = r.unifiedEdges; - oracleNoRewalk = lib.filter (e: !(e.source ? rewalk)) oracle; - order = validOrder den unified; - in - fleetSetup den lib - // { - den.hosts.x86_64-linux.igloo.users.tux = { }; - den.aspects.igloo.homeManager.home.sessionVariables.X = "y"; - den.aspects.tux.includes = [ den.batteries.host-aspects ]; - den.aspects.igloo.nixos.networking.hostName = "igloo"; - - expr = { - # (1) completeness: every non-rewalk oracle edge survives in unified. - completenessOracleMinusRewalk = isSubset oracleNoRewalk unified; - # the unified set has surfaced the spawn arm (no rewalk left). - unifiedHasNoRewalk = lib.all (e: !(e.source ? rewalk)) unified; - # the surfaced spawn delivers a homeManager default fold into the user - # root — a concrete edge the oracle's single rewalk arm collapsed away. - surfacedSpawnHmFold = lib.any ( - e: - e.mode == "merge" - && e.path == [ ] - && e.source ? collected - && e.source.collected.class == "homeManager" - && e.target ? root - && lib.hasInfix "user" e.target.root - && e.target.class == "homeManager" - ) unified; - # (2) valid order. - inherit (order) sortSucceeds sortIsPermutation producerBeforeMerge; - }; - expected = { - completenessOracleMinusRewalk = true; - unifiedHasNoRewalk = true; - surfacedSpawnHmFold = true; - sortSucceeds = true; - sortIsPermutation = true; - producerBeforeMerge = true; - }; - } - ); - - # ===== PLAIN host+user (no spawn) ==================================== - # No spawn marker → the oracle has no rewalk arm, so the WHOLE oracle set must - # survive in unified (completeness on the full set). Resolved at host level - # (the per-host/instantiate arms are flake-level only, so this is the pure - # top-level mechanism set). - test-plain-complete-and-ordered = denTest ( - { den, lib, ... }: - let - r = den.lib.aspects.resolveWithPaths "nixos" ( - den.lib.resolveEntity "host" { host = den.hosts.x86_64-linux.igloo; } - ); - oracle = r.legacyEdgeTrace; - unified = r.unifiedEdges; - order = validOrder den unified; - in - { - den.hosts.x86_64-linux.igloo.users.tux = { }; - den.aspects.igloo.nixos.networking.hostName = "igloo"; - - expr = { - # No spawn → oracle has no rewalk edge to drop. - oracleHasNoRewalk = lib.all (e: !(e.source ? rewalk)) oracle; - # (1) completeness: the full oracle set survives in unified. - completenessFullOracle = isSubset oracle unified; - # (2) valid order. - inherit (order) sortSucceeds sortIsPermutation producerBeforeMerge; - }; - expected = { - oracleHasNoRewalk = true; - completenessFullOracle = true; - sortSucceeds = true; - sortIsPermutation = true; - producerBeforeMerge = true; - }; - } - ); - - # ===== INSTANTIATE / multi-host (flake-level fleet) ================== - # A flake-level fleet resolve with an instantiate spec: unified carries the - # per-host default-fold + route edges (the mkInstantiateEdges projection) that - # the top-level oracle does not derive. We prove: oracle-minus-rewalk ⊆ unified, - # at least one host-rooted default fold is present (the per-host surface), AND - # the unified set sorts to a valid permutation. - test-instantiate-complete-and-ordered = denTest ( - { den, lib, ... }: - let - r = den.lib.aspects.resolveWithPaths "flake" (den.lib.resolveEntity "flake" { }); - oracle = r.legacyEdgeTrace; - unified = r.unifiedEdges; - oracleNoRewalk = lib.filter (e: !(e.source ? rewalk)) oracle; - order = validOrder den unified; - # A per-host default-fold edge: merge, P=[], targeting a host root's class. - # The oracle's top-level folds target the flake/system roots, so a - # host-rooted merge fold is the per-host projection's signature. - hostRootedFolds = lib.filter ( - e: - e.mode == "merge" - && e.path == [ ] - && e.source ? collected - && e.target ? root - && lib.hasInfix "host" e.target.root - ) unified; - in - fleetSetup den lib - // { - den.hosts.x86_64-linux.igloo.users.tux = { }; - den.aspects.igloo.nixos.networking.hostName = "igloo"; - - expr = { - # (1) completeness: every non-rewalk oracle edge survives. - completenessOracleMinusRewalk = isSubset oracleNoRewalk unified; - # the per-host projection surfaced at least one host-rooted default fold. - perHostFoldPresent = hostRootedFolds != [ ]; - # (2) valid order. - inherit (order) sortSucceeds sortIsPermutation producerBeforeMerge; - }; - expected = { - completenessOracleMinusRewalk = true; - perHostFoldPresent = true; - sortSucceeds = true; - sortIsPermutation = true; - producerBeforeMerge = true; - }; - } - ); - - # ===== ISOLATED-GUEST topology (host-level, appendToParent route) ==== - # An isolated guest kind under the host: the guest gets its OWN default fold - # (isolation = it is its own entity-root) and a nest-verbatim delivery route - # (appendToParent, reinstantiate) into the host root. No spawn → full oracle - # set survives. We prove completeness on the full oracle set AND valid order - # (the verbatim route producer, an appendToParent edge writing the host cell, - # is among the producer→fold pairs the ordering spot-check covers). - test-isolated-guest-complete-and-ordered = 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" - ]; - }) - ] - ); - r = den.lib.aspects.resolveWithPaths "nixos" ( - den.lib.resolveEntity "host" { host = den.hosts.x86_64-linux.igloo; } - ); - oracle = r.legacyEdgeTrace; - unified = r.unifiedEdges; - order = validOrder den unified; - 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 = { - # No spawn → oracle has no rewalk edge. - oracleHasNoRewalk = lib.all (e: !(e.source ? rewalk)) oracle; - # (1) completeness: the full oracle set survives in unified. - completenessFullOracle = isSubset oracle unified; - # The verbatim delivery route (the isolation producer) is in the set. - verbatimRoutePresent = lib.any (e: e.mode == "nest-verbatim") unified; - # (2) valid order. - inherit (order) sortSucceeds sortIsPermutation producerBeforeMerge; - }; - expected = { - oracleHasNoRewalk = true; - completenessFullOracle = true; - verbatimRoutePresent = true; - sortSucceeds = true; - sortIsPermutation = true; - producerBeforeMerge = true; - }; - } - ); - - # ===== CYCLE throws ================================================== - # A synthesize 2-cycle (F1 a→b writes (s,b) reads all "a"; F2 b→a writes (s,a) - # reads all "b") through the SAME `topoSortEdges` entry the unified set uses - # must THROW the loud cycle error (mutual dependency → no Kahn-ready edge). - test-cycle-throws = denTest ( - { den, ... }: - let - inherit (den.lib.aspects.fx.edges) toposort edge; - f1 = edge.mkEdge { - source = edge.synthesize "F1" "a" "b"; - target = edge.rootTarget "s" "b"; - path = [ ]; - mode = "nest"; - }; - f2 = edge.mkEdge { - source = edge.synthesize "F2" "b" "a"; - target = edge.rootTarget "s" "a"; - path = [ ]; - mode = "nest"; - }; - result = builtins.tryEval ( - builtins.deepSeq (toposort.topoSortEdges [ - f1 - f2 - ]) "no-throw" - ); - in - { - expr = result.success; - expected = false; - } - ); - }; -} diff --git a/templates/ci/modules/internal-api/fx-materialize-unified.nix b/templates/ci/modules/internal-api/fx-materialize-unified.nix deleted file mode 100644 index de195e567..000000000 --- a/templates/ci/modules/internal-api/fx-materialize-unified.nix +++ /dev/null @@ -1,381 +0,0 @@ -# fx-materialize-unified — the Task-17 byte-equivalence proof. The ordered- -# dispatch engine (nix/lib/aspects/fx/edges/materialize-unified.nix) interleaves -# provides + routes in topoSortEdges order, reusing the EXISTING per-spec -# materializers; this suite proves it is byte-equivalent to the current -# phase2∘phase3 phase folds over the SAME live seed. -# -# Reached via the `materializeEquiv` surface on the resolveWithPaths result (a lazy -# thunk beside edgeTrace / unifiedEdges): -# - materializeEquiv.phaseFold — phase2∘phase3 (production order). -# - materializeEquiv.unified — materializeUnified { doFinalMerge = false }. -# - materializeEquiv.unifiedMerged / .phaseFoldMerged — the doFinalMerge = true -# pair (materializeUnified vs phaseFold-then-assembleSubtree). -# -# Class modules carry FUNCTIONS (`{ config, ... }: …` modules + the route nesting -# closures), which Nix cannot compare with `==` unless the references are identical -# — and the route closures are RE-CONSTRUCTED per fold, so `phaseFold == unified` -# would throw on a content list even when the delivery is identical. Fully -# EVALUATING the modules is also unsound here (a standalone freeform evalModules of -# a host class bucket hits undefined `nixpkgs` options). -# -# The proof is therefore TWO-PART, exactly matching Design B (order-only, reuse the -# same materializers): -# (1) DISPATCH ORDER — the sequence of (kind, spec-identity) the unified engine -# folds is IDENTICAL to phase2∘phase3 (all provides, in dedup order, then all -# routes, in orderedKeptRoutes order). Since both paths run the SAME per-spec -# materializers on the SAME seed, identical order ⇒ identical output by -# construction. This is the load-bearing equivalence. -# (2) STRUCTURAL FINGERPRINT — a function-tolerant deep walk of both -# `{ classImports; perScope }` accumulators agrees: same attr keys, same list -# lengths, same scalars, functions treated as opaque-equal leaves. This -# guards (1) against a materializer that branches on fold position (it does -# not — but the fingerprint catches any structural divergence the order proof -# alone would miss). -# Together: identical dispatch order + identical structure over the same reused -# materializers == byte-equivalent delivery. -# -# `just ci fx-materialize-unified` runs this suite. -{ denTest, lib, ... }: -let - # Function-tolerant structural fingerprint. Attrsets → sorted key list + per-key - # fingerprint; lists → length + per-elem fingerprint; functions → opaque "" - # (uninspectable, treated equal); scalars → their toString. NOT a content proof - # on functions — paired with the dispatch-order proof which IS conclusive. - fingerprint = - v: - if builtins.isFunction v then - "" - else if builtins.isList v then - { - __list = builtins.length v; - items = map fingerprint v; - } - else if builtins.isAttrs v && !(lib.isDerivation v) then - lib.mapAttrs (_: fingerprint) v - else if lib.isDerivation v then - "" - else - builtins.toString v; - - # The two accumulators agree iff their structural fingerprints are deep-equal. - equivalent = - e: - let - pf = e.phaseFold; - un = e.unified; - in - { - # (1) dispatch order — the identity sequence is identical. - dispatchOrderEqual = e.phaseFoldDispatch == e.unifiedDispatch; - # (2) structural fingerprint — classImports + perScope agree. - classImportsEqual = fingerprint pf.classImports == fingerprint un.classImports; - perScopeEqual = fingerprint pf.perScope == fingerprint un.perScope; - }; - equivExpected = { - dispatchOrderEqual = true; - classImportsEqual = true; - perScopeEqual = true; - }; -in -{ - flake.tests.fx-materialize-unified = { - - # ===== PLAIN host+user (default fold only) =========================== - # No provides, no routes → the unified fold is the identity over the seed - # (empty pair list); equivalence is the trivial-but-meaningful base case. - test-plain-equivalent = denTest ( - { den, ... }: - let - r = den.lib.aspects.resolveWithPaths "nixos" ( - den.lib.resolveEntity "host" { host = den.hosts.x86_64-linux.igloo; } - ); - in - { - den.hosts.x86_64-linux.igloo.users.tux = { }; - den.aspects.igloo.nixos.networking.hostName = "igloo"; - - expr = equivalent r.materializeEquiv; - expected = equivExpected; - } - ); - - # ===== PROVIDES topology ============================================= - # A policy.provide injects a module into the host's nixos class (P=[]) AND a - # second provide nests at a path. Exercises applyOneProvide in the interleaved - # fold vs the phase2 fold — both deduped, both into the source bucket. - test-provides-equivalent = denTest ( - { den, ... }: - let - r = den.lib.aspects.resolveWithPaths "nixos" ( - den.lib.resolveEntity "host" { host = den.hosts.x86_64-linux.igloo; } - ); - in - { - den.hosts.x86_64-linux.igloo.users.tux = { }; - den.policies.provide-direct = - { host, ... }: - [ - (den.lib.policy.provide { - class = host.class; - module.networking.hostName = "provided"; - }) - (den.lib.policy.provide { - class = host.class; - module.value = "boxed"; - path = [ "provide-box" ]; - }) - ]; - den.default.includes = [ den.policies.provide-direct ]; - den.aspects.igloo.nixos.networking.domain = "local"; - - expr = equivalent r.materializeEquiv; - expected = equivExpected; - } - ); - - # ===== ROUTE topology ================================================ - # A class route (path=[] merge) and a nested route (path≠[] nest) deliver a - # custom source class into nixos. Exercises applySimpleRouteEdge in the - # interleaved fold vs phase3, with simple routes reading the FROZEN seed - # perScope in BOTH paths. - test-routes-equivalent = denTest ( - { den, ... }: - let - r = den.lib.aspects.resolveWithPaths "nixos" ( - den.lib.resolveEntity "host" { host = den.hosts.x86_64-linux.igloo; } - ); - in - { - den.hosts.x86_64-linux.igloo.users.tux = { }; - den.classes.custom.description = "custom source class"; - den.classes.src.description = "nested source class"; - den.policies.route-both = - { host, ... }: - [ - (den.lib.policy.route { - fromClass = "custom"; - intoClass = host.class; - path = [ ]; - }) - (den.lib.policy.route { - fromClass = "src"; - intoClass = host.class; - path = [ "route-box" ]; - }) - ]; - den.default.includes = [ den.policies.route-both ]; - den.aspects.igloo = { - nixos.networking.hostName = "igloo"; - custom.networking.domain = "routed"; - src.value = "nested"; - }; - - expr = equivalent r.materializeEquiv; - expected = equivExpected; - } - ); - - # ===== PROVIDES + ROUTES interleaved ================================= - # Both mechanisms active: the interleaving (provides-before-routes among - # independents) is the load-bearing case for byte-equivalence. The unified fold - # must keep provides ahead of routes exactly as phase2∘phase3 does. - test-provides-and-routes-equivalent = denTest ( - { den, ... }: - let - r = den.lib.aspects.resolveWithPaths "nixos" ( - den.lib.resolveEntity "host" { host = den.hosts.x86_64-linux.igloo; } - ); - in - { - den.hosts.x86_64-linux.igloo.users.tux = { }; - den.classes.custom.description = "custom source class"; - den.policies.provide-and-route = - { host, ... }: - [ - (den.lib.policy.provide { - class = host.class; - module.networking.hostName = "provided"; - }) - (den.lib.policy.route { - fromClass = "custom"; - intoClass = host.class; - path = [ ]; - }) - ]; - den.default.includes = [ den.policies.provide-and-route ]; - den.aspects.igloo = { - custom.networking.domain = "routed"; - }; - - expr = equivalent r.materializeEquiv; - expected = equivExpected; - } - ); - - # ===== ISOLATED-GUEST topology (appendToParent, reinstantiate) ======= - # An isolated guest kind with a nest-verbatim appendToParent route into the - # host root (the gate's isolation canary). Exercises applySimpleRouteEdge's - # nest-verbatim arm + appendToParent target scope in the interleaved fold. - test-isolated-guest-equivalent = 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" - ]; - }) - ] - ); - r = den.lib.aspects.resolveWithPaths "nixos" ( - den.lib.resolveEntity "host" { host = den.hosts.x86_64-linux.igloo; } - ); - 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 = equivalent r.materializeEquiv; - expected = equivExpected; - } - ); - - # ===== doFinalMerge = true ========================================== - # materializeUnified { doFinalMerge = true } must equal phaseFold-then- - # assembleSubtree (the final-extraction merge step, unchanged). Compared on the - # provides+routes topology so the merge sees real content. - test-final-merge-equivalent = denTest ( - { den, ... }: - let - r = den.lib.aspects.resolveWithPaths "nixos" ( - den.lib.resolveEntity "host" { host = den.hosts.x86_64-linux.igloo; } - ); - e = r.materializeEquiv; - in - { - den.hosts.x86_64-linux.igloo.users.tux = { }; - den.classes.custom.description = "custom source class"; - den.policies.provide-and-route = - { host, ... }: - [ - (den.lib.policy.provide { - class = host.class; - module.networking.hostName = "provided"; - }) - (den.lib.policy.route { - fromClass = "custom"; - intoClass = host.class; - path = [ ]; - }) - ]; - den.default.includes = [ den.policies.provide-and-route ]; - den.aspects.igloo = { - custom.networking.domain = "routed"; - }; - - expr = { - # assembleSubtree returns { class → [ modules ] }; the function-tolerant - # fingerprint compares the two merged results structurally. - mergeEqual = fingerprint e.unifiedMerged == fingerprint e.phaseFoldMerged; - }; - expected = { - mergeEqual = true; - }; - } - ); - - # ===== exposeEdges = true (Task 18 capture) ========================== - # materializeUnified { exposeEdges = true } ALSO carries the folded edge - # records (`map (p: p.edge) orderedPairs`). Capture fidelity: the captured - # `.edges` are the SAME SET as the constructor-built provides+route edges - # over the same inputs — proven by sorting both via the edge sort key and - # deep-comparing. Run on the provides+routes topology so both edge kinds - # are present. - # - # ALSO proves the existing-mode invariant: with exposeEdges the accumulator - # (everything but `.edges`) is byte-identical to the plain { doFinalMerge = - # false } return — exposeEdges only ADDS the capture key. - test-expose-edges-capture = denTest ( - { den, lib, ... }: - let - r = den.lib.aspects.resolveWithPaths "nixos" ( - den.lib.resolveEntity "host" { host = den.hosts.x86_64-linux.igloo; } - ); - e = r.materializeEquiv; - edgeMod = den.lib.aspects.fx.edges.edge; - # Edges are pure data (target/source/path/mode/annotations) — compare the - # captured fold edges to the constructor-built oracle as a SET by sorting - # both via the edge sort key, then deep-comparing the sorted lists. - sorted = edges: edgeMod.sortEdges edges; - capturedSorted = sorted e.unifiedWithEdges.edges; - oracleSorted = sorted e.oracleEdges; - in - { - den.hosts.x86_64-linux.igloo.users.tux = { }; - den.classes.custom.description = "custom source class"; - den.policies.provide-and-route = - { host, ... }: - [ - (den.lib.policy.provide { - class = host.class; - module.networking.hostName = "provided"; - }) - (den.lib.policy.route { - fromClass = "custom"; - intoClass = host.class; - path = [ ]; - }) - ]; - den.default.includes = [ den.policies.provide-and-route ]; - den.aspects.igloo = { - custom.networking.domain = "routed"; - }; - - expr = { - # Capture fidelity: folded edges == constructor edges (as a sorted set). - edgesMatchOracle = fingerprint capturedSorted == fingerprint oracleSorted; - # The capture is non-empty here (one provide edge + one route edge). - edgesNonEmpty = builtins.length e.unifiedWithEdges.edges > 0; - # Existing-mode invariant: the accumulator (sans the added `edges` key) - # is byte-identical to the plain no-exposeEdges return. - accUnchanged = - fingerprint (builtins.removeAttrs e.unifiedWithEdges [ "edges" ]) == fingerprint e.unified; - }; - expected = { - edgesMatchOracle = true; - edgesNonEmpty = true; - accUnchanged = true; - }; - } - ); - }; -} diff --git a/templates/ci/modules/internal-api/fx-oracle-production-differential.nix b/templates/ci/modules/internal-api/fx-oracle-production-differential.nix deleted file mode 100644 index fb431f37a..000000000 --- a/templates/ci/modules/internal-api/fx-oracle-production-differential.nix +++ /dev/null @@ -1,231 +0,0 @@ -# fx-oracle-production-differential suite — the Task 18.3 differential gate. -# -# As of Task 18.2 the resolveWithPaths result carries TWO edge objects side by -# side: -# - `edgeTrace` — the PRODUCTION edge object. Its fold-ordered -# provides+routes portion is CAPTURED from the production -# materializeUnified folds (the top-level fold, the spawn's -# surfaced `.edges`, the per-host `.edges`); its default-fold -# + instantiate edges are constructor-built. This is -# drift-proof for the captured part. -# - `legacyEdgeTrace` — the LEGACY end-state RE-DERIVATION (edge-trace.nix -# extractEdgeTrace), WITH its spawn `rewalk` arm (the -# spawn UNDERCOUNT) and the dedup-suppressed route twins. -# -# This suite diffs the two on a SPAWN topology and an INSTANTIATE topology and -# pins the load-bearing relationship: -# -# (A) PRODUCTION ⊇ (LEGACY minus rewalk-source AND dedup-suppressed edges) — -# every legacy edge that is neither a spawn rewalk nor a suppressed route -# twin survives (by normalized key) in the production object. Production drops -# the rewalk arm (replaced by the spawn's real surfaced edges) AND the -# suppressed twins (it folds `orderedKeptRoutes` only). Today every CI -# suppressed twin key-aliases its kept sibling so it would survive the key -# check anyway, but the gate strips suppressed from the legacy arm -# (`legacyDelivered`) so it stays sound for a future distinct-key suppression. -# -# (B) the production-only delta (edges in production NOT in legacy, by key) on -# the spawn topology is NON-EMPTY and CONTAINS the spawn's surfaced route / -# default-fold edge — the concrete homeManager fold into the user root that -# the legacy oracle's single rewalk edge collapsed away. -# -# This is a PRODUCTION-vs-LEGACY differential (NOT production-vs-self): `oracle` -# binds `legacyEdgeTrace`, `production` binds `edgeTrace`. -# -# `just ci fx-oracle-production-differential` runs this suite. -{ denTest, lib, ... }: -let - # Stable sort key mirroring edges/edge.nix edgeSortKey (T, P, S, M), so the two - # edge lists are compared as normalized SETS regardless of construction order. - 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"; - edgeKey = - e: - lib.concatStringsSep " | " [ - (targetKey e.target) - (pathKey e.path) - (sourceKey e.source) - e.mode - ]; - - keySet = edges: lib.genAttrs (map edgeKey edges) (_: true); - isSubset = sub: super: lib.all (e: (keySet super) ? ${edgeKey e}) sub; - - # The legacy edges production is EXPECTED to still deliver: everything except - # (a) the spawn `rewalk` arm (the undercount production replaces with real - # surfaced edges) and (b) the dedup-`suppressed` route twins (production folds - # `orderedKeptRoutes` only, so a suppressed route is never materialized — its - # absence from production is faithful, not a drop). The correct subset relation - # is therefore `production ⊇ legacy \ rewalk \ suppressed`. Today every CI - # suppressed twin is a rule-1 same-identity forward duplicate that key-aliases - # its kept sibling (so it would survive the subset check anyway), but stripping - # it here makes the gate sound for a future DISTINCT-key suppression (rule-2 - # redundant-root, or an adapterKey route with differing path/intoClass) without - # weakening it. - legacyDelivered = lib.filter (e: !(e.source ? rewalk) && !(e.annotations.suppressed or false)); - - # The fleet → hosts include policy shared by the spawn + instantiate topologies - # (a flake-level resolve that fans out to each host with an instantiate spec). - fleetSetup = den: lib: { - 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 - ]; - }; -in -{ - flake.tests.fx-oracle-production-differential = { - - # ===== SPAWN topology (flake-level, host-aspects battery) ============ - # A user under a host runs the host-aspects battery → a policy.spawn marker. - # The LEGACY object renders ONE rewalk edge for it; the PRODUCTION object drops - # the rewalk arm and surfaces the spawn's real delivered edges (its homeManager - # default fold into the user root). We diff the two: - # (A) production ⊇ (legacy minus its rewalk-source edges); - # (B) the production-only delta is non-empty AND contains the surfaced spawn - # homeManager fold into the user root. - test-spawn-production-superset-of-oracle = denTest ( - { den, lib, ... }: - let - r = den.lib.aspects.resolveWithPaths "flake" (den.lib.resolveEntity "flake" { }); - oracle = r.legacyEdgeTrace; - production = r.edgeTrace; - # Legacy minus rewalk AND suppressed twins — the set production must retain - # (see legacyDelivered). - oracleNoRewalk = legacyDelivered oracle; - # The legacy object DID carry a rewalk edge (the undercount we correct). - oracleRewalk = lib.filter (e: e.source ? rewalk) oracle; - # Production-only edges (the surfaced spawn's real delivered edges, which - # the single legacy rewalk edge collapsed away). - oracleKeys = keySet oracle; - productionOnly = lib.filter (e: !(oracleKeys ? ${edgeKey e})) production; - # The surfaced spawn delivers a homeManager default fold into the user - # root — the concrete edge that replaces the legacy rewalk edge. - surfacedSpawnHmFold = lib.any ( - e: - e.mode == "merge" - && e.path == [ ] - && e.source ? collected - && e.source.collected.class == "homeManager" - && e.target ? root - && lib.hasInfix "user" e.target.root - && e.target.class == "homeManager" - ) productionOnly; - in - fleetSetup den lib - // { - den.hosts.x86_64-linux.igloo.users.tux = { }; - den.aspects.igloo.homeManager.home.sessionVariables.X = "y"; - den.aspects.tux.includes = [ den.batteries.host-aspects ]; - den.aspects.igloo.nixos.networking.hostName = "igloo"; - - expr = { - # The legacy object has a rewalk arm (the spawn undercount). - oracleHasRewalk = oracleRewalk != [ ]; - # (A) production ⊇ (legacy minus rewalk). - productionSupersetOfOracleMinusRewalk = isSubset oracleNoRewalk production; - # The production object dropped the rewalk arm entirely. - productionHasNoRewalk = lib.all (e: !(e.source ? rewalk)) production; - # (B) production-only delta non-empty AND carries the surfaced spawn fold. - productionDeltaNonEmpty = productionOnly != [ ]; - inherit surfacedSpawnHmFold; - }; - expected = { - oracleHasRewalk = true; - productionSupersetOfOracleMinusRewalk = true; - productionHasNoRewalk = true; - productionDeltaNonEmpty = true; - surfacedSpawnHmFold = true; - }; - } - ); - - # ===== INSTANTIATE topology (flake-level fleet, no spawn) ============ - # A flake-level fleet resolve with an instantiate spec but NO spawn marker. The - # legacy object has no rewalk arm, so the FULL legacy set survives in the - # production object; production ADDS the per-host surfaced fold edges the - # instantiate projection derives (host-rooted default folds the legacy top-level - # set does not). We diff the two: - # (A) production ⊇ legacy (the full set — no rewalk to drop); - # (B) the production-only delta is non-empty AND contains a host-rooted - # default fold (the per-host projection's signature). - test-instantiate-production-superset-of-oracle = denTest ( - { den, lib, ... }: - let - r = den.lib.aspects.resolveWithPaths "flake" (den.lib.resolveEntity "flake" { }); - oracle = r.legacyEdgeTrace; - production = r.edgeTrace; - oracleHasRewalk = lib.any (e: e.source ? rewalk) oracle; - oracleKeys = keySet oracle; - productionOnly = lib.filter (e: !(oracleKeys ? ${edgeKey e})) production; - # A per-host default-fold edge: merge, P=[], targeting a host root's class. - hostRootedFoldInDelta = lib.any ( - e: - e.mode == "merge" - && e.path == [ ] - && e.source ? collected - && e.target ? root - && lib.hasInfix "host" e.target.root - ) productionOnly; - in - fleetSetup den lib - // { - den.hosts.x86_64-linux.igloo.users = { }; - den.aspects.igloo.nixos.networking.hostName = "igloo"; - - expr = { - # No spawn → the legacy object has no rewalk edge. - oracleHasNoRewalk = !oracleHasRewalk; - # (A) the legacy delivered set (minus rewalk/suppressed) survives in the - # production object. No spawn here, so this is the full legacy set sans - # any suppressed twins (see legacyDelivered). - productionSupersetOfOracle = isSubset (legacyDelivered oracle) production; - # (B) production-only delta non-empty AND carries a host-rooted fold. - productionDeltaNonEmpty = productionOnly != [ ]; - inherit hostRootedFoldInDelta; - }; - expected = { - oracleHasNoRewalk = true; - productionSupersetOfOracle = true; - productionDeltaNonEmpty = true; - hostRootedFoldInDelta = true; - }; - } - ); - }; -} diff --git a/templates/ci/modules/internal-api/fx-unified-edges.nix b/templates/ci/modules/internal-api/fx-unified-edges.nix deleted file mode 100644 index 2443f702b..000000000 --- a/templates/ci/modules/internal-api/fx-unified-edges.nix +++ /dev/null @@ -1,237 +0,0 @@ -# fx-unified-edges suite — the unifiedEdges(root) collector -# (nix/lib/aspects/fx/resolve.nix), the union edge set that CORRECTS the oracle's -# (edge-trace.nix) spawn UNDERCOUNT: it is the oracle's top-level mechanism set -# MINUS the single `rewalk` arm, PLUS the SURFACED spawn edges (the spawn node's -# real default-fold + provides + route edges) and the per-host / B′ instantiate -# edges. -# -# `unifiedEdges` sits beside `edgeTrace` on the resolveWithPaths result, reached -# the same way the delivery-edges suite reaches `edgeTrace`. -# -# `just ci fx-unified-edges` runs this suite. -{ denTest, lib, ... }: -let - # Stable sort key mirroring edges/edge.nix edgeSortKey (T, P, S, M), so the two - # edge lists are compared as normalized SETS regardless of construction order. - 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"; - edgeKey = - e: - lib.concatStringsSep " | " [ - (targetKey e.target) - (pathKey e.path) - (sourceKey e.source) - e.mode - ]; - - keySet = edges: lib.genAttrs (map edgeKey edges) (_: true); - isSubset = sub: super: lib.all (e: (keySet super) ? ${edgeKey e}) sub; - - # The resolve result (carries edgeTrace + unifiedEdges side by side). - hostResult = - den: cls: host: - den.lib.aspects.resolveWithPaths cls (den.lib.resolveEntity "host" { inherit host; }); -in -{ - flake.tests.fx-unified-edges = { - - # ===== (1) spawn topology: unifiedEdges fixes the rewalk undercount ===== - # The host-aspects battery on a user emits a policy.spawn marker; the oracle - # renders ONE rewalk edge for it, but the spawn actually delivers a full edge - # set (its homeManager default fold + the re-applied mergedSpawnRoutes route - # edges). unifiedEdges drops the oracle's rewalk arm and adds the surfaced - # spawn edges, so: - # - unifiedEdges is a superset of (edgeTrace MINUS its rewalk edges); - # - unifiedEdges contains at least one edge the oracle OMITTED (the surfaced - # spawn delivered a route/default-fold edge the rewalk arm collapsed away). - test-spawn-superset-of-oracle-minus-rewalk = denTest ( - { den, lib, ... }: - let - # FLAKE-level resolve: the drain-fold spawn (mkDrained) fires only when - # the spawn's parent scope is a resolve.to-created entity scope (so it is - # in scopeEntityKind). At HOST level the host is the ctx-seeded root, not - # in scopeEntityKind, so the drain-fold spawn arm is a no-op there — the - # surfaced spawn edges only exist at flake level. - r = den.lib.aspects.resolveWithPaths "flake" (den.lib.resolveEntity "flake" { }); - oracle = r.legacyEdgeTrace; - unified = r.unifiedEdges; - oracleNoRewalk = lib.filter (e: !(e.source ? rewalk)) oracle; - # Edges the unified set has that the oracle did NOT (the surfaced spawn's - # real delivered edges, which the single rewalk edge collapsed away). - oracleKeys = keySet oracle; - novelInUnified = lib.filter (e: !(oracleKeys ? ${edgeKey e})) unified; - # The oracle DID carry a rewalk edge (the undercount we are correcting). - oracleRewalk = lib.filter (e: e.source ? rewalk) oracle; - in - { - 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.tux = { }; - den.aspects.igloo.homeManager.home.sessionVariables.X = "y"; - den.aspects.tux.includes = [ den.batteries.host-aspects ]; - den.aspects.igloo.nixos.networking.hostName = "igloo"; - - expr = { - oracleHasRewalk = oracleRewalk != [ ]; - unifiedHasNoRewalk = lib.all (e: !(e.source ? rewalk)) unified; - unifiedSupersetOfOracleMinusRewalk = isSubset oracleNoRewalk unified; - unifiedHasNovelEdges = novelInUnified != [ ]; - # The surfaced spawn delivers a homeManager default fold into the user - # root — a concrete edge the oracle's single rewalk edge collapsed away. - unifiedHasUserHmFold = lib.any ( - e: - e.mode == "merge" - && e.path == [ ] - && e.source ? collected - && e.source.collected.class == "homeManager" - && e.target ? root - && lib.hasInfix "user" e.target.root - && e.target.class == "homeManager" - ) unified; - }; - expected = { - oracleHasRewalk = true; - unifiedHasNoRewalk = true; - unifiedSupersetOfOracleMinusRewalk = true; - unifiedHasNovelEdges = true; - unifiedHasUserHmFold = true; - }; - } - ); - - # ===== (2) plain host+user (no spawn): unifiedEdges ⊇ oracle ============= - # With NO spawn marker the legacy oracle has no rewalk arm, so the production - # edge object contains the SAME top-level mechanism edges (default folds + - # os routes + the user forward) AND augments them with the per-host instantiate - # edges. We assert the full legacy oracle set is a subset of the production set - # (nothing top-level dropped). NOTE: the production object CAPTURES the edges its - # fold dispatched (kept routes only), so it omits the legacy oracle's - # suppressed-twin DUPLICATES (same edge KEY) — hence we compare DISTINCT-key - # counts, not raw list lengths. - test-plain-superset-of-oracle = denTest ( - { den, lib, ... }: - let - r = hostResult den "nixos" den.hosts.x86_64-linux.igloo; - oracle = r.legacyEdgeTrace; - unified = r.unifiedEdges; - oracleHasRewalk = lib.any (e: e.source ? rewalk) oracle; - distinctKeyCount = edges: builtins.length (builtins.attrNames (keySet edges)); - in - { - den.hosts.x86_64-linux.igloo.users.tux = { }; - den.aspects.igloo.nixos.networking.hostName = "igloo"; - - expr = { - # No spawn in this topology → oracle has no rewalk edge. - oracleHasNoRewalk = !oracleHasRewalk; - # The whole legacy oracle set survives in the production set (by key). - unifiedSupersetOfOracle = isSubset oracle unified; - # And the production set has at least the oracle's DISTINCT-key count. - unifiedAtLeastOracleCount = distinctKeyCount unified >= distinctKeyCount oracle; - }; - expected = { - oracleHasNoRewalk = true; - unifiedSupersetOfOracle = true; - unifiedAtLeastOracleCount = true; - }; - } - ); - - # ===== (3) per-host edges present (instantiate-style topology) =========== - # A flake-level resolve with an instantiate spec: unifiedEdges carries the - # per-host default-fold + route edges (the mkInstantiateEdges projection) that - # the top-level oracle set does not derive (the oracle has the flake-output - # instantiate edge; the per-host fold edges are the NEW additive surface). - test-perhost-edges-present = denTest ( - { den, lib, ... }: - let - flakeResult = den.lib.aspects.resolveWithPaths "flake" (den.lib.resolveEntity "flake" { }); - unified = flakeResult.unifiedEdges; - # A per-host default-fold edge: merge, P=[], targeting the host root's - # nixos. The oracle's top-level folds target the flake/system roots, so a - # host-rooted merge fold is the per-host projection's signature. - hostRootedFolds = lib.filter ( - e: - e.mode == "merge" - && e.path == [ ] - && e.source ? collected - && e.target ? root - && lib.hasInfix "host" e.target.root - ) unified; - in - { - 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"; - - # The per-host projection surfaced at least one host-rooted default fold. - expr = hostRootedFolds != [ ]; - expected = true; - } - ); - }; -} From d11b0e968ecce374bc1ea42213e418ca09d22849 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 8 Sep 2026 14:41:19 -0700 Subject: [PATCH 33/59] test: add entity- and fleet-scale performance cells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The performance suite declared zero hosts across all 8 sites, so a green perf run certified only bare aspect-resolution walks, never entity or fleet building. Adds one entity-building cell (host + host-nested user + standalone home, each forcing a real nixosConfiguration/home-manager build) and one fleet-scale cell (N=5 real hosts, each forced individually via length∘filter to avoid builtins.all's short-circuit, with an independent registration count as a control). --- .../ci/modules/internal-api/entity-scale.nix | 107 ++++++++++++++++++ .../ci/modules/internal-api/fleet-scale.nix | 75 ++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 templates/ci/modules/internal-api/entity-scale.nix create mode 100644 templates/ci/modules/internal-api/fleet-scale.nix diff --git a/templates/ci/modules/internal-api/entity-scale.nix b/templates/ci/modules/internal-api/entity-scale.nix new file mode 100644 index 000000000..41a470a50 --- /dev/null +++ b/templates/ci/modules/internal-api/entity-scale.nix @@ -0,0 +1,107 @@ +# PERFORMANCE SUITE COVERAGE — read this before trusting `perf 29/29`. +# +# Every OTHER file feeding `flake.tests.performance` (resolve.nix, depth.nix, +# forward.nix, namespace.nix, ctx-pipeline.nix, ctx-chain.nix, pure-eval.nix, +# deprecated/parametric.nix) resolves a bare `den.aspects.` tree via +# `funnyNames`/`den.lib.resolveEntity` directly. None of them declares a +# `den.hosts` or `den.homes` entity, so none builds a real +# nixosConfiguration/homeConfiguration or runs the schema/policy dispatch +# that only fires at entity scope (den.schema.host.includes, +# den.classes.homeManager parent-path forwarding, etc). A green suite there +# certifies the aspect-resolution walk, not entity or fleet building. +# +# This file and fleet-scale.nix close that gap: +# - test-entity-chain-host-user-home (below): one host, one host-nested +# user, one standalone home, each carrying its own N-deep includes +# chain through a REAL class (nixos/homeManager), forcing an actual +# nixosConfiguration + home-manager user + homeConfiguration build. +# - fleet-scale.nix: N hosts built and forced individually, N configurable. +# +# What is still NOT covered: timing/cost assertions. nix-unit asserts +# correctness, not wall-clock; D4 (fleet-linear policy dispatch, +# nix/lib/aspects/fx/handlers/constraint.nix `isPolicyExcluded`) needs a +# real timing comparison (`just bench`, external), not a cell here — these +# cells only make the entity/fleet scale D4 depends on reachable by CI. +{ denTest, lib, ... }: +let + mkNixosChain = + n: + let + go = + i: + if i >= n then + { nixos.environment.etc."chain-leaf".text = "leaf"; } + else + { + nixos.environment.etc."chain-${toString i}".text = "n${toString i}"; + includes = [ (go (i + 1)) ]; + }; + in + go 0; + + mkHmChain = + n: + let + go = + i: + if i >= n then + { homeManager.home.sessionVariables.CHAIN_LEAF = "leaf"; } + else + { + homeManager.home.sessionVariables."CHAIN_${toString i}" = "n${toString i}"; + includes = [ (go (i + 1)) ]; + }; + in + go 0; + + # depth of each entity's own chain — a single entity per class, not + # fleet-multiplied, so this can run deeper than fleet-scale.nix's N. + n = 20; +in +{ + flake.tests.performance.entity = { + + test-entity-chain-host-user-home = denTest ( + { + den, + igloo, + tuxHm, + config, + lib, + ... + }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.homes.x86_64-linux.solo = { }; + # define-user supplies home.username/homeDirectory for both the + # host-nested user and the standalone home — required to force + # standalone .config without an assertion failure (see homes.nix). + den.default.includes = [ den.provides.define-user ]; + + den.aspects.igloo = mkNixosChain n; + den.aspects.tux.includes = [ (mkHmChain n) ]; + den.aspects.solo.includes = [ (mkHmChain n) ]; + + expr = { + hostChainLen = builtins.length ( + lib.filter (k: lib.hasPrefix "chain-" k) (builtins.attrNames igloo.environment.etc) + ); + userChainLen = builtins.length ( + lib.filter (k: lib.hasPrefix "CHAIN_" k) (builtins.attrNames tuxHm.home.sessionVariables) + ); + homeChainLen = builtins.length ( + lib.filter (k: lib.hasPrefix "CHAIN_" k) ( + builtins.attrNames config.flake.homeConfigurations.solo.config.home.sessionVariables + ) + ); + }; + expected = { + hostChainLen = n + 1; + userChainLen = n + 1; + homeChainLen = n + 1; + }; + } + ); + + }; +} diff --git a/templates/ci/modules/internal-api/fleet-scale.nix b/templates/ci/modules/internal-api/fleet-scale.nix new file mode 100644 index 000000000..adb173a64 --- /dev/null +++ b/templates/ci/modules/internal-api/fleet-scale.nix @@ -0,0 +1,75 @@ +# N real hosts, built and forced individually. Companion to entity-scale.nix +# (see its header for what the rest of the performance suite does NOT cover). +# +# Every host carries its own copy of a SAME-NAMED policy (`tools`), included +# via den.schema.host.includes so it registers once per host scope — this is +# the shape D4 (nix/lib/aspects/fx/handlers/constraint.nix `isPolicyExcluded`) +# needs to be visible at all: policyClaimsByName."name:tools" accumulates one +# claim per host regardless of scope, while each host's own scoped registry +# keeps exactly one. One host excludes the policy by rawRef (R > 0) so the +# cell also proves exclude resolution still names the right claimant once N +# grows past 1. This cell asserts correctness only — it is not a timing +# instrument; use `just bench` for that, with N raised well past this file's +# default. +# +# Sizing: bump `n` below to scale. Do NOT raise the COMMITTED default: N=1000 +# alone, on unmodified den, hits rc=1 at roughly 43GB — a harness memory +# ceiling, not a mechanism cost. n=5 here costs a few seconds under +# `nix-unit --flake ./templates/ci#.tests.performance`. +{ denTest, lib, ... }: +let + n = 5; + fleetNames = lib.genList (i: "fleet${toString i}") n; + excludedHost = builtins.head fleetNames; +in +{ + flake.tests.performance.fleet = { + + test-fleet-scale = denTest ( + { + den, + config, + lib, + ... + }: + { + den.hosts.x86_64-linux = lib.genAttrs fleetNames (_: { + users.tux = { }; + }); + + den.policies.tools = + { host, ... }: [ (den.lib.policy.include { nixos.environment.variables.TOOLS = host.name; }) ]; + den.schema.host.includes = [ den.policies.tools ]; + + den.aspects.${excludedHost}.excludes = [ den.policies.tools ]; + + expr = { + # length ∘ filter forces EVERY non-excluded host's built config. + # builtins.all short-circuits on the first false and would silently + # time N=1 while claiming to have checked all of them. + built = builtins.length ( + lib.filter ( + name: (config.flake.nixosConfigurations.${name}.config.environment.variables.TOOLS or null) == name + ) (lib.filter (name: name != excludedHost) fleetNames) + ); + excluded = + config.flake.nixosConfigurations.${excludedHost}.config.environment.variables.TOOLS or "absent"; + # control, independent of `built`: every host actually registered + # a nixosConfiguration, regardless of whether its content landed + # correctly. If `built` fell short of `singles - 1` while `singles` + # still read `n`, that would mean hosts built but with wrong + # content — a different failure than hosts never building at all. + singles = builtins.length ( + lib.filter (name: config.flake.nixosConfigurations ? ${name}) fleetNames + ); + }; + expected = { + built = n - 1; + excluded = "absent"; + singles = n; + }; + } + ); + + }; +} From bcb84eeb4e432e43277ec0467790dfa509716044 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 8 Sep 2026 15:00:35 -0700 Subject: [PATCH 34/59] refactor: unify the three `_` constructions behind one mkUnderscore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The synthetic `_`/`provides` aspect was built three times in types.nix (root submodule, functor-carrying battery, nested freeform key), and the three had drifted apart on three axes with nothing in CI pinning any of the divergence. mkUnderscore own path replaces all three: `own` supplies both the child-key domain and the provides source, `path` names the synthetic aspect. Ruling: a key held both as a provides child and a direct key is included via its direct value — `_` is a total alias for `provides`, not a filtered view of it. Pinned at all three sites by a new deadbugs cell. --- nix/lib/aspects/types.nix | 154 +++++++++--------- .../underscore-shadow-key-included.nix | 90 ++++++++++ 2 files changed, 164 insertions(+), 80 deletions(-) create mode 100644 templates/ci/modules/features/deadbugs/underscore-shadow-key-included.nix diff --git a/nix/lib/aspects/types.nix b/nix/lib/aspects/types.nix index 3e404ffd9..2bfa27441 100644 --- a/nix/lib/aspects/types.nix +++ b/nix/lib/aspects/types.nix @@ -92,6 +92,51 @@ let else attrs; + # Registries are ambient — populated by battery modules and aspect-schema.nix, + # and identical at every site that builds a synthetic `_`. Not parameters. + classReg = den.classes or { }; + pipeReg = den.quirks or { }; + inherit (den.lib.aspects.fx.keyClassification) structuralKeysSet; + + # A key names a candidate child aspect when it is neither structural, + # internal, class, nor pipe. Provides children are reached through + # `provides`/`_`, which are structural, so this alone decides child-key + # membership — a key held both as a provides child and as a direct key is + # still a child key, included via its direct value (see mkUnderscore). + isChildKey = + k: + !(structuralKeysSet ? ${k}) && !(lib.hasPrefix "__" k) && !(classReg ? ${k}) && !(pipeReg ? ${k}); + + # The synthetic `_`/`provides` aspect, built once for all three shapes an + # aspect construction can take (declared submodule, functor-carrying + # battery, nested freeform key). `own` is the aspect's own attrset — it + # supplies both the child-key domain (`attrNames own`) and the provides + # source (`own.provides or { }`). `path` is the aspect's dotted position + # (`chain ++ [ localName ]`), naming the synthetic aspect "._". + # + # `_` is a total alias for `provides`: a key held both as a provides child + # and a direct key is included via its direct value, never excluded for + # being shadowed — excluding it would make `_` a filtered view of + # `provides` rather than another spelling of it. + mkUnderscore = + own: path: + let + providesChildren = lib.filterAttrs (k: _: !(structuralKeysSet ? ${k}) && !(lib.hasPrefix "__" k)) ( + own.provides or { } + ); + childKeys = builtins.filter isChildKey (builtins.attrNames own); + functor = { + __functor = _self: _args: { + name = "${lib.concatStringsSep "." path}._"; + includes = map (k: own.${k}) childKeys; + }; + }; + in + { + inherit providesChildren functor; + syntheticProvides = providesChildren // functor; + }; + aspectType = typeCfg: let @@ -153,33 +198,13 @@ let # key above, so masking it would classify neither: an aspect declaring # provides.user alongside user-class content would silently emit no # user class at all. - providesChildren = builtins.removeAttrs (merged.provides or { }) [ "_module" ]; - unshadowedProvides = builtins.filter (k: !(merged ? ${k})) (builtins.attrNames providesChildren); - # Child aspect keys for synthetic provides: freeform keys that are - # not structural, internal, class, pipe, or forwarded-from-provides. - classReg = den.classes or { }; - pipeReg = den.quirks or { }; - inherit (den.lib.aspects.fx.keyClassification) structuralKeysSet; - forwardedSet = lib.genAttrs (builtins.attrNames providesChildren) (_: true); aspectName = merged.name or (locName loc); - childKeys = builtins.filter ( - k: - !(structuralKeysSet ? ${k}) - && !(lib.hasPrefix "__" k) - && !(classReg ? ${k}) - && !(pipeReg ? ${k}) - && !(forwardedSet ? ${k}) - ) (builtins.attrNames merged); - syntheticAspect = { - name = "${aspectName}._"; - includes = map (k: merged.${k}) childKeys; - }; # __functor hides the synthetic aspect from attrValues while keeping _ # usable in an includes list: wrapChild sees a zero-arg functor and calls # it, so the aspect below is built only when _ is actually included. - syntheticProvides = providesChildren // { - __functor = _self: _args: syntheticAspect; - }; + underscore = mkUnderscore merged ((typeCfg.chain or typeCfg.origin) ++ [ aspectName ]); + inherit (underscore) providesChildren; + unshadowedProvides = builtins.filter (k: !(merged ? ${k})) (builtins.attrNames providesChildren); in # __functor makes merged aspects callable (aspect { host = ...; }). # Explicit functors (e.g. den.batteries.forward) take priority. @@ -188,8 +213,8 @@ let // { __functor = if originalFunctor != null then originalFunctor else resolveAspectWith; __providesForwarded = unshadowedProvides; - provides = syntheticProvides; - _ = syntheticProvides; + provides = underscore.syntheticProvides; + _ = underscore.syntheticProvides; }; aspectMeta = @@ -270,33 +295,14 @@ let # aspect.child both work, matching mergeWithAspectMeta behavior. let normalizedFn = foldUnderscoreIntoProvides fn; - providesChildren = builtins.removeAttrs (normalizedFn.provides or { }) [ "_module" ]; - classReg = den.classes or { }; - pipeReg = den.quirks or { }; - inherit (den.lib.aspects.fx.keyClassification) structuralKeysSet; - forwardedSet = lib.genAttrs (builtins.attrNames providesChildren) (_: true); - result = providesChildren // normalizedFn; aspectName = fn.name or (lib.last loc); - childKeys = builtins.filter ( - k: - !(structuralKeysSet ? ${k}) - && !(lib.hasPrefix "__" k) - && !(classReg ? ${k}) - && !(pipeReg ? ${k}) - && !(forwardedSet ? ${k}) - ) (builtins.attrNames result); - syntheticAspect = { - name = "${aspectName}._"; - includes = map (k: result.${k}) childKeys; - }; - syntheticProvides = providesChildren // { - __functor = _self: _args: syntheticAspect; - }; + underscore = mkUnderscore normalizedFn ((typeCfg.chain or typeCfg.origin) ++ [ aspectName ]); in - result + underscore.providesChildren + // normalizedFn // { - provides = syntheticProvides; - _ = syntheticProvides; + provides = underscore.syntheticProvides; + _ = underscore.syntheticProvides; } else let @@ -624,10 +630,6 @@ let # wrapper must still be invocable like the providerType path. singleFn = builtins.length flatDefs == 1 && lib.isFunction (builtins.head flatDefs).value; # Synthetic ._ for nested aspects — same semantics as root aspects. - # Collect forwarded child keys (exclude class, pipe, structural, internal). - classReg = den.classes or { }; - pipeReg = den.quirks or { }; - inherit (den.lib.aspects.fx.keyClassification) structuralKeysSet; # Forward provides children onto the wrapper so # aspect.child.monitoring resolves to aspect.child.provides.monitoring, # matching mergeWithAspectMeta behavior for root aspects — including @@ -640,32 +642,12 @@ let # every `_` write into `provides` on flatDefs above, so `merged` never # carries a `_` key for genuine writes and `merged.provides` alone is # the complete source, at every depth. - # - # Both spellings arrive as a content wrapper when the key is defined - # in more than one file, carrying `__contentValues` / `__aspectChain` - # / `_` alongside the real children. Those are wrapper machinery, not - # provides children: unfiltered they surface as `provides` keys and - # enter `__providesForwarded`. Filtering here covers both, and the - # single-def path is unaffected because a raw attrset carries none of - # these keys. - providesChildren = lib.filterAttrs (k: _: !(structuralKeysSet ? ${k}) && !(lib.hasPrefix "__" k)) ( - merged.provides or { } - ); - unshadowedProvides = builtins.filter (k: !(merged ? ${k})) (builtins.attrNames providesChildren); provider = (typeCfg.chain or typeCfg.origin) ++ [ keyName ]; - # A key names a candidate child aspect when it is neither structural, - # internal, class nor pipe. Provides children are reached through - # `provides`/`_`, which are structural — ._ never collects them. - isChildKey = - k: - !(structuralKeysSet ? ${k}) && !(lib.hasPrefix "__" k) && !(classReg ? ${k}) && !(pipeReg ? ${k}); - # The synthetic aspect behind ._ at a given tree position. - underscoreAt = provPath: attrs: { - __functor = _self: _args: { - name = "${lib.concatStringsSep "." provPath}._"; - includes = map (k: attrs.${k}) (builtins.filter isChildKey (builtins.attrNames attrs)); - }; - }; + # The synthetic aspect behind ._ at a given tree position — reused + # unqualified (no providesChildren fold) for every nested position by + # annotateChildren below; the top-level `provides`/`_` fold + # providesChildren in explicitly, matching mergeWithAspectMeta. + underscoreAt = provPath: attrs: (mkUnderscore attrs provPath).functor; # Annotate nested attrset children with __aspectChain so deeply nested # aspects carry provenance for hasAspect resolution, and give each # one its own ._ so the shorthand holds at every depth rather than @@ -694,6 +676,18 @@ let v ) attrs; annotatedMerged = annotateChildren provider merged; + # Both spellings arrive as a content wrapper when the key is defined + # in more than one file, carrying `__contentValues` / `__aspectChain` + # / `_` alongside the real children. Those are wrapper machinery, not + # provides children: unfiltered they surface as `provides` keys and + # enter `__providesForwarded`. Filtering here covers both, and the + # single-def path is unaffected because a raw attrset carries none of + # these keys. + topUnderscore = mkUnderscore annotatedMerged provider; + inherit (topUnderscore) providesChildren; + unshadowedProvides = builtins.filter (k: !(annotatedMerged ? ${k})) ( + builtins.attrNames providesChildren + ); in providesChildren // annotatedMerged @@ -705,8 +699,8 @@ let # children plus the all-children functor (mergeWithAspectMeta's # syntheticProvides). Match that here so the two spellings are # interchangeable for reading as well as writing, at any depth. - provides = providesChildren // underscoreAt provider annotatedMerged; - _ = providesChildren // underscoreAt provider annotatedMerged; + provides = topUnderscore.syntheticProvides; + _ = topUnderscore.syntheticProvides; } // lib.optionalAttrs singleFn { __functor = _self: (builtins.head flatDefs).value; diff --git a/templates/ci/modules/features/deadbugs/underscore-shadow-key-included.nix b/templates/ci/modules/features/deadbugs/underscore-shadow-key-included.nix new file mode 100644 index 000000000..ffcd51c3c --- /dev/null +++ b/templates/ci/modules/features/deadbugs/underscore-shadow-key-included.nix @@ -0,0 +1,90 @@ +# `_` is a total alias for `provides`, not a filtered view of it. Before the +# three `_` constructions in types.nix were unified behind one `mkUnderscore`, +# A (root) and B (functor-carrying batteries) excluded a key held both as a +# direct child and as a provides child from `_`'s includes, while C (nested +# freeform keys) included it — three constructions of one alias silently +# disagreeing, and nothing in CI asserted either answer. +# +# Ruling: include it, via its direct value. Excluding it would make `_` a +# filtered view of `provides` under one name — the defect class den has spent +# a week removing. This pins that ruling at all three construction sites so a +# future drift back to exclusion is caught rather than silently reintroduced. +{ denTest, ... }: +{ + flake.tests.deadbugs.underscore-shadow-key-included = { + + # Site A: root aspect (mergeWithAspectMeta). + test-shadow-key-included-via-underscore-root = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.shadowRoot = { + provides.shadow.nixos.environment.etc."prov-root".text = "y"; + shadow.nixos.environment.etc."direct-root".text = "y"; + }; + + den.aspects.igloo.includes = [ den.aspects.shadowRoot._ ]; + + expr = { + direct = igloo.environment.etc ? "direct-root"; + prov = igloo.environment.etc ? "prov-root"; + }; + expected = { + direct = true; + prov = false; + }; + } + ); + + # Site B: functor-carrying battery (mergeFunctions' functor branch). + test-shadow-key-included-via-underscore-battery = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.battHolder.provides.shadowBatt = { + __functor = self: args: { }; + shadow.nixos.environment.etc."direct-batt".text = "y"; + _.shadow.nixos.environment.etc."prov-batt".text = "y"; + }; + + den.aspects.igloo.includes = [ den.aspects.battHolder.shadowBatt._ ]; + + expr = { + direct = igloo.environment.etc ? "direct-batt"; + prov = igloo.environment.etc ? "prov-batt"; + }; + expected = { + direct = true; + prov = false; + }; + } + ); + + # Site C: nested freeform key (aspectContentType). + test-shadow-key-included-via-underscore-nested = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.shadowNested.holder = { + provides.shadow.nixos.environment.etc."prov-nested".text = "y"; + shadow.nixos.environment.etc."direct-nested".text = "y"; + }; + + den.aspects.igloo.includes = [ den.aspects.shadowNested.holder._ ]; + + expr = { + direct = igloo.environment.etc ? "direct-nested"; + prov = igloo.environment.etc ? "prov-nested"; + }; + expected = { + direct = true; + prov = false; + }; + } + ); + + }; +} From 3c5b52274a4a1ac6feee801d3df0856e61d81c6a Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 8 Sep 2026 15:15:10 -0700 Subject: [PATCH 35/59] fix: reject a bare string in an aspect's `excludes` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `excludes` typed as `listOf unspecified` accepted any value, including a plain string — which `identity.key` then reduces to "", matching no policy and silently excluding nothing. `includes` already rejects a bad value via `providerType`'s check (attrs, function, or a list of policy records); route `excludes` through the same type so both error alike. --- nix/lib/aspects/types.nix | 2 +- .../excludes-string-form-rejected.nix | 45 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 templates/ci/modules/deadbugs/excludes-string-form-rejected.nix diff --git a/nix/lib/aspects/types.nix b/nix/lib/aspects/types.nix index 2bfa27441..29d8221ab 100644 --- a/nix/lib/aspects/types.nix +++ b/nix/lib/aspects/types.nix @@ -871,7 +871,7 @@ let }; excludes = lib.mkOption { description = "Aspects or policies to exclude from this subtree"; - type = lib.types.listOf lib.types.unspecified; + type = lib.types.listOf (providerType (typeCfg // { chain = null; })); default = [ ]; }; provides = lib.mkOption { diff --git a/templates/ci/modules/deadbugs/excludes-string-form-rejected.nix b/templates/ci/modules/deadbugs/excludes-string-form-rejected.nix new file mode 100644 index 000000000..4a9163a3d --- /dev/null +++ b/templates/ci/modules/deadbugs/excludes-string-form-rejected.nix @@ -0,0 +1,45 @@ +{ denTest, ... }: +{ + flake.tests.excludes-string-form-rejected = { + + # D2: `excludes` used to accept a bare string with `lib.types.unspecified` + # and silently exclude nothing (`identity.key` on a string yields + # "", which matches no policy). `includes` already rejects a bare + # string via `providerType`'s check; `excludes` must error the same way. + test-excludes-string-form-errors = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.aspects.igloo.excludes = [ "some-name" ]; + + expr = igloo.networking.hostName; + expectedError = { + type = "ThrownError"; + msg = "is not of type `aspect or function returning aspect'"; + }; + } + ); + + # Uncontested-policy control: a legitimate record-form exclude must still + # fire, so the cell above isn't vacuous (everything erroring would look + # identical to the fix working). + test-excludes-record-form-still-fires = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.policies.d2-marker = _: [ + (den.lib.policy.include { + nixos.environment.variables.D2_MARKER = "yes"; + }) + ]; + den.aspects.igloo = { + includes = [ den.policies.d2-marker ]; + excludes = [ den.policies.d2-marker ]; + }; + + expr = igloo.environment.variables.D2_MARKER or "absent"; + expected = "absent"; + } + ); + }; +} From 1dba9f1c363ef366f370246be2918d26900a94e1 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 8 Sep 2026 15:29:17 -0700 Subject: [PATCH 36/59] fix: remove the resolve.nix edge-oracle bindings orphaned by the equivalence-oracle retirement dec61330 deleted unifiedEdges/legacyEdgeTrace/materializeEquiv but left resolve.nix importing routeEdges, extractEdgeTrace, assembleSubtree, applyProvidesEdges, dedupProvides, and providesEdges without calling any of them, plus a dead local applyRoutes wrapper. Removing those wrappers orphaned their own single callers: edges/route.nix's applyRoutes fold and edges/provides.nix's applyProvidesEdges fold (both superseded by materializeUnified's interleaved dispatch) and edge-trace.nix's extractEdgeTrace (the differential suite it served was already deleted). Verified each by repo-wide git grep -w before removal. --- nix/lib/aspects/fx/edge-trace.nix | 54 ++++++--------------- nix/lib/aspects/fx/edges/provides.nix | 18 ++----- nix/lib/aspects/fx/edges/route.nix | 70 ++++----------------------- nix/lib/aspects/fx/resolve.nix | 35 ++------------ 4 files changed, 32 insertions(+), 145 deletions(-) diff --git a/nix/lib/aspects/fx/edge-trace.nix b/nix/lib/aspects/fx/edge-trace.nix index f243abbd0..a0be6e73d 100644 --- a/nix/lib/aspects/fx/edge-trace.nix +++ b/nix/lib/aspects/fx/edge-trace.nix @@ -1,15 +1,11 @@ -# edge-trace.nix — the LEGACY end-state re-derivation of the pipeline's delivery -# decisions as a normalized, stably-sorted edge list. As of Task 18 this is NO -# LONGER the live trace: the live `edgeTrace` is the CAPTURED production edge -# object (resolve.nix — its fold-ordered provides+routes come straight from the -# production materializeUnified folds). This `extractEdgeTrace` is retained and -# surfaced as `legacyEdgeTrace` ONLY as the legacy arm of the oracle≡production -# DIFFERENTIAL (templates/ci/.../fx-oracle-production-differential.nix): it -# re-derives the edge set from END-STATE, INCLUDING the spawn `rewalk` arm (the -# undercount the production object eliminates) and the dedup-`suppressed` route -# twins (which production never folds). It was the migration oracle for the -# Phase-2 port (spec 2026-06-12 §3a); post-Task-18 its job is to prove, by diff, -# that production dropped exactly the rewalk undercount + suppressed twins. +# edge-trace.nix — the per-component edge-list constructors shared between +# production and the read-only oracle. The live `edgeTrace` (resolve.nix) is the +# CAPTURED production edge object: its fold-ordered provides+routes come straight +# from the production materializeUnified folds. `extractTopLevelEdges` below +# supplies the top-level mechanism lists (default fold, provides, routes, spawns, +# instantiates) both resolve.nix's production object and any end-state oracle +# consume — the retired full-union oracle (`extractEdgeTrace`, the legacy arm of +# the oracle≡production differential) was removed once that proof was complete. # # All edge kinds (default folds, simple + complex routes, provides, spawns, # instantiates) render through the SAME constructors production materializes @@ -77,13 +73,12 @@ let in rec { # extractTopLevelEdges: pipeline end-state → the per-COMPONENT edge lists, - # UNSORTED. The shared seam between the read-only oracle (extractEdgeTrace, - # which sorts the union) and the production unifiedEdges collector (resolve.nix), - # which wants the SAME top-level mechanism lists but drops the `spawnEdges` - # rewalk arm (it surfaces the real spawn edges from the drain-fold instead) and - # adds the per-host / B′ instantiate edges. Both consume the EXACT SAME - # constructor calls over the SAME end-state, so oracle and production can never - # diverge on the top-level set (spec §3a). + # UNSORTED. Consumed by resolve.nix's production edge trace, which wants the + # SAME top-level mechanism lists but drops the `spawnEdges` rewalk arm (it + # surfaces the real spawn edges from the drain-fold instead) and adds the + # per-host / B′ instantiate edges. Production consumes the EXACT SAME + # constructor calls over the SAME end-state as the trace-facing constructors + # (routeEdges, providesEdges below) it shares with them (spec §3a). extractTopLevelEdges = { scopeContexts, @@ -245,24 +240,7 @@ rec { ; }; - # extractEdgeTrace: pipeline end-state → stably-sorted normalized edge list. - # The oracle's union INCLUDES the spawn `rewalk` arm (one rewalk edge per spawn - # marker — the undercount the unifiedEdges collector corrects by surfacing the - # spawn's real edge set instead). - extractEdgeTrace = - args: - let - parts = extractTopLevelEdges args; - in - sortEdges ( - parts.defaultFold - ++ parts.providesEdgeList - ++ parts.routeEdgeList - ++ parts.spawnEdges - ++ parts.instantiateEdgeList - ); - - # Re-exported so resolve.nix's unifiedEdges can sort its union without a - # second import of edges/edge.nix. + # Re-exported so resolve.nix's production edge trace can sort its union + # without a second import of edges/edge.nix. inherit sortEdges; } diff --git a/nix/lib/aspects/fx/edges/provides.nix b/nix/lib/aspects/fx/edges/provides.nix index a74405db6..a22deb8db 100644 --- a/nix/lib/aspects/fx/edges/provides.nix +++ b/nix/lib/aspects/fx/edges/provides.nix @@ -24,7 +24,7 @@ # - 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. +# bucket) consumed by materializeUnified's ordered-dispatch fold. { lib, den }: let inherit (import ./edge.nix { inherit lib; }) mkEdge collected rootTarget; @@ -51,11 +51,9 @@ let # ctx — the pipeline base ctx (the wrap context for every provide). # scopedProvides — sid → [ provide specs ] (the registered provides). # acc — { classImports; perScope; } (phase-1 output). - # Materialize ONE provides spec onto the accumulator. Factored out of the - # applyProvidesEdges fold (additive) so a single provides spec can be - # materialized in interleaved order by materializeUnified (Task 17) — the per- - # spec body is IDENTICAL, so applyProvidesEdges (= foldl' applyOneProvide) and - # the interleaved fold land byte-identical content. + # Materialize ONE provides spec onto the accumulator. Consumed in interleaved + # order by materializeUnified's ordered-dispatch fold (edges/materialize- + # unified.nix), which folds provides and routes together. applyOneProvide = ctx: prev: spec: let @@ -91,13 +89,6 @@ let }; }; - applyProvidesEdges = - ctx: scopedProvides: acc: - let - allProvides = dedupProvides (lib.concatLists (lib.attrValues scopedProvides)); - in - builtins.foldl' (applyOneProvide ctx) 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 @@ -135,7 +126,6 @@ in inherit dedupProvides applyOneProvide - applyProvidesEdges providesEdges ; } diff --git a/nix/lib/aspects/fx/edges/route.nix b/nix/lib/aspects/fx/edges/route.nix index c396a24cc..de2012328 100644 --- a/nix/lib/aspects/fx/edges/route.nix +++ b/nix/lib/aspects/fx/edges/route.nix @@ -7,15 +7,15 @@ # hybrids decompose into mode + properties. # # 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). +# and COMPLEX (__complexForward) routes (synthesize edges, §B Decision 2). +# materializeUnified (edges/materialize-unified.nix) dispatches between them per +# route, interleaved with provides in one ordered-dispatch fold. # # 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 the `applyRoutes` fold (applySimpleRouteEdge / applyComplexRouteEdge). +# by materializeUnified's 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. # @@ -724,67 +724,15 @@ let 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 ? { }, - scopeEntityKind, - 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 - scopeEntityKind - spawnNode - buildForwardAspect - ; - } - else - applySimpleRouteEdge acc { - inherit - route - wrappedPerScope - scopeParent - scopeIsolated - ; - } - ) - { - inherit classImports; - perScope = wrappedPerScope; - } - allRoutes; in { - # The resolver (applyRoutes) and the read-only oracle (materializeRouteEdge, - # routeEdges below) consume route.nix; the per-spec materializers + ordering - # helpers are ALSO surfaced (additive) so materializeUnified (Task 17) can - # interleave provides + routes in one ordered-dispatch fold while reusing the - # EXACT per-spec materialization applyRoutes uses. + # The read-only oracle (materializeRouteEdge, routeEdges below) and + # materializeUnified (edges/materialize-unified.nix) consume route.nix: the + # per-spec materializers + ordering helpers are surfaced so materializeUnified + # can interleave provides + routes in one ordered-dispatch fold, reusing the + # EXACT per-spec materialization these implement. inherit materializeRouteEdge - applyRoutes applyComplexRouteEdge applySimpleRouteEdge classifyRoute diff --git a/nix/lib/aspects/fx/resolve.nix b/nix/lib/aspects/fx/resolve.nix index afd5c6697..e00588f9b 100644 --- a/nix/lib/aspects/fx/resolve.nix +++ b/nix/lib/aspects/fx/resolve.nix @@ -9,22 +9,14 @@ 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; - routeEdges = import ./edges/route.nix { inherit lib den; }; inherit (import ./edge-trace.nix { inherit lib den; }) - extractEdgeTrace extractTopLevelEdges sortEdges ; inherit (import ./scope-walk.nix { inherit lib; }) subtreeScopes dedupByKey; - inherit (import ./edges/materialize.nix { inherit lib den; }) assembleSubtree; inherit (import ./edges/pi.nix { inherit lib; }) mkStaticPi; inherit (import ./edges/instantiate-edges.nix { inherit lib den; }) mkInstantiateEdges; inherit (import ./edges/edge.nix { inherit lib; }) scopeName edgeSortKey; - inherit (import ./edges/provides.nix { inherit lib den; }) - applyProvidesEdges - dedupProvides - providesEdges - ; inherit (import ./edges/materialize-unified.nix { inherit lib den; }) materializeUnified; instantiateEdges = import ./edges/instantiate.nix { inherit lib; }; handlers = den.lib.aspects.fx.handlers; @@ -65,30 +57,9 @@ let perScope = wrappedPerScope; }; - # 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 - # complex-route forward SOURCE with full fleet visibility (replaces the old - # isolated fxResolve fallback). - applyRoutes = - spawnNode: ctx: scopeContexts: rootScopeId: scopeParent: scopeIsolated: scopeEntityKind: scopedRoutes: acc: - routeEdges.applyRoutes { - inherit - scopedRoutes - scopeContexts - scopeParent - scopeIsolated - scopeEntityKind - rootScopeId - spawnNode - ; - wrappedPerScope = acc.perScope; - classImports = acc.classImports; - inherit (handlers) buildForwardAspect; - }; + # Phase 2 (policy.provide) and phase 3 (routes) are both edge constructors now, + # interleaved by ONE ordered-dispatch fold (edges/materialize-unified.nix + # materializeUnified) — there is no standalone phase2/phase3 fold in this file. # Phase 4: Apply entity instantiation. # Resolve the entity scope an instantiate spec targets. From 2bedbaa7eeeb55721006d73a620d64213d54f478 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 8 Sep 2026 15:57:44 -0700 Subject: [PATCH 37/59] fix: drain a pipe-arg-deferred aspect-level include by re-entering the pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mkDrained's post-assembly drain flat-lifted keys off the deferred child's unapplied parametric shape, which is always empty — its content lives inside __fn until bind resolves it. Walk each drainable child through mkPipeline instead, mirroring scope-widen.nix's in-pipeline drain, and fold its scopedClassImports into the drain scope. A walk that fans into more than one scope throws rather than silently collapsing the fan (D1 §4.2, arm B). Lands the three D1 oracle suites (d1probe, d1matrix, d1cycle) as deadbugs. Per D1 §4.1, d1probe's arm 5 (pipe-deferred nested aspect key) ships as an invariance cell paired with a plain non-deferred twin (arm 5b) rather than a claimed fix — a nested aspect key never auto-walks, deferred or not. --- nix/lib/aspects/fx/resolve.nix | 71 ++++++----- templates/ci/modules/deadbugs/d1-cycle.nix | 115 +++++++++++++++++ .../deadbugs/d1-deferred-aspect-include.nix | 117 ++++++++++++++++++ templates/ci/modules/deadbugs/d1-matrix.nix | 101 +++++++++++++++ 4 files changed, 371 insertions(+), 33 deletions(-) create mode 100644 templates/ci/modules/deadbugs/d1-cycle.nix create mode 100644 templates/ci/modules/deadbugs/d1-deferred-aspect-include.nix create mode 100644 templates/ci/modules/deadbugs/d1-matrix.nix diff --git a/nix/lib/aspects/fx/resolve.nix b/nix/lib/aspects/fx/resolve.nix index e00588f9b..c8c78d4f1 100644 --- a/nix/lib/aspects/fx/resolve.nix +++ b/nix/lib/aspects/fx/resolve.nix @@ -664,8 +664,6 @@ let augmentedContexts: let allDeferred = (result.state.scopedDeferredIncludes or (_: { })) null; - inherit (den.lib.aspects.fx.keyClassification) classifyKeys; - inherit (den.lib.aspects.fx.contentUtil) unwrapContentValuesList unwrapContentValuesAll; # Build enriched context for a scope by inheriting parent enrichment. # Walks up scopeParent to find enrichment keys not present in the # scope's own context. @@ -706,46 +704,53 @@ let accImports else let - newEntries = lib.concatMap ( + # Re-enter the pipeline for each drainable child, mirroring + # scope-widen.nix's in-pipeline drain. A flat key-lift off + # `d.child` cannot work here: every drainable entry is a + # parametric aspect (compile.nix routes __fn/__args-bearing + # aspects to compile-parametric, the only sender of "bind", + # the only sender of "defer"), so its content lives inside + # `__fn` and its own top-level keys are all structural. + walkedBuckets = lib.concatMap ( d: let - child = d.child; - classified = classifyKeys null child; + walked = mkPipeline { inherit class; } { + self = d.child; + ctx = scopeCtx; + }; + # The walk roots at its OWN scope id (mkScopeId hashes + # every ctx key, and scopeCtx carries the pipe value the + # child required), never the draining scope's id — so + # its buckets are folded under `scopeId` explicitly + # below rather than merged in by scope id. + walkedScopeIds = builtins.attrNames (walked.state.scopeContexts null); in - lib.concatMap ( - k: - let - isPipe = den.quirks ? ${k}; - # Pipe keys keep one entry per definition (quirks accumulate); - # class keys collapse into a single module (the module system merges). - modules = if isPipe then unwrapContentValuesAll child.${k} else unwrapContentValuesList child.${k}; - in - map ( - module: - { - __rawEntry = true; - class = k; - inherit module; - ctx = scopeCtx; - identity = child.name or ""; - aspectPolicy = child.meta.collisionPolicy or null; - globalPolicy = den.config.classModuleCollisionPolicy or "error"; - isContextDependent = false; - } - // lib.optionalAttrs isPipe { __isPipeEntry = true; } - ) modules - ) (classified.classKeys ++ classified.pipeKeys) + if builtins.length walkedScopeIds > 1 then + # A deferred child whose own `includes` fan over an entity + # arg (or that carries a `resolve.to`) pushes real child + # scopes of its own. Collapsing those into the draining + # scope would hoist their content across scope isolation + # silently; no measured shape reaches this today, so throw + # loud rather than guess (D1 §4.2). + throw + "den: pipe-arg-deferred include '${d.child.name or ""}' fanned into ${toString (builtins.length walkedScopeIds)} scopes (${lib.concatStringsSep ", " walkedScopeIds}) while draining at scope '${scopeId}' — folding a fanned child's scopes into the drain scope is not supported" + else + lib.attrValues (walked.state.scopedClassImports null) ) drainable; in builtins.foldl' ( - acc: entry: + acc: byClass: acc // { - ${scopeId} = (acc.${scopeId} or { }) // { - ${entry.class} = ((acc.${scopeId} or { }).${entry.class} or [ ]) ++ [ entry ]; - }; + ${scopeId} = builtins.foldl' ( + a: cls: + a + // { + ${cls} = (a.${cls} or [ ]) ++ byClass.${cls}; + } + ) (acc.${scopeId} or { }) (builtins.attrNames byClass); } - ) accImports newEntries + ) accImports walkedBuckets ) importsForPipes (builtins.attrNames allDeferred); # Materialize deferred node spawn markers (policy.spawn) over the diff --git a/templates/ci/modules/deadbugs/d1-cycle.nix b/templates/ci/modules/deadbugs/d1-cycle.nix new file mode 100644 index 000000000..2d413f559 --- /dev/null +++ b/templates/ci/modules/deadbugs/d1-cycle.nix @@ -0,0 +1,115 @@ +{ denTest, ... }: +let + # Cycle probe for the D1 fix: does a pipe-arg-deferred aspect-level include, + # drained by a WALK inside mkDrained, re-enter the hostConfigs (B′) build? + # + # Shape is bprime-basedrain-crosshost's, with the peer's config content moved + # behind a pipe-arg-deferred include. igloo's cross-host collectAll forces + # hostConfigs, which consumes `drainedForHostConfigs` — the mkDrained + # invocation over the hostConfigs-NULL contexts. If a walk there cycles, this + # cell throws infinite recursion rather than failing a comparison. + fixture = + den: + 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."; + + 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 = { }; + + den.aspects.igloo.includes = [ den.policies.collect-marks ]; + + den.aspects.iceberg.includes = [ + # Live control, same run, same includes list: a plain aspect-level + # include. Its port must be present or the instrument is broken. + den.aspects.cycle-plain + # The D1 shape: pipe-arg deferred, nested include. + ( + { feat, ... }: + { + name = "cycle-deferred-parent"; + includes = [ den.aspects.cycle-deferred-leaf ]; + } + ) + ]; + den.aspects.cycle-plain.nixos.networking.firewall.allowedTCPPorts = [ 10190 ]; + den.aspects.cycle-deferred-leaf.nixos.networking.firewall.allowedTCPPorts = [ 10180 ]; + + # Each host emits a config-dependent mark. This is what forces hostConfigs + # (B′) to build the PEER's config — the path that reads + # `drainedForHostConfigs`. + den.aspects.igloo.host-marks = { config, ... }: [ "igloo" ]; + den.aspects.iceberg.host-marks = + { config, ... }: + let + ports = config.networking.firewall.allowedTCPPorts; + has = p: builtins.elem p ports; + in + [ + ( + "iceberg" + + (if has 10190 then "-plain" else "") + + (if has 10180 then "-deferred" else "") + + (if has 19999 then "-NEGCONTROL" else "") + ) + ]; + + den.aspects.igloo.nixos = + { + host-marks, + lib, + ... + }: + { + networking.search = lib.sort (a: b: a < b) host-marks; + }; + }; +in +{ + flake.tests.d1cycle = { + # RED on stock: [ "iceberg-plain" "igloo" ] — the control fires, the + # deferred include delivers nothing. + # GREEN with the drain walk: [ "iceberg-plain-deferred" "igloo" ]. + test-crosshost-peer-config-from-deferred-include = denTest ( + { den, igloo, ... }: + (fixture den) + // { + expr = igloo.networking.search; + expected = [ + "iceberg-plain-deferred" + "igloo" + ]; + } + ); + + # Same fixture, the control arm alone: green on stock AND after, so a red + # above is the deferred include and not the cross-host plumbing. + test-control-plain-include-reaches-peer-config = denTest ( + { den, igloo, ... }: + (fixture den) + // { + expr = builtins.any (m: builtins.match ".*-plain.*" m != null) igloo.networking.search; + expected = true; + } + ); + + # Negative control: the marker for a port nothing declares must never show. + test-negcontrol-undeclared-port-absent = denTest ( + { den, igloo, ... }: + (fixture den) + // { + expr = builtins.any (m: builtins.match ".*NEGCONTROL.*" m != null) igloo.networking.search; + expected = false; + } + ); + }; +} diff --git a/templates/ci/modules/deadbugs/d1-deferred-aspect-include.nix b/templates/ci/modules/deadbugs/d1-deferred-aspect-include.nix new file mode 100644 index 000000000..10edc0dc3 --- /dev/null +++ b/templates/ci/modules/deadbugs/d1-deferred-aspect-include.nix @@ -0,0 +1,117 @@ +{ denTest, ... }: +let + # One fixture, five arms, read through distinct TCP ports so every arm is a + # boolean presence test rather than a diff-renderer read. + fixture = den: { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.quirks.firewall.description = "Firewall port declarations"; + + den.aspects.igloo = { + firewall.ports = [ 22 ]; + includes = [ + # ARM 1 (control) — plain aspect-level include, nested includes. + den.aspects.plain-parent + + # ARM 2 (D1) — pipe-arg deferred aspect-level include, nested includes. + ( + { firewall, ... }: + { + name = "d1-deferred-parent"; + includes = [ den.aspects.deferred-leaf ]; + } + ) + + # ARM 3 — pipe-arg deferred aspect-level include, DIRECT class content. + ( + { firewall, ... }: + { + name = "d1-deferred-direct"; + nixos.networking.firewall.allowedTCPPorts = [ 10030 ]; + } + ) + + # ARM 4 — aspect-level include that IS a function but binds + # synchronously (host is in scope ctx), nested includes. + ( + { host, ... }: + { + name = "d1-sync-fn-parent"; + includes = [ den.aspects.sync-fn-leaf ]; + } + ) + + # ARM 5 — pipe-arg deferred aspect-level include, NESTED aspect key. + # NOT a D1 instance: a nested aspect key never auto-walks, deferred + # or not (compile-static.nix's own comment says so). Paired below + # with ARM 5B, its non-deferred twin, as an invariance cell — both + # must read false, and a future change that starts auto-walking + # nested keys flips both together. + ( + { firewall, ... }: + { + name = "d1-deferred-nestedkey"; + sub.nixos.networking.firewall.allowedTCPPorts = [ 10050 ]; + } + ) + + # ARM 5B — plain (non-deferred) aspect-level include, NESTED aspect + # key. The invariance twin of ARM 5. + { + name = "plain-nestedkey-parent"; + sub.nixos.networking.firewall.allowedTCPPorts = [ 10060 ]; + } + ]; + }; + + den.aspects.plain-parent.includes = [ den.aspects.plain-leaf ]; + den.aspects.plain-leaf.nixos.networking.firewall.allowedTCPPorts = [ 10010 ]; + den.aspects.deferred-leaf.nixos.networking.firewall.allowedTCPPorts = [ 10020 ]; + den.aspects.sync-fn-leaf.nixos.networking.firewall.allowedTCPPorts = [ 10040 ]; + }; + + arm = + port: + denTest ( + { den, igloo, ... }: + (fixture den) + // { + expr = builtins.elem port igloo.networking.firewall.allowedTCPPorts; + expected = true; + } + ); + + # Invariance cells: the port must NEVER appear (nested keys never + # auto-walk), as opposed to the negative control below (a port nothing + # declares at all). + armFalse = + port: + denTest ( + { den, igloo, ... }: + (fixture den) + // { + expr = builtins.elem port igloo.networking.firewall.allowedTCPPorts; + expected = false; + } + ); +in +{ + flake.tests.d1probe = { + test-arm1-control-plain-nested = arm 10010; + test-arm2-deferred-nested-includes = arm 10020; + test-arm3-deferred-direct-class = arm 10030; + test-arm4-syncfn-nested-includes = arm 10040; + test-arm5-deferred-nested-key = armFalse 10050; + test-arm5b-plain-nestedkey-invariance = armFalse 10060; + + # Negative control: a port nothing declares must read absent, proving the + # predicate is not stuck at true. + test-negcontrol-undeclared-port = denTest ( + { den, igloo, ... }: + (fixture den) + // { + expr = builtins.elem 19999 igloo.networking.firewall.allowedTCPPorts; + expected = false; + } + ); + }; +} diff --git a/templates/ci/modules/deadbugs/d1-matrix.nix b/templates/ci/modules/deadbugs/d1-matrix.nix new file mode 100644 index 000000000..719e28a43 --- /dev/null +++ b/templates/ci/modules/deadbugs/d1-matrix.nix @@ -0,0 +1,101 @@ +{ denTest, ... }: +let + # Two axes crossed: WHICH ARG defers (policy-enrichment vs pipe) x WHERE the + # function sits (named aspect vs inline lambda at the includes position) x + # WHAT the body holds (direct class content vs nested includes). + fixture = den: { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.quirks.firewall.description = "Firewall port declarations"; + + den.policies.host-guards = + { host, ... }: [ (den.lib.policy.resolve { isNixos = host.class == "nixos"; }) ]; + den.default.includes = [ den.policies.host-guards ]; + + den.aspects.igloo = { + firewall.ports = [ 22 ]; + includes = [ + den.aspects.named-enrich-direct + den.aspects.named-pipe-direct + ( + { isNixos, ... }: + { + name = "inline-enrich-direct"; + nixos.networking.firewall.allowedTCPPorts = [ 10130 ]; + } + ) + ( + { firewall, ... }: + { + name = "inline-pipe-direct"; + nixos.networking.firewall.allowedTCPPorts = [ 10140 ]; + } + ) + den.aspects.named-enrich-nested + den.aspects.named-pipe-nested + den.aspects.static-pipe-class-fn + ]; + }; + + den.aspects.named-enrich-direct = + { isNixos, ... }: + { + nixos.networking.firewall.allowedTCPPorts = [ 10110 ]; + }; + den.aspects.named-pipe-direct = + { firewall, ... }: + { + nixos.networking.firewall.allowedTCPPorts = [ 10120 ]; + }; + den.aspects.named-enrich-nested = + { isNixos, ... }: + { + includes = [ den.aspects.enrich-nested-leaf ]; + }; + den.aspects.named-pipe-nested = + { firewall, ... }: + { + includes = [ den.aspects.pipe-nested-leaf ]; + }; + den.aspects.enrich-nested-leaf.nixos.networking.firewall.allowedTCPPorts = [ 10150 ]; + den.aspects.pipe-nested-leaf.nixos.networking.firewall.allowedTCPPorts = [ 10160 ]; + + # The shape the corpus already uses for pipe args: a STATIC aspect whose + # class-key VALUE is the pipe-arg function. + den.aspects.static-pipe-class-fn.nixos = + { firewall, ... }: + { + networking.firewall.allowedTCPPorts = [ 10170 ]; + }; + }; + + arm = + port: + denTest ( + { den, igloo, ... }: + (fixture den) + // { + expr = builtins.elem port igloo.networking.firewall.allowedTCPPorts; + expected = true; + } + ); +in +{ + flake.tests.d1matrix = { + test-named-enrich-direct = arm 10110; + test-named-pipe-direct = arm 10120; + test-inline-enrich-direct = arm 10130; + test-inline-pipe-direct = arm 10140; + test-named-enrich-nested = arm 10150; + test-named-pipe-nested = arm 10160; + test-static-pipe-class-fn = arm 10170; + + test-negcontrol-undeclared-port = denTest ( + { den, igloo, ... }: + (fixture den) + // { + expr = builtins.elem 19999 igloo.networking.firewall.allowedTCPPorts; + expected = false; + } + ); + }; +} From 7be57684a4398fbcc688167cba3c2734ace69bfd Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 8 Sep 2026 17:53:59 -0700 Subject: [PATCH 38/59] fix: reject a bare string in a schema-tier excludes list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `den.schema..excludes` accepted a bare string and silently excluded nothing — identity.key reduces a string to "", which matches no policy. Same defect as den.aspects.*.excludes before 3c5b5227, one tier up: that fix routed the aspect-tier field through providerType, but gen-schema's freeform collections have no per-collection type to route this through, so it reached every schema kind unchecked. Validate in the collection's own merge instead, at declaration time like the aspect-tier fix. --- modules/options.nix | 19 +++++ ...ema-tier-excludes-string-form-rejected.nix | 70 +++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 templates/ci/modules/features/schema-tier-excludes-string-form-rejected.nix diff --git a/modules/options.nix b/modules/options.nix index ea8a1f77f..fed55155b 100644 --- a/modules/options.nix +++ b/modules/options.nix @@ -77,6 +77,25 @@ in }; excludes = { default = [ ]; + # Bare-string elements used to be accepted and silently exclude + # nothing: `identity.key` (nix/lib/aspects/fx/identity.nix) reduces a + # string to "", which matches no policy. gen-schema has no + # per-collection `type` to route this through, so validate here — + # the same defect at the aspect tier (den.aspects.*.excludes) was + # fixed by routing it through a type; this is the equivalent + # declaration-time check for the untyped schema-tier collection. + merge = + acc: val: + acc + ++ map ( + v: + if builtins.isAttrs v then + v + else + throw "den: den.schema..excludes: expected a policy or aspect reference, got ${ + if builtins.isString v then ''"${v}"'' else builtins.typeOf v + }" + ) val; }; isEntity = { default = false; diff --git a/templates/ci/modules/features/schema-tier-excludes-string-form-rejected.nix b/templates/ci/modules/features/schema-tier-excludes-string-form-rejected.nix new file mode 100644 index 000000000..8229b5f26 --- /dev/null +++ b/templates/ci/modules/features/schema-tier-excludes-string-form-rejected.nix @@ -0,0 +1,70 @@ +# `den.schema..excludes` used to accept a bare string and silently +# exclude nothing — same defect as `den.aspects.*.excludes` before +# 3c5b5227, one tier up, and reaching every schema kind (gen-schema has no +# per-collection `type` for this untyped collection). See +# `excludes-string-form-rejected.nix` for the aspect-tier counterpart. +{ denTest, ... }: +{ + flake.tests.schema-tier-excludes-string-form-rejected = { + + test-schema-excludes-string-form-errors = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.policies.s2-marker = _: [ + (den.lib.policy.include { + nixos.environment.variables.S2_MARKER = "yes"; + }) + ]; + den.aspects.igloo.includes = [ den.policies.s2-marker ]; + den.schema.host.excludes = [ "s2-marker" ]; + + expr = igloo.networking.hostName; + expectedError = { + type = "ThrownError"; + msg = "den: den.schema..excludes"; + }; + } + ); + + # Uncontested-policy control: a legitimate record-form exclude must + # still fire, so the cell above isn't vacuous (everything erroring would + # look identical to the fix working). + test-schema-excludes-record-form-still-fires = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.policies.s2-marker-b = _: [ + (den.lib.policy.include { + nixos.environment.variables.S2_MARKER_B = "yes"; + }) + ]; + den.aspects.igloo.includes = [ den.policies.s2-marker-b ]; + den.schema.host.excludes = [ den.policies.s2-marker-b ]; + + expr = igloo.environment.variables.S2_MARKER_B or "absent"; + expected = "absent"; + } + ); + + # Breadth: the defect reached every schema kind, not just `host`. + test-schema-excludes-string-form-errors-on-user-kind = denTest ( + { den, tuxHm, ... }: + { + den.policies.s2-marker-c = _: [ + (den.lib.policy.include { + homeManager.home.sessionVariables.S2_MARKER_C = "yes"; + }) + ]; + den.hosts.x86_64-linux.igloo.users.tux.aspect.includes = [ den.policies.s2-marker-c ]; + den.schema.user.excludes = [ "s2-marker-c" ]; + + expr = tuxHm.home.sessionVariables or { }; + expectedError = { + type = "ThrownError"; + msg = "den: den.schema..excludes"; + }; + } + ); + }; +} From 7973c8a839c8ed96287ae3ff6988f16e44f4c984 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 8 Sep 2026 18:59:07 -0700 Subject: [PATCH 39/59] fix: let an aliased aspect's authored name outrank its nested position wrapperToAspect stamped both name and meta.aspect-chain onto every content wrapper it produced, including one whose value it does not own -- an aspect assigned to a nested key from another aspect's own name+chain (den.aspects. group.key = den.aspects.other;). That discarded the author's own name and identity for the position it happened to be written at, so hasAspect and .identity disagreed with each other for a value that is present and emitting, with no diagnostic. Fill only the components the value does not already carry: skip the name when it is present and meaningful (isMeaningfulName, matching normalize.nix and has-aspect.nix's isNamed), and skip the chain when meta.aspect-chain is already set. Guarding the name alone is insufficient -- it leaves the chain null, handing the identity to the inclusion walk instead of the authoring position, so the same value included by two owners lands under two prefixes. Den's own sentinel names ("", ...) are not an author's name and stay overridable by position. Extends test-alias-merged-aspect to assert identity, not only delivery, and adds three new deadbugs cells covering both wrapperToAspect attribution sites (depth-1 provider, depth-2 annotateChildren), the sentinel-name carve-out, and a regression fence over raw-value equality (compile-static's chain fill is unaffected since the mechanism always leaves meta.aspect-chain non-null). --- nix/lib/aspects/types.nix | 20 +- .../deadbugs/nested-key-wrapper-identity.nix | 173 +++++++++++++++++- 2 files changed, 185 insertions(+), 8 deletions(-) diff --git a/nix/lib/aspects/types.nix b/nix/lib/aspects/types.nix index 29d8221ab..489d1eee0 100644 --- a/nix/lib/aspects/types.nix +++ b/nix/lib/aspects/types.nix @@ -430,11 +430,21 @@ let } # Preserve identity: inject name and provider chain from # __aspectChain so aspectSubmodule.merge produces a meaningful - # identity instead of an anonymous include index. - // lib.optionalAttrs (provName != null) { - name = provName; - meta.aspect-chain = lib.init d.value.__aspectChain; - }; + # identity instead of an anonymous include index. Fill only + # what the value does not already carry — an aliased aspect + # (den.aspects.group.key = den.aspects.other;) owns a + # meaningful name and chain of its own, and an author's name + # outranks its position here. + // lib.optionalAttrs (provName != null) ( + lib.optionalAttrs (!(d.value ? name) || !(isMeaningfulName d.value.name)) { + name = provName; + } + // lib.optionalAttrs ((d.value.meta.aspect-chain or null) == null) { + meta = (d.value.meta or { }) // { + aspect-chain = lib.init d.value.__aspectChain; + }; + } + ); }; defs' = map (d: if isContentWrapper d then wrapperToAspect d else d) (map stampDefPos defs); listDefs = builtins.filter (d: builtins.isList d.value) defs'; diff --git a/templates/ci/modules/deadbugs/nested-key-wrapper-identity.nix b/templates/ci/modules/deadbugs/nested-key-wrapper-identity.nix index 527ae8751..e3ccbd754 100644 --- a/templates/ci/modules/deadbugs/nested-key-wrapper-identity.nix +++ b/templates/ci/modules/deadbugs/nested-key-wrapper-identity.nix @@ -150,6 +150,13 @@ # Aliasing a merged aspect: every merged aspect carries __functor, so a # functor-blind functionArgs throws on the alias. + # + # Also asserts identity, not only delivery: den.aspects.base already + # carries a meaningful name+chain of its own, so wrapperToAspect must not + # overwrite it with the alias's nested position ("libraries/alias") — an + # author's name outranks its position. ctrlbase is a live control: an + # ordinary directly-included aspect, unaffected by the alias, present in + # both arms. test-alias-merged-aspect = denTest ( { den, @@ -157,16 +164,176 @@ igloo, ... }: + let + hostEntity = den.hosts.x86_64-linux.igloo; + ids = map (a: a.identity) hostEntity.aspects; + in { den.hosts.x86_64-linux.igloo.users.tux = { }; den.aspects.base.nixos.boot.kernelParams = [ "s-base" ]; den.aspects.libraries.alias = den.aspects.base; + den.aspects.ctrlbase.nixos.boot.kernelParams = [ "s-ctrl" ]; - den.aspects.igloo.includes = [ den.aspects.libraries.alias ]; + den.aspects.igloo.includes = [ + den.aspects.libraries.alias + den.aspects.ctrlbase + ]; + + expr = { + params = builtins.sort (a: b: a < b) (builtins.filter (lib.hasPrefix "s-") igloo.boot.kernelParams); + hasAlias = hostEntity.hasAspect den.aspects.libraries.alias; + hasBase = hostEntity.hasAspect den.aspects.base; + hasCtrl = hostEntity.hasAspect den.aspects.ctrlbase; + hasBaseId = builtins.elem "base" ids; + hasCtrlId = builtins.elem "ctrlbase" ids; + }; + expected = { + params = [ + "s-base" + "s-ctrl" + ]; + hasAlias = true; + hasBase = true; + hasCtrl = true; + hasBaseId = true; + hasCtrlId = true; + }; + } + ); + + # O2 — an authored `name` at a nested key survives wrapperToAspect: an + # author's name outranks its position. Two attribution sites, per the + # attribution trap: holder.slot is depth-1, stamped by + # aspectContentType.merge's `provider` field; deep.mid.slot is depth-2, + # stamped by annotateChildren. toplevel is the live control — a + # top-level authored name already won before this fix (it never reaches + # wrapperToAspect), so its presence in both arms proves the readout + # itself is not empty. + test-authored-name-at-nested-key-outranks-position = 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.holder.slot = { + name = "author-chose-this"; + nixos.boot.kernelParams = [ "s-holder" ]; + }; + den.aspects.deep.mid.slot = { + name = "author-chose-deep"; + nixos.boot.kernelParams = [ "s-deep" ]; + }; + den.aspects.toplevel = { + name = "author-chose-that"; + nixos.boot.kernelParams = [ "s-top" ]; + }; + + den.aspects.igloo.includes = [ + den.aspects.holder.slot + den.aspects.deep.mid.slot + den.aspects.toplevel + ]; + + expr = { + hasHolderName = builtins.elem "holder/author-chose-this" ids; + hasDeepName = builtins.elem "deep/mid/author-chose-deep" ids; + hasTopName = builtins.elem "author-chose-that" ids; + noPositionalIds = !(builtins.elem "holder/slot" ids) && !(builtins.elem "deep/mid/slot" ids); + }; + expected = { + hasHolderName = true; + hasDeepName = true; + hasTopName = true; + noPositionalIds = true; + }; + } + ); - expr = builtins.filter (lib.hasPrefix "s-") igloo.boot.kernelParams; - expected = [ "s-base" ]; + # O3 — the loud path: den's own sentinel ("") is not an authored + # name and stays overridable by position, per the ruling's scope ("an + # author's name"). This is the cell that discriminates the shipped guard + # (component-wise, gated on isMeaningfulName) from the nearest wrong one + # (component-wise, gated on `? name` alone) — the wrong guard treats + # "" as authored and regresses this identity to a positional/index + # one instead of leaving it alone. + test-sentinel-name-at-nested-key-stays-overridden = 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.holder.anonslot = { + name = ""; + nixos.boot.kernelParams = [ "s-anon" ]; + }; + + den.aspects.igloo.includes = [ den.aspects.holder.anonslot ]; + + expr = builtins.elem "holder/anonslot" ids; + expected = true; + } + ); + + # O4 — regression fence, not a discriminator (no candidate mechanism + # measured gives this a red arm). compile-static's chain registry only + # fires where meta.aspect-chain is null (fillsChain); this mechanism + # always leaves it non-null, so raw-value equality is untouched: two + # independently-owned inline "tools" literals keep two identities, and + # one shared "shared-tools" literal referenced by two owners keeps one + # (walk-order-dependent, claimed by whichever owner is included first). + test-name-guard-preserves-raw-value-equality = denTest ( + { den, igloo, ... }: + let + sharedTools = { + name = "shared-tools"; + nixos.environment.etc."shared-tools".text = "yes"; + }; + toolsNodes = builtins.filter (n: n.name == "tools") den.hosts.x86_64-linux.igloo.aspects; + sharedNodes = builtins.filter (n: n.name == "shared-tools") den.hosts.x86_64-linux.igloo.aspects; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + den.aspects.igloo.includes = [ + den.aspects.alpha + den.aspects.beta + ]; + + den.aspects.alpha.includes = [ + { + name = "tools"; + nixos.environment.etc."alpha-tools".text = "yes"; + } + sharedTools + ]; + den.aspects.beta.includes = [ + { + name = "tools"; + nixos.environment.etc."beta-tools".text = "yes"; + } + sharedTools + ]; + + expr = { + toolsIds = builtins.sort builtins.lessThan (map (n: n.identity) toolsNodes); + sharedIds = builtins.sort builtins.lessThan (map (n: n.identity) sharedNodes); + alpha = igloo.environment.etc ? "alpha-tools"; + beta = igloo.environment.etc ? "beta-tools"; + }; + expected = { + toolsIds = [ + "alpha/tools" + "beta/tools" + ]; + sharedIds = [ "alpha/shared-tools" ]; + alpha = true; + beta = true; + }; } ); From f8756ccf7cfa3f1055fae16d40e198449d54b00d Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 8 Sep 2026 19:42:29 -0700 Subject: [PATCH 40/59] fix: let a provides child's name survive when it collides with an aspect option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mkUnderscore filtered a provides child's NAME through structuralKeysSet — the set that classifies an aspect's OWN top-level keys for content dispatch. A provides child named after an ordinary aspect option (description, meta, name, includes, excludes, provides, policies, into, classes) was silently dropped: no error, absent from .provides/._, and the option's own default read back in its place. The only genuine machinery at this seam is the __-prefix pipeline-internal convention and the single key `_` (a multi-def nested key's content wrapper injects a literal `_` alongside its real children — see multidef-provides-internals.nix). Every other structural-key name now survives; the aspect's own declared option keeps top-level priority via its always-present default (mergeWithAspectMeta's existing merged-shadow check). --- nix/lib/aspects/types.nix | 21 ++++- .../structural-name-provides-child.nix | 93 +++++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 templates/ci/modules/features/deadbugs/structural-name-provides-child.nix diff --git a/nix/lib/aspects/types.nix b/nix/lib/aspects/types.nix index 489d1eee0..4ab28445e 100644 --- a/nix/lib/aspects/types.nix +++ b/nix/lib/aspects/types.nix @@ -121,7 +121,26 @@ let mkUnderscore = own: path: let - providesChildren = lib.filterAttrs (k: _: !(structuralKeysSet ? ${k}) && !(lib.hasPrefix "__" k)) ( + # A provides child's NAME lives in a different namespace than the + # aspect's own top-level keys — `structuralKeysSet` classifies the + # latter (own-key dispatch) and does not apply here: every declared + # aspect option (description, meta, includes, …) already has a + # default, so `merged` always wins the top-level + # `providesChildren // merged` shadow for those names (see + # unshadowedProvides below) while `.provides`/`._` still reach the + # child's own content untouched — measured per key, nothing else in + # structuralKeysSet is genuine machinery at this seam. `_module` is + # real NixOS module-system machinery, but it never reaches + # `own.provides`'s attrNames in the first place (the module system + # consumes it before freeform merge), so no explicit reservation is + # needed for it either. + # + # Two keys ARE genuine machinery here: `__`-prefixed (pipeline + # internals) and `_` — a multi-def nested key merges into a content + # wrapper carrying `__contentValues`/`__aspectChain`/`_` beside its + # real children (aspectContentType below), and `_` is the one of + # those three not already caught by the `__`-prefix rule. + providesChildren = lib.filterAttrs (k: _: !(lib.hasPrefix "__" k) && k != "_") ( own.provides or { } ); childKeys = builtins.filter isChildKey (builtins.attrNames own); diff --git a/templates/ci/modules/features/deadbugs/structural-name-provides-child.nix b/templates/ci/modules/features/deadbugs/structural-name-provides-child.nix new file mode 100644 index 000000000..8608529ad --- /dev/null +++ b/templates/ci/modules/features/deadbugs/structural-name-provides-child.nix @@ -0,0 +1,93 @@ +# `mkUnderscore` (nix/lib/aspects/types.nix) filtered a provides child's NAME +# through `structuralKeysSet` — the set that classifies an ASPECT'S OWN +# top-level keys for content dispatch. Reusing it here filtered out any +# provides child whose author-chosen name happened to also be an ordinary +# aspect option (description, meta, name, includes, excludes, provides, +# policies, into, classes): the child was silently dropped from +# `.provides`/`._` with no error, and the aspect's own option default (e.g. +# `description = "Aspect "`) read back in its place. +# +# The only genuine machinery at THIS seam is the `__`-prefix pipeline-internal +# convention and `_` itself — `_` is the namespace's own alias sigil, and a +# multi-def nested key's content wrapper injects a literal `_` alongside its +# real children (see multidef-provides-internals.nix), so an unfiltered `_` +# leaks wrapper machinery as a provides key. Every other structural-key name +# now survives: it is reachable via `.provides`/`._` while the aspect's own +# declared option (always present, via its default) keeps top-level priority. +{ denTest, ... }: +{ + flake.tests.deadbugs.structural-name-provides-child = { + + # A structural-named child now delivers, at all three construction sites. + test-structural-name-survives-at-all-sites = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + # site A — declared submodule, direct `provides.description`. + den.aspects.d10a.provides.description.nixos.environment.etc."d10-a".text = "y"; + # site B — functor-carrying battery attrset, `_.description` write. + den.aspects.d10h.provides.d10b = { + __functor = _self: _args: { }; + _.description.nixos.environment.etc."d10-b".text = "y"; + }; + # site C — nested freeform key, direct `provides.description`. + den.aspects.d10c.holder.provides.description.nixos.environment.etc."d10-c".text = "y"; + + den.aspects.igloo.includes = [ + den.aspects.d10a._.description + den.aspects.d10h.d10b._.description + den.aspects.d10c.holder._.description + ]; + + expr = { + a = igloo.environment.etc ? "d10-a"; + b = igloo.environment.etc ? "d10-b"; + c = igloo.environment.etc ? "d10-c"; + }; + expected = { + a = true; + b = true; + c = true; + }; + } + ); + + # The aspect's own declared option keeps top-level priority: forwarding a + # structural-named provides child must not clobber `aspect.description`. + test-own-option-still-wins-at-top-level = denTest ( + { den, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.aspects.d10own = { + description = "own-desc"; + provides.description.nixos.environment.etc."d10-own".text = "y"; + }; + + expr = { + ownValue = den.aspects.d10own.description; + childReachable = den.aspects.d10own._ ? description; + }; + expected = { + ownValue = "own-desc"; + childReachable = true; + }; + } + ); + + # CONTROL: a provides child named `_` stays reserved — it's the + # namespace's own alias sigil, and letting it through leaks multi-def + # wrapper machinery (multidef-provides-internals.nix). Loud, not silent: + # the aspect simply has no child by that name, same as before this fix. + test-underscore-name-stays-reserved = denTest ( + { den, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.aspects.d10u.provides._.nixos.environment.etc."d10-u".text = "y"; + + expr = den.aspects.d10u._ ? "_"; + expected = false; + } + ); + }; +} From 58d57266f5e6e7020cea3d857f2ac0cdd8bacfff Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 8 Sep 2026 19:55:47 -0700 Subject: [PATCH 41/59] fix: emit the forwarded-provides marker at the functor-battery construction site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mkUnderscore's three construction sites all compute unshadowedProvides, but mergeFunctions' attrset-with-__functor branch (site B — the shape a functor-carrying battery like import-tree/forward takes) never published it as __providesForwarded. classifyKeys reads that marker to skip a forwarded provides child during classification; missing it at B meant a battery's forwarded provides child was classified as the battery's own content where sites A and C both correctly skip it. --- nix/lib/aspects/types.nix | 7 ++- .../functor-battery-provides-forwarded.nix | 57 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 templates/ci/modules/features/deadbugs/functor-battery-provides-forwarded.nix diff --git a/nix/lib/aspects/types.nix b/nix/lib/aspects/types.nix index 4ab28445e..39e5711ae 100644 --- a/nix/lib/aspects/types.nix +++ b/nix/lib/aspects/types.nix @@ -316,10 +316,15 @@ let normalizedFn = foldUnderscoreIntoProvides fn; aspectName = fn.name or (lib.last loc); underscore = mkUnderscore normalizedFn ((typeCfg.chain or typeCfg.origin) ++ [ aspectName ]); + inherit (underscore) providesChildren; + unshadowedProvides = builtins.filter (k: !(normalizedFn ? ${k})) ( + builtins.attrNames providesChildren + ); in - underscore.providesChildren + providesChildren // normalizedFn // { + __providesForwarded = unshadowedProvides; provides = underscore.syntheticProvides; _ = underscore.syntheticProvides; } diff --git a/templates/ci/modules/features/deadbugs/functor-battery-provides-forwarded.nix b/templates/ci/modules/features/deadbugs/functor-battery-provides-forwarded.nix new file mode 100644 index 000000000..c0aa8055e --- /dev/null +++ b/templates/ci/modules/features/deadbugs/functor-battery-provides-forwarded.nix @@ -0,0 +1,57 @@ +# `mkUnderscore`'s three construction sites (nix/lib/aspects/types.nix) all +# compute `unshadowedProvides` and publish it as `__providesForwarded`, so +# `classifyKeys` (key-classification.nix) knows to skip a forwarded provides +# child during classification — the same child is already reachable via +# `.provides`/`._` and must not ALSO be classified as the aspect's own +# content. Site B (`mergeFunctions`'s attrset-with-`__functor` branch, the +# shape a functor-carrying battery like import-tree/forward takes) never +# computed or emitted the marker: `__providesForwarded` read back "MISSING" +# there while sites A (`mergeWithAspectMeta`) and C (`aspectContentType`) +# both emit it correctly, so a battery's forwarded provides child was +# classified (and thus dispatched) at B only. +{ denTest, ... }: +{ + flake.tests.deadbugs.functor-battery-provides-forwarded = { + + # Three-way comparison, one provides-child name (`d9leak`), one per + # construction site. B is the site under test; A and C are live + # controls proving the marker/classification predicate discriminates. + test-marker-and-classification-agree-at-all-sites = denTest ( + { den, ... }: + let + cls = den.lib.aspects.fx.keyClassification.classifyKeys null; + allKeys = c: c.classKeys ++ c.nestedKeys ++ c.unregisteredClassKeys ++ c.pipeKeys; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + # site A — declared submodule, direct `provides.d9leak`. + den.aspects.d9fa.provides.d9leak.nixos.environment.etc."d9-fa".text = "y"; + # site B — functor-carrying battery attrset, `_.d9leak` write. + den.aspects.d9fh.provides.d9fb = { + __functor = _self: _args: { }; + _.d9leak.nixos.environment.etc."d9-fb".text = "y"; + }; + # site C — nested freeform key, direct `provides.d9leak`. + den.aspects.d9fc.holder.provides.d9leak.nixos.environment.etc."d9-fc".text = "y"; + + expr = { + aMarker = den.aspects.d9fa.__providesForwarded or "MISSING"; + bMarker = den.aspects.d9fh.d9fb.__providesForwarded or "MISSING"; + cMarker = den.aspects.d9fc.holder.__providesForwarded or "MISSING"; + aClassified = builtins.elem "d9leak" (allKeys (cls den.aspects.d9fa)); + bClassified = builtins.elem "d9leak" (allKeys (cls den.aspects.d9fh.d9fb)); + cClassified = builtins.elem "d9leak" (allKeys (cls den.aspects.d9fc.holder)); + }; + expected = { + aMarker = [ "d9leak" ]; + bMarker = [ "d9leak" ]; + cMarker = [ "d9leak" ]; + aClassified = false; + bClassified = false; + cClassified = false; + }; + } + ); + }; +} From 764507e93a055560a42b5b6c12ef7d1ad739a06b Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 8 Sep 2026 20:00:06 -0700 Subject: [PATCH 42/59] chore: remove dead bindings orphaned by the extractEdgeTrace removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extractEdgeTrace (the retired full-union oracle) was removed in 1dba9f1c, leaving edge-trace.nix's own `synthesize` import unused — synthesize edges now arrive pre-built through routeEdges rather than being constructed directly here — and the top-level `rec` with nothing left to self-reference. `synthesize` itself stays alive and load-bearing in edges/edge.nix, route.nix, and toposort.nix; only this file's unused import of it is removed. --- nix/lib/aspects/fx/edge-trace.nix | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/nix/lib/aspects/fx/edge-trace.nix b/nix/lib/aspects/fx/edge-trace.nix index a0be6e73d..c29ad9b0b 100644 --- a/nix/lib/aspects/fx/edge-trace.nix +++ b/nix/lib/aspects/fx/edge-trace.nix @@ -39,7 +39,6 @@ let sortEdges collected rewalk - synthesize rootTarget outputTarget ; @@ -71,7 +70,7 @@ let # production's, not a parallel render. instantiateEdges = import ./edges/instantiate.nix { inherit lib; }; in -rec { +{ # extractTopLevelEdges: pipeline end-state → the per-COMPONENT edge lists, # UNSORTED. Consumed by resolve.nix's production edge trace, which wants the # SAME top-level mechanism lists but drops the `spawnEdges` rewalk arm (it From b5af26042ae506cdd020a7232b915deda035db99 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 8 Sep 2026 20:09:03 -0700 Subject: [PATCH 43/59] fix: prefix den.schema.*.includes bad-element error with den: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the sibling excludes fix (7be57684): a bare string (or other non-aspect value) in den.schema..includes used to reach children.nix's aspect walk unchecked and crash with a raw Nix "expected a set but found a string" from propagateScope's `//`. This freeform gen-schema collection has no per-collection type to route the value through first, so validate at merge time instead, recursing into nested lists the same way processInclude walks them. Message quality only — it already errored, and keeps erroring on exactly the same inputs, now with a den:-prefixed message. --- modules/options.nix | 24 ++++++ ...ema-tier-includes-string-form-rejected.nix | 73 +++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 templates/ci/modules/features/schema-tier-includes-string-form-rejected.nix diff --git a/modules/options.nix b/modules/options.nix index fed55155b..bd1473c74 100644 --- a/modules/options.nix +++ b/modules/options.nix @@ -74,6 +74,30 @@ in collections = { includes = { default = [ ]; + # A bare-string (or other non-aspect) element used to reach + # children.nix's aspect walk unchecked and crash with a raw Nix + # `expected a set but found a string` from propagateScope's `//` — + # the aspect tier catches this via providerType's `check`, but this + # freeform collection has no type to route through, so validate here + # instead, same as excludes below. Recurses into nested lists: + # children.nix's processInclude walks nested lists the same way, so a + # bad leaf at any depth must still be caught, just with a den: + # message instead of the raw one. + merge = + acc: val: + let + check = + v: + if builtins.isList v then + map check v + else if builtins.isAttrs v || lib.isFunction v then + v + else + throw "den: den.schema..includes: expected a policy or aspect reference, got ${ + if builtins.isString v then ''"${v}"'' else builtins.typeOf v + }"; + in + acc ++ map check val; }; excludes = { default = [ ]; diff --git a/templates/ci/modules/features/schema-tier-includes-string-form-rejected.nix b/templates/ci/modules/features/schema-tier-includes-string-form-rejected.nix new file mode 100644 index 000000000..be8efd4aa --- /dev/null +++ b/templates/ci/modules/features/schema-tier-includes-string-form-rejected.nix @@ -0,0 +1,73 @@ +# `den.schema..includes` used to accept a bare string (or any +# non-aspect value) and reach `children.nix`'s aspect walk unchecked, +# crashing with a raw Nix `expected a set but found a string` from +# `propagateScope`'s `//` — gen-schema has no per-collection `type` for this +# untyped collection to route the bad value through first. See +# `schema-tier-excludes-string-form-rejected.nix` for the sibling `excludes` +# fix this mirrors. +{ denTest, ... }: +{ + flake.tests.schema-tier-includes-string-form-rejected = { + + test-schema-includes-string-form-errors = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.schema.host.includes = [ "s3-marker" ]; + + expr = igloo.networking.hostName; + expectedError = { + type = "ThrownError"; + msg = "den: den.schema..includes"; + }; + } + ); + + # Uncontested-policy control: a legitimate aspect/policy-form include + # must still fire, so the cell above isn't vacuous (everything erroring + # would look identical to the fix working). + test-schema-includes-record-form-still-fires = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.aspects.s3marker.nixos.environment.variables.S3_MARKER = "yes"; + den.schema.host.includes = [ den.aspects.s3marker ]; + + expr = igloo.environment.variables.S3_MARKER or "absent"; + expected = "yes"; + } + ); + + # Nested-list leaf: children.nix's processInclude walks nested lists, + # so a bad leaf below the top level must still be caught, not silently + # reach the raw crash a level deeper. + test-schema-includes-nested-string-form-errors = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.schema.host.includes = [ [ "s3-marker-nested" ] ]; + + expr = igloo.networking.hostName; + expectedError = { + type = "ThrownError"; + msg = "den: den.schema..includes"; + }; + } + ); + + # Breadth: the defect reaches every schema kind, not just `host`. + test-schema-includes-string-form-errors-on-user-kind = denTest ( + { den, tuxHm, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux.aspect.includes = [ ]; + den.schema.user.includes = [ "s3-marker-user" ]; + + expr = tuxHm.home.sessionVariables or { }; + expectedError = { + type = "ThrownError"; + msg = "den: den.schema..includes"; + }; + } + ); + }; +} From 00b0af80481f96c15d6cc1dd27af219a248d746b Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 8 Sep 2026 20:29:47 -0700 Subject: [PATCH 44/59] fix: guard the drain walk's undeliverable residue, not its unreachable scope-fork count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D1 F1: the walkedScopeIds > 1 throw landed for §4.2 was dead code — push-scope can only fire from inside policy dispatch, which this walk never runs (installPolicies gates on __entityKind, attached only via the resolve-entity effect, never reached here), so the walk can never fan into more than one scope. Worse, the fix as landed introduced a new silent-drop mode: a pipe-arg-deferred child carrying both direct class content and a policy effect (route/instantiate/provide/aspect-policy) or a still-deferred nested include delivered the direct half and dropped the other with no diagnostic, since this walk registers those effects without ever dispatching them. Guard on that residue instead: throw when any of the five scoped-effect state maps (scopedAspectPolicies, scopedRoutes, scopedInstantiates, scopedProvides, scopedDeferredIncludes) hold something for the walk, rather than deliver scopedClassImports alone and lose the rest quietly. Known gap, not closed here: a deferred child whose own includes fans over an entity arg leaves no residue in any of these maps and still loses content silently — that needs bind's entity-arg fan classification, a different position entirely (D1 F1 arm C). --- nix/lib/aspects/fx/resolve.nix | 65 ++++++++--- .../deadbugs/d1-undeliverable-residue.nix | 101 ++++++++++++++++++ 2 files changed, 151 insertions(+), 15 deletions(-) create mode 100644 templates/ci/modules/deadbugs/d1-undeliverable-residue.nix diff --git a/nix/lib/aspects/fx/resolve.nix b/nix/lib/aspects/fx/resolve.nix index c8c78d4f1..36c01c027 100644 --- a/nix/lib/aspects/fx/resolve.nix +++ b/nix/lib/aspects/fx/resolve.nix @@ -718,22 +718,57 @@ let self = d.child; ctx = scopeCtx; }; - # The walk roots at its OWN scope id (mkScopeId hashes - # every ctx key, and scopeCtx carries the pipe value the - # child required), never the draining scope's id — so - # its buckets are folded under `scopeId` explicitly - # below rather than merged in by scope id. - walkedScopeIds = builtins.attrNames (walked.state.scopeContexts null); + # This walk never runs policy dispatch: installPolicies + # (resolve-children.nix) skips any aspect without + # __entityKind, and nothing on a deferred child's walk + # ever attaches one (resolve-entity.nix is the only + # site that does, reached only via the resolve-entity + # effect). Since push-scope fires only from inside + # policy dispatch, that also means this walk can never + # fan into more than one scope — scope-forking and + # policy dispatch share one gate (D1 F1, measured: 51 + # walk firings across the D1 suites, all n=1). + # + # What DOES happen on this path: a policy effect + # (route/instantiate/provide/aspect-policy) or a + # still-deferred nested include gets REGISTERED by the + # walk's compile step without ever being DISPATCHED, and + # that content was silently lost (D1 F1 — a pipe-arg- + # deferred child carrying both direct class content and + # a `resolve.to` policy delivered the direct half and + # dropped the policy half with no diagnostic). Guard on + # that residue instead: throw loud when any of the five + # scoped-effect maps hold something for this walk, + # rather than deliver `scopedClassImports` alone and + # lose the rest quietly. + # + # Known gap NOT covered here: a deferred child whose own + # `includes` fans over an entity arg loses its content + # with no residue in any of these maps (`includeSeen` is + # set, `scopedClassImports` is simply absent) — closing + # that needs `bind`'s entity-arg fan classification, a + # different position entirely (D1 F1 arm C, open). + # Per-scope values are lists for four of these + # (scopedAppend) but scopedAspectPolicies is a merged + # attrset keyed by policy name (scopedMerge, policy.nix) + # — normalise both to a list before concatenating. + residueOf = + key: + builtins.concatLists ( + map (v: if builtins.isList v then v else lib.attrValues v) ( + lib.attrValues ((walked.state.${key} or (_: { })) null) + ) + ); + residueKinds = builtins.filter (k: residueOf k != [ ]) [ + "scopedAspectPolicies" + "scopedRoutes" + "scopedInstantiates" + "scopedProvides" + "scopedDeferredIncludes" + ]; in - if builtins.length walkedScopeIds > 1 then - # A deferred child whose own `includes` fan over an entity - # arg (or that carries a `resolve.to`) pushes real child - # scopes of its own. Collapsing those into the draining - # scope would hoist their content across scope isolation - # silently; no measured shape reaches this today, so throw - # loud rather than guess (D1 §4.2). - throw - "den: pipe-arg-deferred include '${d.child.name or ""}' fanned into ${toString (builtins.length walkedScopeIds)} scopes (${lib.concatStringsSep ", " walkedScopeIds}) while draining at scope '${scopeId}' — folding a fanned child's scopes into the drain scope is not supported" + if residueKinds != [ ] then + throw "den: pipe-arg-deferred include '${d.child.name or ""}' left undeliverable content (${lib.concatStringsSep ", " residueKinds}) while draining at scope '${scopeId}' — this drain walk does not dispatch policies, so registered effects are silently dropped rather than delivered" else lib.attrValues (walked.state.scopedClassImports null) ) drainable; diff --git a/templates/ci/modules/deadbugs/d1-undeliverable-residue.nix b/templates/ci/modules/deadbugs/d1-undeliverable-residue.nix new file mode 100644 index 000000000..8a129e847 --- /dev/null +++ b/templates/ci/modules/deadbugs/d1-undeliverable-residue.nix @@ -0,0 +1,101 @@ +{ denTest, ... }: +let + # D1 F1: mkDrained's walk (resolve.nix) never runs policy dispatch, so a + # pipe-arg-deferred child that registers a policy effect (here, a + # `resolve.to`) alongside direct class content used to deliver the direct + # half and drop the policy half with no diagnostic. Each arm gets its own + # fixture: the residue guard throws for the WHOLE scope's drain fold, so a + # shared multi-arm `includes` list (fine for D1's other suites) would make + # every cell in the same host config throw together here. + + # ARM A — pipe-arg deferred, carrying BOTH direct class content (10300) + # AND a scope-pushing policy (would deliver 10310). The policy half is the + # one that used to vanish silently. + fixtureDeferred = den: { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.quirks.firewall.description = "Firewall port declarations"; + + den.policies.p-def = _: [ + (den.lib.policy.resolve.to "gA" { + gA = { + name = "a"; + }; + }) + ]; + den.schema.gA.includes = [ den.aspects.leaf-a ]; + den.aspects.leaf-a.nixos.networking.firewall.allowedTCPPorts = [ 10310 ]; + + den.aspects.igloo = { + firewall.ports = [ 22 ]; + includes = [ + ( + { firewall, ... }: + { + name = "gate2-deferred"; + nixos.networking.firewall.allowedTCPPorts = [ 10300 ]; + includes = [ den.policies.p-def ]; + } + ) + ]; + }; + }; + + # ARM B — the plain (non-deferred) twin of A: same body, same halves. + # Live control proving the guard is gated on the deferred walk, not on + # `resolve.to` content itself. + fixturePlain = den: { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.quirks.firewall.description = "Firewall port declarations"; + + den.policies.p-plain = _: [ + (den.lib.policy.resolve.to "gB" { + gB = { + name = "b"; + }; + }) + ]; + den.schema.gB.includes = [ den.aspects.leaf-b ]; + den.aspects.leaf-b.nixos.networking.firewall.allowedTCPPorts = [ 10320 ]; + + den.aspects.igloo = { + firewall.ports = [ 22 ]; + includes = [ + { + name = "gate2-plain"; + nixos.networking.firewall.allowedTCPPorts = [ 10305 ]; + includes = [ den.policies.p-plain ]; + } + ]; + }; + }; +in +{ + flake.tests.d1residue = { + # Pins the fix: the policy half no longer vanishes silently. It throws, + # naming the undeliverable kind, instead of dropping it with no trace. + test-policy-borne-residue-throws-loud = denTest ( + { den, igloo, ... }: + (fixtureDeferred den) + // { + expr = igloo.networking.firewall.allowedTCPPorts; + expectedError = { + type = "ThrownError"; + msg = "left undeliverable content"; + }; + } + ); + + # Same shapes, not deferred: both halves must still deliver, in the same + # run — proves the guard does not touch the ordinary (non-drain) path. + test-plain-twin-both-halves-deliver = denTest ( + { den, igloo, ... }: + (fixturePlain den) + // { + expr = + builtins.elem 10305 igloo.networking.firewall.allowedTCPPorts + && builtins.elem 10320 igloo.networking.firewall.allowedTCPPorts; + expected = true; + } + ); + }; +} From 90f47f5939a13f4a106396ab873f3e241fd7e456 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 8 Sep 2026 20:57:35 -0700 Subject: [PATCH 45/59] fix: check that an expectedError cell actually throws ci.bash's --select marked every expectedError cell as passing without forcing its expr, so a cell asserting an error read green even when the code stopped throwing. Force it through tryEval instead. Only the binary throw/no-throw crosses the nix-eval-jobs worker boundary, so type and message verification still needs nix-unit directly; the header now says so rather than claiming these cells are ignored. --- ci.bash | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/ci.bash b/ci.bash index c6afc5b74..f21ec2720 100644 --- a/ci.bash +++ b/ci.bash @@ -1,7 +1,12 @@ #!/usr/bin/env bash # # Uses nix-eval-jobs with $(nproc) workers -# NOTE: Ignores tests with expectedError +# NOTE: expectedError cells only verify that expr throws SOMETHING (via +# tryEval, in the --select expression below). They do not verify +# expectedError.type/.msg — nix-eval-jobs runs each job out-of-process, so +# only tryEval's success/failure crosses that boundary, not the caught +# exception's details. Use `nix-unit` directly (`just ci-deep`/`just test`) +# for full type/msg verification. # # Redirect stdout to null IF you only want to see failures set -aeuo pipefail @@ -68,8 +73,16 @@ nix-eval-jobs \ let hasExpected = v ? expected && !(v.expected ? undefined); hasExpectedError = v ? expectedError && !(v.expectedError ? undefined); + # nix-eval-jobs runs each job in a separate worker; only + # tryEval'\''s success/failure crosses that boundary, not the + # caught exception'\''s type/msg text. So this only proves expr + # throws SOMETHING — closing the class where a fix silently stops + # throwing and the cell still reads green. It does not verify + # expectedError.type/.msg; only `nix-unit` does that (it uses + # the evaluator'\''s C++ API directly to inspect the exception). pass = if hasExpected then v.expr == v.expected - else if hasExpectedError then true # ignored + else if hasExpectedError then + !(builtins.tryEval (builtins.deepSeq v.expr null)).success else true; name = builtins.replaceStrings ["." "'\''"] ["-" "_"] prefix; in derivation { From b6b2c40498c6041bfab345f44a0d4009d140b996 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 8 Sep 2026 20:57:35 -0700 Subject: [PATCH 46/59] test: pin the residue guard with a gate-visible twin The expectedError cell alongside this one discriminates only under raw nix-unit. Assert the same throw through tryEval so the gate sees it. --- .../ci/modules/deadbugs/d1-undeliverable-residue.nix | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/templates/ci/modules/deadbugs/d1-undeliverable-residue.nix b/templates/ci/modules/deadbugs/d1-undeliverable-residue.nix index 8a129e847..b6e6813af 100644 --- a/templates/ci/modules/deadbugs/d1-undeliverable-residue.nix +++ b/templates/ci/modules/deadbugs/d1-undeliverable-residue.nix @@ -85,6 +85,18 @@ in } ); + # Same scenario, via tryEval instead of expectedError: proves the + # residue guard is visible to `just ci` independent of the + # expectedError branch above (see ci.bash's hasExpectedError handling). + test-policy-borne-residue-throws-loud-under-just-ci = denTest ( + { den, igloo, ... }: + (fixtureDeferred den) + // { + expr = (builtins.tryEval (builtins.deepSeq igloo.networking.firewall.allowedTCPPorts null)).success; + expected = false; + } + ); + # Same shapes, not deferred: both halves must still deliver, in the same # run — proves the guard does not touch the ordinary (non-drain) path. test-plain-twin-both-halves-deliver = denTest ( From 825b5d25ec566898790b718f86fbe198adf17b42 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Wed, 9 Sep 2026 08:48:43 -0700 Subject: [PATCH 47/59] perf: resolve rawRef excludes once per dispatch, not once per policy name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `isPolicyExcluded` resolved every rawRef exclude entry to its target identity and flattened the whole scoped constraint registry, neither of which depends on the candidate name being tested. Both were inside the four-argument call the two callers made from within a `filterAttrs` lambda, so the entire O(E + R × D × C) term was recomputed for each of P policy names. Curry the function so `name` is the last argument and hoist the partial application out of both lambdas. The rawRef arm now resolves every entry rather than stopping at the first match: which entries got resolved previously depended on the order candidate names arrived in, so an error reachable through resolveClaim surfaced for some dispatch orders and not others. --- nix/lib/aspects/fx/handlers/constraint.nix | 38 +++++++++++++------ .../aspects/fx/handlers/dispatch-policies.nix | 6 +-- nix/lib/aspects/fx/policy/schema.nix | 6 +-- 3 files changed, 32 insertions(+), 18 deletions(-) diff --git a/nix/lib/aspects/fx/handlers/constraint.nix b/nix/lib/aspects/fx/handlers/constraint.nix index a7b179a06..975987f5e 100644 --- a/nix/lib/aspects/fx/handlers/constraint.nix +++ b/nix/lib/aspects/fx/handlers/constraint.nix @@ -148,11 +148,16 @@ let # both must exclude the SAME claimant, or a claimant filtered from one # still fires through the other. # - # Cost: registry is already scoped to one entity's self+ancestors, but - # within that scope this flattens EVERY identity bucket to find rawRef - # entries and re-walks ancestor scopes (resolveClaim) per rawRef entry — - # replacing what was a single `registry.${name} or []` lookup. Per call: - # O(E + R × D × C). Per dispatch over P policies: O(P × (E + R × D × C)). + # CURRIED DELIBERATELY: `name` is the last argument and everything above it + # is a partial application both callers make ONCE, outside their filterAttrs + # lambda. Nothing in arm (b) depends on the name being tested, so applying + # all four arguments per candidate rebuilt the whole rawRef resolution for + # each of P policy names. Callers must keep hoisting the partial application; + # re-inlining a fully-applied call inside a per-name lambda silently restores + # the P factor below. + # + # Cost, with the hoist: O(E + R × D × C) once per dispatch, plus O(1) per + # candidate name. Without it, every term was multiplied by P. # E = constraint entries in the scoped registry — per-entity, bounded by # how many excludes/handleWith one aspect tree declares, not fleet-wide. # R = rawRef excludes in scope. @@ -170,19 +175,28 @@ let # fleet size N — do not read E's per-entity bound as covering the # whole cost; E and C are scoped oppositely and must not be merged # under one "bounded per-entity" claim. - # den's performance suite (perf 29/29) declares zero hosts and does not - # exercise this path at entity scale; unmeasured there. + # + # The rawRef arm now resolves EVERY rawRef entry rather than stopping at the + # first whose identity matches. That is deliberate: which entries got + # resolved previously depended on the order candidate names arrived in, so + # any error reachable through resolveClaim surfaced for some dispatch orders + # and not others. isPolicyExcluded = - state: scope: registry: name: + state: scope: registry: let - directEntries = registry.${name} or [ ]; - directApplies = e: e.type == "exclude" && (e.rawRef or null) == null; rawRefEntries = builtins.filter (e: e.type == "exclude" && (e.rawRef or null) != null) ( builtins.concatLists (builtins.attrValues registry) ); + rawRefExcluded = lib.genAttrs (builtins.filter (id: id != null) ( + map (resolveRawRefIdentity state scope) rawRefEntries + )) (_: true); + in + name: + let + directEntries = registry.${name} or [ ]; + directApplies = e: e.type == "exclude" && (e.rawRef or null) == null; in - builtins.any directApplies directEntries - || builtins.any (e: resolveRawRefIdentity state scope e == name) rawRefEntries; + builtins.any directApplies directEntries || rawRefExcluded ? ${name}; entryToResume = entry: diff --git a/nix/lib/aspects/fx/handlers/dispatch-policies.nix b/nix/lib/aspects/fx/handlers/dispatch-policies.nix index a379b300d..c878fd637 100644 --- a/nix/lib/aspects/fx/handlers/dispatch-policies.nix +++ b/nix/lib/aspects/fx/handlers/dispatch-policies.nix @@ -20,9 +20,9 @@ in # Entity-scoped (scope + ancestors, NOT fleet-wide) — a sibling entity's # policy-exclude must not filter this scope's policies (#613 analog). registry = scopedConstraintsFor state; - filteredPolicies = lib.filterAttrs ( - name: _: !isPolicyExcluded state state.currentScope registry name - ) param.aspectPolicies; + # Applied once, outside the lambda — see isPolicyExcluded's currying note. + excluded = isPolicyExcluded state state.currentScope registry; + filteredPolicies = lib.filterAttrs (name: _: !excluded name) param.aspectPolicies; in { resume = mkDispatch filteredPolicies param.firedPolicies param.resolveCtx; diff --git a/nix/lib/aspects/fx/policy/schema.nix b/nix/lib/aspects/fx/policy/schema.nix index e234fa5d5..529093f58 100644 --- a/nix/lib/aspects/fx/policy/schema.nix +++ b/nix/lib/aspects/fx/policy/schema.nix @@ -212,9 +212,9 @@ let # child sibling, and the relevant excludes (e.g. den.schema.flake-system. # excludes) register at the sibling/descendant scope, not an ancestor. constraintRegistry = scopedConstraintsForScope state sib.scopeId; - filteredPolicies = lib.filterAttrs ( - name: _: !isPolicyExcluded state sib.scopeId constraintRegistry name - ) latePolicies; + # Applied once, outside the lambda — see isPolicyExcluded's currying note. + excluded = isPolicyExcluded state sib.scopeId constraintRegistry; + filteredPolicies = lib.filterAttrs (name: _: !excluded name) latePolicies; resolveCtx = sib.scopedCtx // { __entityKind = sib.targetKind; }; From 08b82b3d93f473e608fdf2d71a130c7b914e8f7c Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Wed, 9 Sep 2026 08:57:17 -0700 Subject: [PATCH 48/59] fix: replace normalizeRoot's functor whitelist with structural carry-forward normalizeRoot's needsWrap branch rebuilt a functor-shaped ROOT aspect from name/meta/includes/__scopeHandlers, dropping excludes, provides, policies, into, classes and every __ marker. Carry every structural key forward except the ones the unwrap itself re-derives, matching mkParametricBase. The branch is reached whenever an aspect carrying a user-written __functor is resolved as a root (the synthetic resolveAspectWith is a non-pattern lambda and reports no functionArgs, so ordinary aspects never enter it). The new cell pins a dropped sibling `excludes`; its non-functor twin is the control that excludes are otherwise live at that root. --- nix/lib/aspects/default.nix | 26 +++++++- .../functor-root-structural-siblings.nix | 63 +++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 templates/ci/modules/features/deadbugs/functor-root-structural-siblings.nix diff --git a/nix/lib/aspects/default.nix b/nix/lib/aspects/default.nix index dd6b49b4e..686e21528 100644 --- a/nix/lib/aspects/default.nix +++ b/nix/lib/aspects/default.nix @@ -9,6 +9,25 @@ let hasAspect = import ./has-aspect.nix { inherit den lib; }; fx = import ./fx { inherit den lib; }; + # Structural keys the functor-root unwrap below re-derives itself, so the + # general carry-forward must not copy them off the pre-normalization root: + # name/meta/includes are defaulted explicitly; __fn/__args are built here + # from the functor; __functor/__functionArgs are the function representation + # this branch consumes into __fn/__args, and re-emitting them would leave the + # root looking unnormalized to content-util's functor unwrap; _module is + # module-system bookkeeping the aspect merge leaves behind (resolveAspectWith + # strips it for the same reason), never pipeline state. + functorRootOwnedKeys = lib.genAttrs [ + "name" + "meta" + "includes" + "__fn" + "__args" + "__functor" + "__functionArgs" + "_module" + ] (_: true); + normalizeRoot = resolved: let @@ -40,6 +59,9 @@ let meta = { }; } else if needsWrap then + # Every other structural key on the root (excludes, provides, policies, + # into, classes, __scopeHandlers, __walkStamped, …) survives the unwrap + # unchanged — a whitelist here silently dropped each new marker. { __fn = resolved.__functor resolved; __args = functorArgs; @@ -47,7 +69,9 @@ let meta = resolved.meta or { }; includes = resolved.includes or [ ]; } - // lib.optionalAttrs (resolved ? __scopeHandlers) { inherit (resolved) __scopeHandlers; } + // lib.filterAttrs ( + k: _: (fx.keyClassification.structuralKeysSet ? ${k}) && !(functorRootOwnedKeys ? ${k}) + ) resolved else resolved; diff --git a/templates/ci/modules/features/deadbugs/functor-root-structural-siblings.nix b/templates/ci/modules/features/deadbugs/functor-root-structural-siblings.nix new file mode 100644 index 000000000..2f4b30687 --- /dev/null +++ b/templates/ci/modules/features/deadbugs/functor-root-structural-siblings.nix @@ -0,0 +1,63 @@ +# normalizeRoot (nix/lib/aspects/default.nix) rebuilds a functor-shaped ROOT +# aspect from an explicit whitelist (name, meta, includes, __scopeHandlers), +# dropping every other structural key — excludes, provides, policies, into, +# classes and every `__` marker. A merged aspect only keeps a user-written +# __functor (resolveAspectWith, the synthetic one, is a non-pattern lambda and +# reports no functionArgs), so the branch is reached exactly when such an +# aspect is resolved as a root: `funnyNames den.aspects.`. +# +# The control twin pins that this is the whitelist and not `excludes` being +# inert at a funnyNames root. +{ denTest, ... }: +{ + flake.tests.deadbugs.functor-root-structural-siblings = { + + test-functor-root-excludes-survive-normalize = denTest ( + { den, funnyNames, ... }: + { + den.aspects.frss-dropped.funny.names = [ "dropped" ]; + den.aspects.frss-kept.funny.names = [ "kept" ]; + + den.aspects.frss-fnroot = { + __functor = + _self: + { + host ? null, + ... + }: + { }; + includes = [ + den.aspects.frss-dropped + den.aspects.frss-kept + ]; + excludes = [ den.aspects.frss-dropped ]; + }; + + expr = funnyNames den.aspects.frss-fnroot; + expected = [ "kept" ]; + } + ); + + # Control: same shape, no __functor — normalizeRoot passes it through + # untouched, so `excludes` reaches registerConstraints. + test-plain-root-excludes-control = denTest ( + { den, funnyNames, ... }: + { + den.aspects.frss-c-dropped.funny.names = [ "dropped" ]; + den.aspects.frss-c-kept.funny.names = [ "kept" ]; + + den.aspects.frss-plainroot = { + includes = [ + den.aspects.frss-c-dropped + den.aspects.frss-c-kept + ]; + excludes = [ den.aspects.frss-c-dropped ]; + }; + + expr = funnyNames den.aspects.frss-plainroot; + expected = [ "kept" ]; + } + ); + + }; +} From aad65c8ff4193733cf1a073e1e1d88e54b04f959 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Wed, 9 Sep 2026 09:02:04 -0700 Subject: [PATCH 49/59] refactor: close the structural-key registry by rule for __ markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit builtinStructuralKeys enumerated its twelve __-prefixed entries, so a new pipeline marker was structural only once someone remembered to list it here; until then it was dispatched as class or nested-aspect content. CLAUDE.md already states the convention, and types.nix's providesChildren already applies it as a live predicate. structuralKeysSet becomes isStructuralKey, a predicate — every reader was a membership test, none iterated the set, so no reader loses a list. The eleven non-__ names and den.reservedKeys stay listed and unchanged. isChildKey drops its now-redundant hasPrefix term. --- nix/lib/aspects/default.nix | 2 +- nix/lib/aspects/fx/aspect.nix | 6 +-- nix/lib/aspects/fx/key-classification.nix | 31 +++++++------- nix/lib/aspects/types.nix | 25 ++++++----- .../internal-api/structural-key-rule.nix | 42 +++++++++++++++++++ 5 files changed, 72 insertions(+), 34 deletions(-) create mode 100644 templates/ci/modules/internal-api/structural-key-rule.nix diff --git a/nix/lib/aspects/default.nix b/nix/lib/aspects/default.nix index 686e21528..4008ee165 100644 --- a/nix/lib/aspects/default.nix +++ b/nix/lib/aspects/default.nix @@ -70,7 +70,7 @@ let includes = resolved.includes or [ ]; } // lib.filterAttrs ( - k: _: (fx.keyClassification.structuralKeysSet ? ${k}) && !(functorRootOwnedKeys ? ${k}) + k: _: fx.keyClassification.isStructuralKey k && !(functorRootOwnedKeys ? ${k}) ) resolved else resolved; diff --git a/nix/lib/aspects/fx/aspect.nix b/nix/lib/aspects/fx/aspect.nix index 41d80987d..16c3d9a38 100644 --- a/nix/lib/aspects/fx/aspect.nix +++ b/nix/lib/aspects/fx/aspect.nix @@ -6,7 +6,7 @@ let inherit (den.lib) fx; inherit (den.lib.aspects.fx) identity; - inherit (den.lib.aspects.fx.keyClassification) structuralKeysSet; + inherit (den.lib.aspects.fx.keyClassification) isStructuralKey; inherit (import ./class-module.nix { inherit lib den; }) wrapClassModule; ctxFromHandlers = @@ -73,7 +73,7 @@ let fnArgNames = builtins.attrNames (aspect.__args or { }); }; } - // lib.filterAttrs (k: _: (structuralKeysSet ? ${k}) && !(parametricOwnedKeysSet ? ${k})) aspect; + // lib.filterAttrs (k: _: isStructuralKey k && !(parametricOwnedKeysSet ? ${k})) aspect; # Merge the resolved value into the parametric base. mkParametricNext = @@ -185,7 +185,7 @@ in emitIncludes emitAspectPolicies chainWrap - structuralKeysSet + isStructuralKey wrapClassModule ctxFromHandlers enterScope diff --git a/nix/lib/aspects/fx/key-classification.nix b/nix/lib/aspects/fx/key-classification.nix index f1d29af9a..6b7a826a1 100644 --- a/nix/lib/aspects/fx/key-classification.nix +++ b/nix/lib/aspects/fx/key-classification.nix @@ -5,7 +5,8 @@ }: let # Structural keys are always handled by the pipeline itself — not - # dispatched as class or nested aspect keys. + # dispatched as class or nested aspect keys. Only the names that carry no + # marker prefix are listed; the `__` half is a rule below. builtinStructuralKeys = [ "name" "description" @@ -16,24 +17,20 @@ let "policies" "into" "classes" - "__fn" - "__args" - "__functor" - "__functionArgs" - "__scopeHandlers" - "__ctxId" - "__entityKind" - "__parametricResolvedArgs" - "__contentValues" - "__aspectChain" - "__providesForwarded" - "__walkStamped" "_module" "_" ]; # User-extensible reserved keys via den.reservedKeys option. - structuralKeysSet = lib.genAttrs (builtinStructuralKeys ++ (den.reservedKeys or [ ])) (_: true); + listedStructuralKeys = lib.genAttrs (builtinStructuralKeys ++ (den.reservedKeys or [ ])) (_: true); + + # A `__`-prefixed key is a pipeline internal by convention, so the registry + # closes by RULE rather than by enumeration: every marker (__walkStamped, + # __aspectChain, __ctxId, __contentValues, …) is structural the moment it + # exists, not the moment someone remembers to add it here. Listing them was + # a standing silent-drop hazard — a forgotten marker got dispatched as class + # or nested-aspect content instead of being handled by the pipeline. + isStructuralKey = k: lib.hasPrefix "__" k || listedStructuralKeys ? ${k}; # Schema registry for key classification. # Top-level den.classes lives outside den.schema, breaking @@ -75,7 +72,7 @@ let builtins.isAttrs val && builtins.any ( sk: - structuralKeysSet ? ${sk} + isStructuralKey sk || pipeRegistry ? ${sk} || (classRegistry ? ${sk} && looksLikeClassContent val.${sk}) ) (builtins.attrNames val); @@ -84,7 +81,7 @@ let targetClass: aspect: let forwardedSet = lib.genAttrs (aspect.__providesForwarded or [ ]) (_: true); - allKeys = builtins.filter (k: !(structuralKeysSet ? ${k}) && !(forwardedSet ? ${k})) ( + allKeys = builtins.filter (k: !(isStructuralKey k) && !(forwardedSet ? ${k})) ( builtins.attrNames aspect ); in @@ -112,5 +109,5 @@ let }; in { - inherit structuralKeysSet classifyKeys pipeRegistry; + inherit isStructuralKey classifyKeys pipeRegistry; } diff --git a/nix/lib/aspects/types.nix b/nix/lib/aspects/types.nix index 39e5711ae..a1ee32be0 100644 --- a/nix/lib/aspects/types.nix +++ b/nix/lib/aspects/types.nix @@ -96,16 +96,15 @@ let # and identical at every site that builds a synthetic `_`. Not parameters. classReg = den.classes or { }; pipeReg = den.quirks or { }; - inherit (den.lib.aspects.fx.keyClassification) structuralKeysSet; + inherit (den.lib.aspects.fx.keyClassification) isStructuralKey; - # A key names a candidate child aspect when it is neither structural, - # internal, class, nor pipe. Provides children are reached through - # `provides`/`_`, which are structural, so this alone decides child-key - # membership — a key held both as a provides child and as a direct key is - # still a child key, included via its direct value (see mkUnderscore). - isChildKey = - k: - !(structuralKeysSet ? ${k}) && !(lib.hasPrefix "__" k) && !(classReg ? ${k}) && !(pipeReg ? ${k}); + # A key names a candidate child aspect when it is neither structural (which + # covers `__`-prefixed internals by rule), class, nor pipe. Provides children + # are reached through `provides`/`_`, which are structural, so this alone + # decides child-key membership — a key held both as a provides child and as a + # direct key is still a child key, included via its direct value (see + # mkUnderscore). + isChildKey = k: !(isStructuralKey k) && !(classReg ? ${k}) && !(pipeReg ? ${k}); # The synthetic `_`/`provides` aspect, built once for all three shapes an # aspect construction can take (declared submodule, functor-carrying @@ -122,14 +121,14 @@ let own: path: let # A provides child's NAME lives in a different namespace than the - # aspect's own top-level keys — `structuralKeysSet` classifies the + # aspect's own top-level keys — `isStructuralKey` classifies the # latter (own-key dispatch) and does not apply here: every declared # aspect option (description, meta, includes, …) already has a # default, so `merged` always wins the top-level # `providesChildren // merged` shadow for those names (see # unshadowedProvides below) while `.provides`/`._` still reach the # child's own content untouched — measured per key, nothing else in - # structuralKeysSet is genuine machinery at this seam. `_module` is + # the structural registry is genuine machinery at this seam. `_module` is # real NixOS module-system machinery, but it never reaches # `own.provides`'s attrNames in the first place (the module system # consumes it before freeform merge), so no explicit reservation is @@ -753,7 +752,7 @@ let typeCfg: let contentType = aspectContentType typeCfg; - inherit (den.lib.aspects.fx.keyClassification) structuralKeysSet; + inherit (den.lib.aspects.fx.keyClassification) isStructuralKey; in lib.types.mkOptionType { name = "aspectKey"; @@ -766,7 +765,7 @@ let # 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; + if isStructuralKey (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/internal-api/structural-key-rule.nix b/templates/ci/modules/internal-api/structural-key-rule.nix new file mode 100644 index 000000000..d5ca549f9 --- /dev/null +++ b/templates/ci/modules/internal-api/structural-key-rule.nix @@ -0,0 +1,42 @@ +# The structural-key registry (nix/lib/aspects/fx/key-classification.nix) used +# to enumerate its `__`-prefixed half, so a pipeline marker added without an +# entry there was classified as ordinary aspect content — dispatched as a class +# or nested key instead of being handled by the pipeline. The `__` half is a +# rule now; this pins it against a marker name that is deliberately NOT listed. +{ denTest, ... }: +{ + flake.tests.structural-key-rule = { + + test-unlisted-double-underscore-key-is-not-dispatched = denTest ( + { den, ... }: + let + # `futureMarker` is the live control: the identical value under a name + # with no `__` prefix must still classify as a nested key, so a green + # here cannot come from classifyKeys failing to classify anything. + cls = den.lib.aspects.fx.keyClassification.classifyKeys null { + name = "probe"; + meta = { }; + __futureMarker.includes = [ ]; + futureMarker.includes = [ ]; + }; + in + { + expr = { + inherit (cls) + classKeys + nestedKeys + unregisteredClassKeys + pipeKeys + ; + }; + expected = { + classKeys = [ ]; + nestedKeys = [ "futureMarker" ]; + unregisteredClassKeys = [ ]; + pipeKeys = [ ]; + }; + } + ); + + }; +} From a206a21ecfab2816a625fd72cfaede7b697ff271 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Wed, 9 Sep 2026 09:03:43 -0700 Subject: [PATCH 50/59] docs: record that the two deferral stubs are minimal on purpose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deferHandler's stub and deferConditional's stub read like carry-forward whitelists and are not: the real aspect is queued intact and resolves in full at the drain, while the stub only registers a deferred identity. Verified before writing — the three resolve-complete handlers in the tree (identity.collectPathsHandler, trace.nix's two) read name, meta, __entityKind and __ctxId, none reads includes; resolvedNodes' only consumer (has-aspect.nix's augment) reads name and meta.aspect-chain. --- nix/lib/aspects/fx/handlers/compile-conditional.nix | 8 ++++++++ nix/lib/aspects/fx/handlers/defer.nix | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/nix/lib/aspects/fx/handlers/compile-conditional.nix b/nix/lib/aspects/fx/handlers/compile-conditional.nix index f7985318d..cddd89b01 100644 --- a/nix/lib/aspects/fx/handlers/compile-conditional.nix +++ b/nix/lib/aspects/fx/handlers/compile-conditional.nix @@ -149,6 +149,14 @@ let deferConditional = condNode: let + # Bookkeeping only — deliberately NOT a rebuild of condNode. The node is + # queued intact by the defer-conditional effect below and re-evaluated at + # the entity boundary; this record exists so the deferred conditional + # still registers an identity, and every resolve-complete consumer + # (identity.collectPathsHandler, trace.nix) reads name/meta and nothing + # else. guard/aspects are stripped because the payload has not fired, and + # `includes = [ ]` states that this marker carries no children of its own + # — a reset, not a dropped carry-forward. stub = { name = condNode.name or ""; meta = diff --git a/nix/lib/aspects/fx/handlers/defer.nix b/nix/lib/aspects/fx/handlers/defer.nix index 23d442bbe..7934d00e8 100644 --- a/nix/lib/aspects/fx/handlers/defer.nix +++ b/nix/lib/aspects/fx/handlers/defer.nix @@ -26,6 +26,14 @@ in 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"; + # Bookkeeping only — deliberately NOT a rebuild of `child`. The child + # is queued intact below and resolves in full when the drain fires; + # this record exists so the deferred node still registers an identity, + # and every resolve-complete consumer (identity.collectPathsHandler, + # trace.nix) reads name/meta and nothing else. `includes = [ ]` states + # that this marker carries no children of its own — it is a reset, not + # a dropped carry-forward, and adding structural state here would only + # duplicate what the drained `child` emits later. stub = { name = child.name or ""; meta = (child.meta or { }) // { From 6bf4d24b0deb105ea801fc5edb30b2eabe32f468 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Wed, 9 Sep 2026 09:07:40 -0700 Subject: [PATCH 51/59] refactor: drop the unread isStructuralKey re-export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `fx.aspect` re-exported the key predicate with no reader in or out of the module. The dead export predates the rule refactor — `structuralKeysSet` was re-exported here and equally unread — so it was renamed rather than introduced. Leaving it tells the next reader the seam is load-bearing when nothing crosses it. --- nix/lib/aspects/fx/aspect.nix | 1 - 1 file changed, 1 deletion(-) diff --git a/nix/lib/aspects/fx/aspect.nix b/nix/lib/aspects/fx/aspect.nix index 16c3d9a38..b6a176ac7 100644 --- a/nix/lib/aspects/fx/aspect.nix +++ b/nix/lib/aspects/fx/aspect.nix @@ -185,7 +185,6 @@ in emitIncludes emitAspectPolicies chainWrap - isStructuralKey wrapClassModule ctxFromHandlers enterScope From 3549c12caf617bece75d1bb9a158c673b89245bf Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Wed, 9 Sep 2026 09:27:01 -0700 Subject: [PATCH 52/59] fix: make an inert entity-fan verdict leave a residue for the terminal drain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bind rules an aspect inert when an entity arg is neither in-ctx nor a descendant. That silence is correct in the main pipeline — the aspect is delivered at another scope — but the post-assembly drain is terminal, so there is no later scope and the content is gone with no trace in any of the five scoped-effect maps the drain guard scans. Record the verdict where it is made (bind) and read it where terminality is known (the drain's residueKinds), rather than inferring a loss from an absent scopedClassImports, which is indistinguishable from an aspect that legitimately delivers nothing. Only the misplaced-entity-arg verdict is recorded: zero-children has no target to deliver to, and the shared-with-descendant verdict is double-cover avoidance, where the descendant does receive the content. --- nix/lib/aspects/fx/handlers/bind.nix | 12 +- nix/lib/aspects/fx/handlers/default.nix | 1 + nix/lib/aspects/fx/handlers/inert.nix | 28 +++++ nix/lib/aspects/fx/pipeline.nix | 2 + nix/lib/aspects/fx/resolve.nix | 20 ++-- .../deadbugs/d1-undeliverable-residue.nix | 103 ++++++++++++++++++ .../internal-api/fx-compile-parametric.nix | 6 +- 7 files changed, 162 insertions(+), 10 deletions(-) create mode 100644 nix/lib/aspects/fx/handlers/inert.nix diff --git a/nix/lib/aspects/fx/handlers/bind.nix b/nix/lib/aspects/fx/handlers/bind.nix index 650e8782e..120eb3ae4 100644 --- a/nix/lib/aspects/fx/handlers/bind.nix +++ b/nix/lib/aspects/fx/handlers/bind.nix @@ -205,8 +205,18 @@ in misplaced = builtins.filter (k: !(builtins.elem k descendants)) entityMissing; in # An entity arg that is neither in-ctx nor a descendant → inert. + # Silent here by design, but the verdict is recorded: a TERMINAL + # walk (resolve.nix's post-assembly drain) has no later scope to + # deliver at, and reads this residue to tell a vanished delivery + # from an aspect that legitimately emits nothing. The other two + # inert verdicts stay unrecorded on purpose — zero children has no + # target to deliver to, and the shared-with-descendant case is + # double-cover avoidance, where the descendant does receive it. if misplaced != [ ] then - fx.pure { inert = true; } + fx.bind (fx.send "record-inert" { + aspect = aspect.name or ""; + args = misplaced; + }) (_: 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 diff --git a/nix/lib/aspects/fx/handlers/default.nix b/nix/lib/aspects/fx/handlers/default.nix index d03150e79..ac51e6e33 100644 --- a/nix/lib/aspects/fx/handlers/default.nix +++ b/nix/lib/aspects/fx/handlers/default.nix @@ -21,6 +21,7 @@ args: // (import ./resolve.nix args) // (import ./bind.nix args) // (import ./defer.nix args) +// (import ./inert.nix args) // (import ./drain.nix args) // (import ./scope-widen.nix args) // (import ./classify.nix args) diff --git a/nix/lib/aspects/fx/handlers/inert.nix b/nix/lib/aspects/fx/handlers/inert.nix new file mode 100644 index 000000000..24b17f630 --- /dev/null +++ b/nix/lib/aspects/fx/handlers/inert.nix @@ -0,0 +1,28 @@ +# Effect handler: record-inert +# Records bind's misplaced-entity-arg inert verdict into scoped state. +# +# The verdict is silent and correct in the main pipeline — an aspect inert at +# one scope is delivered at another — so nothing reads this there. It exists +# for positions that know they are TERMINAL (resolve.nix's post-assembly +# drain), where there is no later scope and the verdict means the content is +# gone for good. Only bind knows the verdict; only the drain knows it is +# terminal, so each states its own half. +_: +let + inherit (import ./state-util.nix) scopedAppend; + + recordInertHandler = { + "record-inert" = + { param, state }: + let + scope = state.currentScope; + in + { + resume = null; + state = scopedAppend state "scopedInertAspects" scope (param // { sourceScopeId = scope; }); + }; + }; +in +{ + inherit recordInertHandler; +} diff --git a/nix/lib/aspects/fx/pipeline.nix b/nix/lib/aspects/fx/pipeline.nix index 851b954ca..c5830a626 100644 --- a/nix/lib/aspects/fx/pipeline.nix +++ b/nix/lib/aspects/fx/pipeline.nix @@ -71,6 +71,7 @@ let // handlers.compileStaticHandler // handlers.bindHandler // handlers.deferHandler + // handlers.recordInertHandler // handlers.drainHandler // handlers.scopeWidenHandler // handlers.classifyHandler @@ -179,6 +180,7 @@ let scopedClassImports = _: { }; scopedAspectPolicies = _: { }; scopedDeferredIncludes = _: { }; + scopedInertAspects = _: { }; scopedDeferredConditionals = _: { }; scopedIncludesChain = _: { }; scopedIncludesChainSegments = _: { }; diff --git a/nix/lib/aspects/fx/resolve.nix b/nix/lib/aspects/fx/resolve.nix index 36c01c027..16b8644a7 100644 --- a/nix/lib/aspects/fx/resolve.nix +++ b/nix/lib/aspects/fx/resolve.nix @@ -737,18 +737,21 @@ let # deferred child carrying both direct class content and # a `resolve.to` policy delivered the direct half and # dropped the policy half with no diagnostic). Guard on - # that residue instead: throw loud when any of the five + # that residue instead: throw loud when any of the scoped # scoped-effect maps hold something for this walk, # rather than deliver `scopedClassImports` alone and # lose the rest quietly. # - # Known gap NOT covered here: a deferred child whose own - # `includes` fans over an entity arg loses its content - # with no residue in any of these maps (`includeSeen` is - # set, `scopedClassImports` is simply absent) — closing - # that needs `bind`'s entity-arg fan classification, a - # different position entirely (D1 F1 arm C, open). - # Per-scope values are lists for four of these + # A deferred child whose own `includes` fans over an entity + # arg is covered by the same guard: this walk has no entity + # kind, so every entity arg there is misplaced and bind + # rules the aspect inert. That verdict left no trace in any + # effect map (`includeSeen` set, `scopedClassImports` simply + # absent), which is indistinguishable from an aspect that + # legitimately emits nothing — so bind records the verdict + # itself into scopedInertAspects (handlers/inert.nix) and it + # reads as residue below (D1 F1 arm C). + # Per-scope values are lists for five of these # (scopedAppend) but scopedAspectPolicies is a merged # attrset keyed by policy name (scopedMerge, policy.nix) # — normalise both to a list before concatenating. @@ -765,6 +768,7 @@ let "scopedInstantiates" "scopedProvides" "scopedDeferredIncludes" + "scopedInertAspects" ]; in if residueKinds != [ ] then diff --git a/templates/ci/modules/deadbugs/d1-undeliverable-residue.nix b/templates/ci/modules/deadbugs/d1-undeliverable-residue.nix index b6e6813af..d9f6f314a 100644 --- a/templates/ci/modules/deadbugs/d1-undeliverable-residue.nix +++ b/templates/ci/modules/deadbugs/d1-undeliverable-residue.nix @@ -68,6 +68,65 @@ let ]; }; }; + + # ARM C — pipe-arg deferred, whose OWN `includes` fans over an entity arg. + # The drain walk has no entity kind, so `user` is neither in-ctx nor a + # descendant: bind rules the whole child inert and 10410 vanished with no + # residue in any effect map (D1 F1 arm C). 10400 kept delivering, so the + # loss showed up as nothing at all. + fixtureEntityFan = den: { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.quirks.firewall.description = "Firewall port declarations"; + + den.aspects.igloo = { + firewall.ports = [ 22 ]; + includes = [ + ( + { firewall, ... }: + { + name = "gate3-deferred"; + nixos.networking.firewall.allowedTCPPorts = [ 10400 ]; + includes = [ + ( + { user, ... }: + { + name = "fan-over-user"; + nixos.networking.firewall.allowedTCPPorts = [ 10410 ]; + } + ) + ]; + } + ) + ]; + }; + }; + + # ARM D — the live control for arm C's ruling. The pipe-arg-deferred child + # legitimately delivers nothing into the drained class, while the run's + # other scopes deliver normally. A guard written on the ABSENCE of + # scopedClassImports cannot tell this from arm C and throws here; a guard + # written on bind's recorded verdict stays silent. Measured both ways: with + # the absence guard swapped in, this cell goes red and arm C goes green — + # the two are not interchangeable, and arm C alone would not have caught it. + fixtureInertElsewhere = den: { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.quirks.firewall.description = "Firewall port declarations"; + + den.aspects.tux.homeManager.programs.direnv.enable = true; + + den.aspects.igloo = { + firewall.ports = [ 22 ]; + nixos.networking.firewall.allowedTCPPorts = [ 10500 ]; + includes = [ + ( + { firewall, ... }: + { + name = "gate4-deferred"; + } + ) + ]; + }; + }; in { flake.tests.d1residue = { @@ -109,5 +168,49 @@ in expected = true; } ); + + # ARM C, the exhibit: the entity-fan half must be loud, not absent. + test-entity-fan-residue-throws-loud = denTest ( + { den, igloo, ... }: + (fixtureEntityFan den) + // { + expr = igloo.networking.firewall.allowedTCPPorts; + expectedError = { + type = "ThrownError"; + msg = "left undeliverable content"; + }; + } + ); + + test-entity-fan-residue-throws-loud-under-just-ci = denTest ( + { den, igloo, ... }: + (fixtureEntityFan den) + // { + expr = (builtins.tryEval (builtins.deepSeq igloo.networking.firewall.allowedTCPPorts null)).success; + expected = false; + } + ); + + # ARM D, the control: same drain, an aspect that legitimately delivers + # nothing HERE and delivers at the user scope. Must stay silent. + test-inert-elsewhere-drains-silently = denTest ( + { + den, + igloo, + tuxHm, + ... + }: + (fixtureInertElsewhere den) + // { + expr = [ + (builtins.elem 10500 igloo.networking.firewall.allowedTCPPorts) + tuxHm.programs.direnv.enable + ]; + expected = [ + true + true + ]; + } + ); }; } diff --git a/templates/ci/modules/internal-api/fx-compile-parametric.nix b/templates/ci/modules/internal-api/fx-compile-parametric.nix index 5e90f6cc9..f70864e22 100644 --- a/templates/ci/modules/internal-api/fx-compile-parametric.nix +++ b/templates/ci/modules/internal-api/fx-compile-parametric.nix @@ -209,7 +209,10 @@ inherit state; }; }; - # No scope handlers — bind will defer. + # No scope handlers — `host` is an entity kind with no scope kind to be + # a descendant of, so bind rules it misplaced → inert (it never reaches + # defer). recordInertHandler is listed for the same reason deferHandler + # is: this composition must handle every effect bind can send. comp = fx.send "compile-parametric" param; result = fx.handle { handlers = @@ -217,6 +220,7 @@ // handlers.gateHandler // handlers.bindHandler // handlers.deferHandler + // handlers.recordInertHandler // identity.collectPathsHandler // stubs; inherit state; From 6127cbc090175926d0eb86aa2706a4720cec2329 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Wed, 9 Sep 2026 10:02:25 -0700 Subject: [PATCH 53/59] fix: flatten an aspect's excludes so a list-wrapped policy reference excludes `providerType`'s check names a list of policy records as a valid element, so `excludes = [ [ policy ] ]` type-checked and then excluded nothing: with no list arm in `excludeIdentity`, `identity.key` reduced the list to "" and matched no policy. `includes` flattens the same value and delivers, so the two disagreed over a shape their shared type calls valid. This is the silent no-op 3c5b5227 closed for bare strings, still open over the shape that commit's own type admits. Its message claimed routing `excludes` through `providerType` made "both error alike"; they did not. Also record why bind's shared-with-descendant verdict is not reported to the terminal drain. Double-cover avoidance is not a vanished delivery, so recording it would throw on correct behaviour, and no cell can catch that mistake: the drain walk starts a fresh pipeline where scopeKind is null, which makes recording all three inert sites observationally identical to recording one. --- nix/lib/aspects/fx/aspect/children.nix | 7 +- nix/lib/aspects/fx/handlers/bind.nix | 10 +++ .../deadbugs/list-element-exclude.nix | 77 +++++++++++++++++++ 3 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 templates/ci/modules/features/deadbugs/list-element-exclude.nix diff --git a/nix/lib/aspects/fx/aspect/children.nix b/nix/lib/aspects/fx/aspect/children.nix index 441d908fc..1c847d94b 100644 --- a/nix/lib/aspects/fx/aspect/children.nix +++ b/nix/lib/aspects/fx/aspect/children.nix @@ -246,7 +246,12 @@ let aspect: let rawHandleWith = aspect.meta.handleWith or null; - rawExcludes = aspect.excludes or [ ]; + # Flattened for the same reason `includes` is (fx/aspect.nix): `providerType` + # names a list of policy records as a valid element, so `excludes = [ [ p ] ]` + # type-checks. Unflattened it reached `identity.key` as a list, which yields + # "" and excludes nothing — the silent no-op #3c5b5227 closed for bare + # strings, still open over the shape that commit's own type admits. + rawExcludes = lib.flatten (aspect.excludes or [ ]); handleWithList = if rawHandleWith == null then [ ] diff --git a/nix/lib/aspects/fx/handlers/bind.nix b/nix/lib/aspects/fx/handlers/bind.nix index 120eb3ae4..4a2f48378 100644 --- a/nix/lib/aspects/fx/handlers/bind.nix +++ b/nix/lib/aspects/fx/handlers/bind.nix @@ -232,6 +232,16 @@ in fanable = argClass.fanableDescendants schema scopeKind availRecords descendants; pick = if fanable != [ ] then builtins.head fanable else builtins.head descendants; in + # Deliberately NOT recorded via `record-inert`, unlike the + # misplaced-entity verdict above: double-cover avoidance is not a + # vanished delivery — the descendant does receive this content — + # so recording it would make the terminal drain's residue guard + # throw on correct behaviour. No cell can catch that mistake: + # recording all three inert sites is observationally identical to + # recording one across the whole suite, because the drain walk + # starts a fresh pipeline where `scopeKind` is null and + # `arg-class.nix` leaves `descendants` empty. The reason is + # semantic, and this comment is the only instrument guarding it. if sharedWithDescendant pick then fx.pure { inert = true; } else fanOut pick ) # Only non-entity (pipe/conditional/enrichment) args remain → defer. diff --git a/templates/ci/modules/features/deadbugs/list-element-exclude.nix b/templates/ci/modules/features/deadbugs/list-element-exclude.nix new file mode 100644 index 000000000..264c73ff8 --- /dev/null +++ b/templates/ci/modules/features/deadbugs/list-element-exclude.nix @@ -0,0 +1,77 @@ +# `excludes = [ [ policy ] ]` type-checked and excluded NOTHING, in silence. +# +# `providerType`'s check names a list of policy records as a valid element, so +# the value was admitted; `excludeIdentity` had no list arm, so `identity.key` +# reduced it to "" and matched no policy. The same value in `includes` +# flattens and delivers, so the two disagreed over a shape their shared type +# calls valid — the silent no-op #3c5b5227 closed for bare strings, still open +# over the shape that commit's own type admits. +# +# Three arms in one run, because no single cell discriminates both directions: +# over-excluding is visible, under-excluding is silent. Arm three is why the +# other two are not vacuous — without a firing policy they pass on a marker that +# never existed. +{ denTest, ... }: +{ + flake.tests.list-element-exclude = { + # The fixed direction: a list-wrapped policy reference must exclude. + test-list-element-exclude = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.policies.add-marker = _: [ + (den.lib.policy.include { + nixos.environment.variables.LIST_EXCLUDE_MARKER = "yes"; + }) + ]; + den.aspects.igloo = { + includes = [ den.policies.add-marker ]; + excludes = [ [ den.policies.add-marker ] ]; + }; + + expr = igloo.environment.variables.LIST_EXCLUDE_MARKER or "absent"; + expected = "absent"; + } + ); + + # Control A — the record form already worked. If this reddens, the fix broke + # the path it was meant to leave alone. + test-control-record-exclude = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.policies.add-marker = _: [ + (den.lib.policy.include { + nixos.environment.variables.LIST_EXCLUDE_MARKER = "yes"; + }) + ]; + den.aspects.igloo = { + includes = [ den.policies.add-marker ]; + excludes = [ den.policies.add-marker ]; + }; + + expr = igloo.environment.variables.LIST_EXCLUDE_MARKER or "absent"; + expected = "absent"; + } + ); + + # Control B — with nothing excluded the marker must actually fire. + test-control-no-exclude = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.policies.add-marker = _: [ + (den.lib.policy.include { + nixos.environment.variables.LIST_EXCLUDE_MARKER = "yes"; + }) + ]; + den.aspects.igloo = { + includes = [ den.policies.add-marker ]; + }; + + expr = igloo.environment.variables.LIST_EXCLUDE_MARKER or "absent"; + expected = "yes"; + } + ); + }; +} From 71e6ec1d97ebdde8fba3c9390babb5bdfdac4873 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Wed, 9 Sep 2026 10:17:37 -0700 Subject: [PATCH 54/59] fix: reserve structural names from the provides forward at both raw sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mkUnderscore's shadow comment held only at the declared-submodule site. The two raw sites — providerType.merge's battery attrset and aspectContentType's nested key — have no submodule and no option defaults to win the providesChildren fold, so a provides child named name/description/meta/includes landed in the aspect's own option position and was read structurally from there. provides.name at a raw site became the aspect's own name and died coercing a set to a string. Reserve those names from the forward only; .provides/._ still reach every child, so _ stays a total alias for provides. --- nix/lib/aspects/types.nix | 65 +++++++----- .../structural-name-provides-child.nix | 99 +++++++++++++++++-- 2 files changed, 128 insertions(+), 36 deletions(-) diff --git a/nix/lib/aspects/types.nix b/nix/lib/aspects/types.nix index a1ee32be0..26b39b334 100644 --- a/nix/lib/aspects/types.nix +++ b/nix/lib/aspects/types.nix @@ -121,27 +121,40 @@ let own: path: let # A provides child's NAME lives in a different namespace than the - # aspect's own top-level keys — `isStructuralKey` classifies the - # latter (own-key dispatch) and does not apply here: every declared - # aspect option (description, meta, includes, …) already has a - # default, so `merged` always wins the top-level - # `providesChildren // merged` shadow for those names (see - # unshadowedProvides below) while `.provides`/`._` still reach the - # child's own content untouched — measured per key, nothing else in - # the structural registry is genuine machinery at this seam. `_module` is - # real NixOS module-system machinery, but it never reaches - # `own.provides`'s attrNames in the first place (the module system - # consumes it before freeform merge), so no explicit reservation is - # needed for it either. - # - # Two keys ARE genuine machinery here: `__`-prefixed (pipeline - # internals) and `_` — a multi-def nested key merges into a content - # wrapper carrying `__contentValues`/`__aspectChain`/`_` beside its - # real children (aspectContentType below), and `_` is the one of - # those three not already caught by the `__`-prefix rule. + # aspect's own top-level keys, so `.provides`/`._` reach every child + # untouched whatever it is called. Two keys ARE genuine machinery even + # there: `__`-prefixed (pipeline internals) and `_` — a multi-def + # nested key merges into a content wrapper carrying + # `__contentValues`/`__aspectChain`/`_` beside its real children + # (aspectContentType below), and `_` is the one of those three not + # already caught by the `__`-prefix rule. `_module` is real NixOS + # module-system machinery but never reaches `own.provides`'s attrNames + # (the module system consumes it before freeform merge), so it needs no + # reservation here. providesChildren = lib.filterAttrs (k: _: !(lib.hasPrefix "__" k) && k != "_") ( own.provides or { } ); + # Forwarding a child onto the aspect's own top level is a different + # question from reaching it through `._`, and it is the one that + # depends on the construction site. Where the aspect is a declared + # submodule (mergeWithAspectMeta) every aspect option already has a + # default, so `merged` wins the `providesChildren // merged` shadow for + # those names on its own. The two RAW sites — providerType.merge's + # functor-carrying battery attrset and aspectContentType's nested + # freeform key — have no submodule and therefore no defaults to win it, + # so an unreserved child named `name`/`includes`/`meta`/… would land in + # the aspect's own option position and be read structurally from there. + # `forwardable` is what those two sites fold, making one user-written + # shape behave the same at all three: the aspect's own value keeps the + # top level, the child stays reachable at `._.`. + # + # Reserved by the structural registry rather than by the declared-option + # list, because that registry is what decides own-key dispatch in the + # first place. It is a superset by two inert names: `into` (declared + # only on the deprecated den.ctx shim) and anything in user + # `den.reservedKeys` — both reachable through `._` exactly as the + # declared options are. + forwardable = lib.filterAttrs (k: _: !(isStructuralKey k)) providesChildren; childKeys = builtins.filter isChildKey (builtins.attrNames own); functor = { __functor = _self: _args: { @@ -151,7 +164,7 @@ let }; in { - inherit providesChildren functor; + inherit providesChildren forwardable functor; syntheticProvides = providesChildren // functor; }; @@ -315,12 +328,10 @@ let normalizedFn = foldUnderscoreIntoProvides fn; aspectName = fn.name or (lib.last loc); underscore = mkUnderscore normalizedFn ((typeCfg.chain or typeCfg.origin) ++ [ aspectName ]); - inherit (underscore) providesChildren; - unshadowedProvides = builtins.filter (k: !(normalizedFn ? ${k})) ( - builtins.attrNames providesChildren - ); + inherit (underscore) forwardable; + unshadowedProvides = builtins.filter (k: !(normalizedFn ? ${k})) (builtins.attrNames forwardable); in - providesChildren + forwardable // normalizedFn // { __providesForwarded = unshadowedProvides; @@ -717,12 +728,12 @@ let # single-def path is unaffected because a raw attrset carries none of # these keys. topUnderscore = mkUnderscore annotatedMerged provider; - inherit (topUnderscore) providesChildren; + inherit (topUnderscore) forwardable; unshadowedProvides = builtins.filter (k: !(annotatedMerged ? ${k})) ( - builtins.attrNames providesChildren + builtins.attrNames forwardable ); in - providesChildren + forwardable // annotatedMerged // { __contentValues = flatDefs; diff --git a/templates/ci/modules/features/deadbugs/structural-name-provides-child.nix b/templates/ci/modules/features/deadbugs/structural-name-provides-child.nix index 8608529ad..a653fd369 100644 --- a/templates/ci/modules/features/deadbugs/structural-name-provides-child.nix +++ b/templates/ci/modules/features/deadbugs/structural-name-provides-child.nix @@ -1,19 +1,28 @@ # `mkUnderscore` (nix/lib/aspects/types.nix) filtered a provides child's NAME -# through `structuralKeysSet` — the set that classifies an ASPECT'S OWN -# top-level keys for content dispatch. Reusing it here filtered out any +# through the structural-key registry — the set that classifies an ASPECT'S +# OWN top-level keys for content dispatch. Reusing it there filtered out any # provides child whose author-chosen name happened to also be an ordinary # aspect option (description, meta, name, includes, excludes, provides, # policies, into, classes): the child was silently dropped from # `.provides`/`._` with no error, and the aspect's own option default (e.g. # `description = "Aspect "`) read back in its place. # -# The only genuine machinery at THIS seam is the `__`-prefix pipeline-internal -# convention and `_` itself — `_` is the namespace's own alias sigil, and a -# multi-def nested key's content wrapper injects a literal `_` alongside its -# real children (see multidef-provides-internals.nix), so an unfiltered `_` -# leaks wrapper machinery as a provides key. Every other structural-key name -# now survives: it is reachable via `.provides`/`._` while the aspect's own -# declared option (always present, via its default) keeps top-level priority. +# Reaching the child and forwarding it onto the aspect's own top level are two +# separate questions, and only the first is site-independent. `.provides`/`._` +# reach every child whatever it is called; the only genuine machinery at THAT +# seam is the `__`-prefix pipeline-internal convention and `_` itself — `_` is +# the namespace's own alias sigil, and a multi-def nested key's content wrapper +# injects a literal `_` alongside its real children (see +# multidef-provides-internals.nix), so an unfiltered `_` leaks wrapper +# machinery as a provides key. +# +# Forwarding is reserved against the structural registry, because a child +# landing in the aspect's own option position is read structurally from there. +# A declared submodule wins that position by itself (every aspect option has a +# default), but the two RAW construction sites have no submodule and no +# defaults, so they need the reservation to behave the same — `provides.name` +# at a raw site used to become the aspect's own name and die coercing a set to +# a string. `mkUnderscore.forwardable` is what the raw sites fold. { denTest, ... }: { flake.tests.deadbugs.structural-name-provides-child = { @@ -89,5 +98,77 @@ expected = false; } ); + + # Arm two of test-structural-name-survives-at-all-sites. That cell reaches + # the same three children through `._` and delivers their content; this one + # shows none of them displaced the aspect's own option to get there. The + # two must disagree about the top level and agree about `._`. + test-own-option-not-displaced-at-any-site = denTest ( + { den, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + # site A — declared submodule + den.aspects.d10sa = { + description = "own-desc"; + provides.description.nixos.environment.etc."d10-sa".text = "y"; + }; + # site B — functor-carrying battery attrset + den.aspects.d10sh.provides.d10sb = { + __functor = _self: _args: { }; + _.description.nixos.environment.etc."d10-sb".text = "y"; + }; + # site C — nested freeform key + den.aspects.d10sc.holder.provides.description.nixos.environment.etc."d10-sc".text = "y"; + + expr = { + ownValue = den.aspects.d10sa.description; + # No submodule at a raw site means no own value to read — and the + # child no longer supplies one in its place. + rawBatteryTop = den.aspects.d10sh.d10sb ? description; + rawNestedTop = den.aspects.d10sc.holder ? description; + # …while `._` still reaches all three. + reachA = den.aspects.d10sa._ ? description; + reachB = den.aspects.d10sh.d10sb._ ? description; + reachC = den.aspects.d10sc.holder._ ? description; + }; + expected = { + ownValue = "own-desc"; + rawBatteryTop = false; + rawNestedTop = false; + reachA = true; + reachB = true; + reachC = true; + }; + } + ); + + # A child named `name` reached the top level at the raw sites, where the + # pipeline reads it as the aspect's own name: including the holder died on + # `cannot coerce a set to a string`, with no `den:` prefix and no mention + # of the collision. Both the holder's own content and the child deliver. + test-name-collision-does-not-break-its-holder = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.aspects.d10n.holder = { + nixos.environment.etc."d10-n-own".text = "y"; + provides.name.nixos.environment.etc."d10-n".text = "y"; + }; + den.aspects.igloo.includes = [ + den.aspects.d10n.holder + den.aspects.d10n.holder._.name + ]; + + expr = { + own = igloo.environment.etc ? "d10-n-own"; + child = igloo.environment.etc ? "d10-n"; + }; + expected = { + own = true; + child = true; + }; + } + ); }; } From 8fd5c6bd46005aa97945df9e6fa16d2327253d6f Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Wed, 9 Sep 2026 10:28:07 -0700 Subject: [PATCH 55/59] fix: surface the evaluator's stderr in ci.bash, and correct nine stale claims ci.bash sent nix-eval-jobs' stderr to /dev/null, so a run the evaluator could not finish produced EXIT 1, no summary, zero failures tallied and nothing saying why. Route it to a file, read PIPESTATUS rather than letting set -e abort the summary, and print it. The rest are comments and docs that a commit made false: the residue guard's 'any of the scoped-effect maps' (it is six of fourteen, and the three exclusions are derived, not measured); the two deferral stubs' 'name/meta and nothing else' (also __entityKind and __ctxId); the record-inert handler rule, recorded at the fx.send site where the next author will meet it; meta.provider in the public reference, renamed to meta.aspect-chain and silently absorbed by meta's freeform type if written; and five citations to deleted identifiers and retired suites, one of which claimed coverage from a suite that no longer exists. --- CLAUDE.md | 2 +- ci.bash | 36 +++++++++++++++++-- docs/src/content/docs/reference/aspects.mdx | 28 ++++++++++----- nix/lib/aspects/fx/edge-trace.nix | 6 ++-- .../aspects/fx/edges/materialize-unified.nix | 15 ++++---- nix/lib/aspects/fx/handlers/bind.nix | 10 ++++++ .../fx/handlers/compile-conditional.nix | 7 ++-- nix/lib/aspects/fx/handlers/defer.nix | 5 ++- nix/lib/aspects/fx/resolve.nix | 30 +++++++++++++--- .../ci/modules/internal-api/edge-trace.nix | 17 +++++---- .../ci/modules/internal-api/entity-scale.nix | 2 +- .../ci/modules/public-api/angle-brackets.nix | 3 +- 12 files changed, 125 insertions(+), 36 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 93e1be4d9..a4e76a247 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -119,7 +119,7 @@ New test files must be `git add`'d before nix can evaluate them. Use `--override - Idiomatic Nix: use `lib.optional` `lib.optionals` `lib.optionalAttrs` for basic conditionals - Idiomatic Nix: avoid `with` — prefer `inherit` to bring names into scope. `with` obscures where bindings come from and breaks tooling. - Error messages: prefix with `den:` for traceability (e.g., `throw "den: multiple __functor definitions at ..."`) -- Internal markers: double-underscore prefixed attrs (`__contentValues`, `__provider`, `__fn`) are pipeline internals. Don't add new ones without understanding the classification and structural key filtering in `key-classification.nix`. +- Internal markers: double-underscore prefixed attrs (`__contentValues`, `__aspectChain`, `__fn`) are pipeline internals. Don't add new ones without understanding the classification and structural key filtering in `key-classification.nix`. - Commenting: comments should describe why not what, code should be self documenting as to what - Minimal changes: fix the bug, don't refactor surroundings - Diagnose before reverting: the fix is usually one targeted change diff --git a/ci.bash b/ci.bash index f21ec2720..a4777dea1 100644 --- a/ci.bash +++ b/ci.bash @@ -53,6 +53,7 @@ if test -n "$testFilter"; then fi results=$(mktemp -t den-test-XXXXX.json) +evalLog=$(mktemp -t den-test-XXXXX.err) # Cap workers and per-worker memory to prevent OOM from infinite recursion. # nproc can be very high (32+); limit workers so worst-case memory is bounded. @@ -60,6 +61,10 @@ max_workers=8 mem_per_worker=2048 # MiB workers=$(( $(nproc) < max_workers ? $(nproc) : max_workers )) +# set +e around the pipeline: under `set -e` a dying evaluator aborts the +# script here, so the summary below never runs and the exit status arrives +# with nothing said. Read PIPESTATUS instead and report it. +set +e nix-eval-jobs \ --flake ./templates/ci#tests${preSuite} \ --override-input den . \ @@ -94,9 +99,26 @@ nix-eval-jobs \ builtins.mapAttrs (k: go (if prefix == "" then k else "${prefix}.${k}")) v else derivation { name = "SKIP"; system = "${system}"; builder = "/bin/sh"; args = ["-c" "echo > $out"]; }; in builtins.mapAttrs (k: go k) tests' \ - "${args[@]}" 2>/dev/null \ + "${args[@]}" 2>"$evalLog" \ | tee "$results" \ | jq -r 'if (.name != null and (.name | startswith("PASS-"))) then "✅ '"${postSuite}"'" + .attr else empty end' +evalStatus=${PIPESTATUS[0]} +set -e + +# A dead evaluator is not a test failure and must not be tallied as one: +# `total` below is pass+fail over whatever reached the JSON stream, so a run +# that stopped early still reads as a clean N/N with zero failures. The +# evaluator's stderr is the only thing that says which file and line killed +# it, so it is a file now rather than /dev/null. +if [ "$evalStatus" -ne 0 ]; then + echo >&2 + echo "💥 EVALUATOR FAILED (nix-eval-jobs exit ${evalStatus})" >&2 + echo "The run stopped early — no tally covers what it did not reach." >&2 + echo >&2 + cat "$evalLog" >&2 + rm -f "$evalLog" "$results" + exit "$evalStatus" +fi pass=$(jq -r 'select(.name != null and (.name | startswith("PASS-"))) | "."' "$results" | wc -l) fail=$(jq -r 'select(.error != null or (.name != null and (.name | startswith("FAIL-")))) | "."' "$results" | wc -l) @@ -104,7 +126,7 @@ total=$(expr "$pass" + "$fail") if [ "$fail" -eq "0" ]; then echo "🎉 ${pass}/${total} successful" >&2 - rm "$results" || true + rm -f "$results" "$evalLog" || true else echo >&2 echo "💥 FAILURES (${fail}):" >&2 @@ -114,6 +136,14 @@ else jq -r 'select(.error != null or (.name != null and (.name | startswith("FAIL-")))) | "❌ '"${postSuite}"'" + .attr' "$results" >&2 echo >&2 echo "😢 ${pass}/${total} successful" >&2 - rm "$results" || true + # Only when the evaluator actually said something. An ordinary assertion + # failure leaves nothing here but nix's lock-file warnings, and burying the + # list of failures under those is how a diagnostic stops being read. + if grep -q "^error:" "$evalLog"; then + echo >&2 + echo "--- evaluator stderr ---" >&2 + cat "$evalLog" >&2 + fi + rm -f "$results" "$evalLog" || true exit 1 fi diff --git a/docs/src/content/docs/reference/aspects.mdx b/docs/src/content/docs/reference/aspects.mdx index 84c78b911..4af687bb5 100644 --- a/docs/src/content/docs/reference/aspects.mdx +++ b/docs/src/content/docs/reference/aspects.mdx @@ -114,17 +114,29 @@ for the full API and examples. longer exists. -### `meta.provider` +### `meta.aspect-chain` -Type: `listOf str` (internal, read-only). Default: the provider prefix. +Type: `listOf str` or `null` (internal, read-only). No default -- a declared +aspect sets its own chain. -Tracks the structural origin of an aspect as a path. Top-level aspects have -`meta.provider = []`. An aspect provided by `foo` (via `foo.provides.bar` or -its alias `foo._.bar`) has `meta.provider = ["foo"]`. Deeply nested -providers accumulate: `foo._.bar._.baz` has `meta.provider = ["foo" "bar"]`. +Tracks the structural origin of an aspect as a path. A top-level aspect has +`meta.aspect-chain = []`. An aspect provided by `foo` (via `foo.provides.bar` +or its alias `foo._.bar`) has `meta.aspect-chain = ["foo"]`. Deeply nested +providers accumulate: `foo._.bar._.baz` has +`meta.aspect-chain = ["foo" "bar"]`. -The `meta.provider` list distinguishes aspects by origin during pipeline -resolution. +`null` and `[]` are different values. `[]` means "root -- the chain is +empty"; `null` means "no chain set", which is what an inline `includes` +literal carries until something fills it in. Absence, not root. + +The chain distinguishes aspects by origin during pipeline resolution. + + ### `meta.guard` / `meta.aspects` -- conditional aspects diff --git a/nix/lib/aspects/fx/edge-trace.nix b/nix/lib/aspects/fx/edge-trace.nix index c29ad9b0b..554fb52f9 100644 --- a/nix/lib/aspects/fx/edge-trace.nix +++ b/nix/lib/aspects/fx/edge-trace.nix @@ -57,7 +57,8 @@ let # 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). + # provides through (resolve.nix → materializeUnified's ordered-dispatch fold, + # edges/materialize-unified.nix). # 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 @@ -125,7 +126,8 @@ in # ===== provides edges (two-edge decomposition, §B Decision 1) ====== # 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 + # through (resolve.nix → materializeUnified's ordered-dispatch fold). 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). diff --git a/nix/lib/aspects/fx/edges/materialize-unified.nix b/nix/lib/aspects/fx/edges/materialize-unified.nix index 68153615e..575900290 100644 --- a/nix/lib/aspects/fx/edges/materialize-unified.nix +++ b/nix/lib/aspects/fx/edges/materialize-unified.nix @@ -1,9 +1,11 @@ # materialize-unified.nix — the ordered-dispatch delivery engine (Task 17). # -# Today the fx delivery pipeline materializes via PHASE FOLDS: phase2 applies ALL -# provides (edges/provides.nix applyProvidesEdges), THEN phase3 applies ALL routes -# (edges/route.nix applyRoutes, which itself toposorts its route specs). The -# accumulator `{ classImports; perScope }` threads through both. +# It REPLACED phase folds, and they are gone: phase2 applied ALL provides +# (edges/provides.nix `applyProvidesEdges`), THEN phase3 applied ALL routes +# (edges/route.nix `applyRoutes`, which itself toposorted its route specs), +# threading an accumulator `{ classImports; perScope }` through both. Neither +# function still exists; the shape is described here only because the ordering +# argument below is stated against it. # # materializeUnified collapses that into ONE ordered-dispatch fold that INTERLEAVES # provides + routes in `topoSortEdges` order, reusing the EXISTING per-spec @@ -24,8 +26,9 @@ # This engine IS production delivery at every site as of Task 17 (fxResolveFull, # fxResolveImports, the per-host re-walk, and the spawn re-entry all fold it). It # was proven byte-equivalent to the old phase2∘phase3 (+ optional assembleSubtree) -# by the fx-materialize-unified suite (the materializeEquiv oracle in resolve.nix -# keeps that comparison standing). Its `exposeEdges` mode (Task 18) returns the +# by the fx-materialize-unified suite and its materializeEquiv oracle. Both were +# retired with the phase folds, so that equivalence is history rather than a +# standing comparison. Its `exposeEdges` mode (Task 18) returns the # folded provides+routes edge records so the production `edgeTrace` is captured, # not re-derived. { lib, den }: diff --git a/nix/lib/aspects/fx/handlers/bind.nix b/nix/lib/aspects/fx/handlers/bind.nix index 4a2f48378..48822ed74 100644 --- a/nix/lib/aspects/fx/handlers/bind.nix +++ b/nix/lib/aspects/fx/handlers/bind.nix @@ -212,6 +212,16 @@ in # inert verdicts stay unrecorded on purpose — zero children has no # target to deliver to, and the shared-with-descendant case is # double-cover avoidance, where the descendant does receive it. + # + # Adding an fx.send is not a local change: every hand-composed + # handler set under templates/ci/modules/internal-api/ has to list + # a handler for it. One of the twelve bare compositions there + # installs recordInertHandler; the other eleven are green only + # because they do not reach this branch, and nothing structurally + # stops a future cell in them from doing so. The failure is + # `unhandled effect ''`, which names nothing about the test + # that caused it, and it surfaces as ☢️ rather than ❌ — a gate + # tallying only ❌ reads it clean. Sweep that directory. if misplaced != [ ] then fx.bind (fx.send "record-inert" { aspect = aspect.name or ""; diff --git a/nix/lib/aspects/fx/handlers/compile-conditional.nix b/nix/lib/aspects/fx/handlers/compile-conditional.nix index cddd89b01..fbe9d8f5f 100644 --- a/nix/lib/aspects/fx/handlers/compile-conditional.nix +++ b/nix/lib/aspects/fx/handlers/compile-conditional.nix @@ -153,8 +153,11 @@ let # queued intact by the defer-conditional effect below and re-evaluated at # the entity boundary; this record exists so the deferred conditional # still registers an identity, and every resolve-complete consumer - # (identity.collectPathsHandler, trace.nix) reads name/meta and nothing - # else. guard/aspects are stripped because the payload has not fired, and + # (identity.collectPathsHandler, trace.nix) reads name, meta, + # `__entityKind` and `__ctxId` — none reads includes. Omitting the two + # `__` keys costs this marker the `{ctxId}` instance suffix + # identity.nix's aspectPath would append; the base path registers either + # way. guard/aspects are stripped because the payload has not fired, and # `includes = [ ]` states that this marker carries no children of its own # — a reset, not a dropped carry-forward. stub = { diff --git a/nix/lib/aspects/fx/handlers/defer.nix b/nix/lib/aspects/fx/handlers/defer.nix index 7934d00e8..43cfacc68 100644 --- a/nix/lib/aspects/fx/handlers/defer.nix +++ b/nix/lib/aspects/fx/handlers/defer.nix @@ -30,7 +30,10 @@ in # is queued intact below and resolves in full when the drain fires; # this record exists so the deferred node still registers an identity, # and every resolve-complete consumer (identity.collectPathsHandler, - # trace.nix) reads name/meta and nothing else. `includes = [ ]` states + # trace.nix) reads name, meta, `__entityKind` and `__ctxId` — none + # reads includes. Omitting the two `__` keys costs this marker the + # `{ctxId}` instance suffix identity.nix's aspectPath would append; the + # base path registers either way. `includes = [ ]` states # that this marker carries no children of its own — it is a reset, not # a dropped carry-forward, and adding structural state here would only # duplicate what the drained `child` emits later. diff --git a/nix/lib/aspects/fx/resolve.nix b/nix/lib/aspects/fx/resolve.nix index 16b8644a7..0f8a8895b 100644 --- a/nix/lib/aspects/fx/resolve.nix +++ b/nix/lib/aspects/fx/resolve.nix @@ -737,10 +737,30 @@ let # deferred child carrying both direct class content and # a `resolve.to` policy delivered the direct half and # dropped the policy half with no diagnostic). Guard on - # that residue instead: throw loud when any of the scoped - # scoped-effect maps hold something for this walk, - # rather than deliver `scopedClassImports` alone and - # lose the rest quietly. + # that residue instead: throw loud when one of the six + # scoped-effect maps listed at `residueKinds` below holds + # something for this walk, rather than deliver + # `scopedClassImports` alone and lose the rest quietly. + # + # Six of the fourteen scope-partitioned maps + # (pipeline.nix), not all of them. `scopedClassImports` is + # the one delivered rather than guarded, and the + # includes-chain, constraint-registry and emitted-loc maps + # are bookkeeping rather than deliverable content. Three + # maps that DO carry deliverable content are left out + # because they cannot be reached on this walk — DERIVED by + # reading their senders and consumers, not measured, and + # that derivation is the guard's whole warrant: + # - scopedPipeEffects, scopedSpawns — written only by + # policy effect emission (policy/apply.nix), and this + # walk never dispatches policies; + # - scopedDeferredConditionals — cleared in-walk: + # resolve-children fires drain-conditionals at the + # sub-pipeline's own root and compile-conditional + # empties the scope's bucket. + # A fourth policy-independent sender, or a push-scope path + # that skips policy dispatch, re-opens exactly the class + # this guard closes. Add the map to the list below. # # A deferred child whose own `includes` fans over an entity # arg is covered by the same guard: this walk has no entity @@ -1037,7 +1057,7 @@ let # deterministic structural edges production invokes via assembleSubtree / # applyInstantiates (no drift surface). This corrects the legacy oracle's # spawn rewalk UNDERCOUNT. A lazy thunk — forced only by inspection / the - # delivery-edges + fx-unified-edges suites, never by normal resolve consumers. + # delivery-edges suite, never by normal resolve consumers. productionEdgeTrace = sortEdges ( materialized.edges ++ topLevelEdgeParts.defaultFold diff --git a/templates/ci/modules/internal-api/edge-trace.nix b/templates/ci/modules/internal-api/edge-trace.nix index 35a481f6b..5984f092d 100644 --- a/templates/ci/modules/internal-api/edge-trace.nix +++ b/templates/ci/modules/internal-api/edge-trace.nix @@ -3,8 +3,9 @@ # Task 18.2 `edgeTrace` is the production edge object: its fold-ordered # provides+routes portion is CAPTURED from the production materializeUnified folds # (not re-derived), with constructor-built default-fold + instantiate edges and the -# SURFACED spawn / per-host edges. This means (vs the legacy re-derivation, now -# `legacyEdgeTrace`): the dedup-suppressed route twins are ABSENT (production never +# SURFACED spawn / per-host edges. This means (vs the legacy re-derivation, whose +# `legacyEdgeTrace` binding has since been retired): the dedup-suppressed route +# twins are ABSENT (production never # dispatches them), the spawn rewalk arm is replaced by the spawn's real surfaced # edges, and instantiate topologies carry the per-host fold edges. # @@ -739,8 +740,9 @@ in # level the host is the ctx-seeded root (not a resolve.to-created entity scope # in scopeEntityKind), so the drain-fold spawn arm is a no-op — neither the # rewalk edge NOR a surfaced-spawn edge exists here. The surfaced-spawn edges - # only appear at FLAKE level (asserted in fx-unified-edges / - # fx-oracle-production-differential). So the production host trace carries NO + # only appear at FLAKE level — which the fx-unified-edges and + # fx-oracle-production-differential suites asserted until both were retired; + # no suite asserts it today. So the production host trace carries NO # rewalk-source edge. test-topology-host-aspects-spawn = denTest ( { den, lib, ... }: @@ -1026,8 +1028,11 @@ in # Suppression annotation ABSENT in the production object: the production edge # object (Task 18.2) CAPTURES the edges its fold dispatched (kept routes only), # so the legacy oracle's dedup-suppressed twin — which carried - # `suppressed = true` — is never present. The suppressed-twin edge lives in - # legacyEdgeTrace, asserted by the fx-oracle-production-differential suite. + # `suppressed = true` — is never present. The suppressed twin lived in the + # legacy re-derivation; that oracle and the + # fx-oracle-production-differential suite that compared the two are both + # retired, so nothing asserts the twin's shape now. This cell covers one + # direction only: the production object's ABSENCE of the annotation. test-corollary-suppression-annotation = denTest ( { den, lib, ... }: let diff --git a/templates/ci/modules/internal-api/entity-scale.nix b/templates/ci/modules/internal-api/entity-scale.nix index 41a470a50..d072b1e81 100644 --- a/templates/ci/modules/internal-api/entity-scale.nix +++ b/templates/ci/modules/internal-api/entity-scale.nix @@ -1,4 +1,4 @@ -# PERFORMANCE SUITE COVERAGE — read this before trusting `perf 29/29`. +# PERFORMANCE SUITE COVERAGE — read this before trusting `perf 31/31`. # # Every OTHER file feeding `flake.tests.performance` (resolve.nix, depth.nix, # forward.nix, namespace.nix, ctx-pipeline.nix, ctx-chain.nix, pure-eval.nix, diff --git a/templates/ci/modules/public-api/angle-brackets.nix b/templates/ci/modules/public-api/angle-brackets.nix index d39f9f1df..65e4507c0 100644 --- a/templates/ci/modules/public-api/angle-brackets.nix +++ b/templates/ci/modules/public-api/angle-brackets.nix @@ -96,7 +96,8 @@ ); # Regression: non-parametric direct freeform child must not duplicate content. - # Without __contentValues in structuralKeysSet, the content wrapper's + # Without __contentValues classified as structural (the `__`-prefix rule + # in key-classification.nix), the content wrapper's # __contentValues key is classified as class content and emitted alongside # the forwarded nixos attr — applying overlays/config twice. test-direct-child-no-duplication = denTest ( From ce38772dc02aff67ccd7e873f63567ffd221e5eb Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Wed, 9 Sep 2026 10:48:57 -0700 Subject: [PATCH 56/59] feat: warn when a raw-ref exclude matches no claim anywhere in a resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An exclude naming a policy record den never found resolved to null, was filtered out, and suppressed nothing with no diagnostic. The check is global, not per-scope: resolveRawRefIdentity returning null for one scope is correct behaviour — a host-scope exclude reaches every descendant scope and the policy it names is typically claimed in only some of them — so warning from isPolicyExcluded would fire on every legitimate multi-scope exclude. It reads the terminal state at fxResolveFull's post-assembly position instead, where both the constraint registry and policyClaimsByName are complete. --- nix/lib/aspects/fx/handlers/constraint.nix | 38 ++++++++ nix/lib/aspects/fx/resolve.nix | 10 +- .../features/unmatched-policy-exclude.nix | 91 +++++++++++++++++++ 3 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 templates/ci/modules/features/unmatched-policy-exclude.nix diff --git a/nix/lib/aspects/fx/handlers/constraint.nix b/nix/lib/aspects/fx/handlers/constraint.nix index 975987f5e..2da76de3a 100644 --- a/nix/lib/aspects/fx/handlers/constraint.nix +++ b/nix/lib/aspects/fx/handlers/constraint.nix @@ -198,6 +198,43 @@ let in builtins.any directApplies directEntries || rawRefExcluded ? ${name}; + # The `den:` diagnostics for raw-ref excludes that resolved to no claim + # ANYWHERE in a finished resolution — a user naming a record den never found, + # suppressing nothing, previously in silence. + # + # DELIBERATELY NOT ON THE PER-SCOPE PATH. resolveRawRefIdentity returning null + # for one scope is ordinary correct behaviour: an exclude registered at host + # scope reaches every descendant scope, and the policy it names is typically + # claimed in only some of them. Warning from isPolicyExcluded would fire on + # every non-matching scope of every legitimate exclude. The warnable condition + # is global — no claim in the whole run carries this reference — so it takes + # the TERMINAL state, and is read once at post-assembly (resolve.nix's + # fxResolveFull), never during dispatch. + # + # Whole-value `==` against the claim bucket, matching resolveClaim's rule + # exactly, minus its scope walk: the question here is whether the referenced + # record registered AT ALL, not whether it registered somewhere a given scope + # can see. Deliberately the weaker test — a claim in an unreachable sibling + # scope stays silent rather than risk a false alarm. + unmatchedRawRefExcludes = + state: + let + registry = (state.scopedConstraintRegistry or (_: { })) null; + claims = (state.policyClaimsByName or (_: { })) null; + rawRefEntries = builtins.filter (e: e.type == "exclude" && (e.rawRef or null) != null) ( + builtins.concatMap (scopeData: builtins.concatLists (builtins.attrValues scopeData)) ( + builtins.attrValues registry + ) + ); + isUnmatched = e: !(builtins.any (c: c.value == e.rawRef) (claims."name:${e.rawRef.name}" or [ ])); + in + lib.unique ( + map ( + e: + "den: exclude in aspect '${e.owner}' names policy '${e.rawRef.name}', which never registered in this resolution — the exclude suppresses nothing" + ) (builtins.filter isUnmatched rawRefEntries) + ); + entryToResume = entry: if entry.type == "exclude" then @@ -319,6 +356,7 @@ in foldScopeAncestors resolveClaim isPolicyExcluded + unmatchedRawRefExcludes collectScopedConstraints scopedConstraintsFor scopedConstraintsForScope diff --git a/nix/lib/aspects/fx/resolve.nix b/nix/lib/aspects/fx/resolve.nix index 0f8a8895b..80df90bf8 100644 --- a/nix/lib/aspects/fx/resolve.nix +++ b/nix/lib/aspects/fx/resolve.nix @@ -1068,7 +1068,15 @@ let ); in { - imports = phase4.${class} or [ ]; + # Terminal position for the unmatched-raw-ref-exclude diagnostic: both + # the constraint registry and policyClaimsByName are complete on + # result.state here, and nothing per-scope can decide the question (see + # unmatchedRawRefExcludes in handlers/constraint.nix). Attached to + # `imports` so it surfaces exactly when the resolved module set is + # consumed, not when a path-set or edge-trace reader touches the bundle. + imports = lib.foldl' (v: msg: lib.warn msg v) (phase4.${class} or [ ]) ( + handlers.unmatchedRawRefExcludes result.state + ); # Surfaced from the SAME result.state — this is thunked onto state. pathSetByScope = result.state.pathSetByScope null; # Per-scope ctx + entity-kind, so the entity surface can re-key the path diff --git a/templates/ci/modules/features/unmatched-policy-exclude.nix b/templates/ci/modules/features/unmatched-policy-exclude.nix new file mode 100644 index 000000000..c1eda610a --- /dev/null +++ b/templates/ci/modules/features/unmatched-policy-exclude.nix @@ -0,0 +1,91 @@ +# A raw-ref exclude naming a policy den never found must say so. +# +# The two cells disagree on purpose. The diagnostic is GLOBAL — "this reference +# matched no claim anywhere in the run" — because an exclude that resolves in +# one scope and not another is CORRECT behaviour, not a user error. The control +# below is the whole reason the check cannot sit on the per-scope dispatch path: +# it holds a host-scope exclude whose policy is claimed only under one of two +# users, so isPolicyExcluded genuinely answers true at one scope and false at +# the other, and nothing may warn about it. +{ denTest, ... }: +let + # The finished pipeline state for igloo — the terminal registries the + # diagnostic reads, same shape resolve.nix's post-assembly sees. + hostState = + den: + let + fxLib = den.lib.aspects.fx; + hostRoot = den.lib.resolveEntity "host" { host = den.hosts.x86_64-linux.igloo; }; + in + (fxLib.pipeline.fxFullResolve { + class = "nixos"; + ctx = fxLib.aspect.ctxFromHandlers (hostRoot.__scopeHandlers or { }); + self = den.lib.aspects.normalizeRoot hostRoot; + }).state; +in +{ + flake.tests.unmatched-policy-exclude = { + + test-exclude-naming-unregistered-policy-warns = denTest ( + { den, ... }: + let + hostRoot = den.lib.resolveEntity "host" { host = den.hosts.x86_64-linux.igloo; }; + in + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.policies.never-registered = _: [ + (den.lib.policy.include { nixos.environment.variables.MARKER = "yes"; }) + ]; + den.aspects.igloo.excludes = [ den.policies.never-registered ]; + + expr = { + messages = den.lib.aspects.fx.handlers.unmatchedRawRefExcludes (hostState den); + # Forces the wired production path so the warning actually reaches + # stderr; the message text itself is asserted above. + resolves = (den.lib.aspects.resolve "nixos" hostRoot).imports != [ ]; + }; + expected = { + messages = [ + "den: exclude in aspect 'igloo' names policy 'never-registered', which never registered in this resolution — the exclude suppresses nothing" + ]; + resolves = true; + }; + } + ); + + # The live control. tux claims the policy, pingu does not; the exclude is + # declared once at host scope and reaches both. Excluded at tux, not at + # pingu — and silent. + test-exclude-matching-only-one-scope-is-silent = denTest ( + { den, ... }: + let + handlers = den.lib.aspects.fx.handlers; + st = hostState den; + scopes = builtins.attrNames (st.scopeContexts null); + scopeMatching = pat: builtins.head (builtins.filter (s: builtins.match pat s != null) scopes); + excludedAt = + scope: + handlers.isPolicyExcluded st scope (handlers.scopedConstraintsForScope st scope) "add-marker"; + in + { + den.hosts.x86_64-linux.igloo.users.pingu = { }; + den.hosts.x86_64-linux.igloo.users.tux.aspect.includes = [ den.policies.add-marker ]; + den.policies.add-marker = _: [ + (den.lib.policy.include { homeManager.programs.git.enable = true; }) + ]; + den.aspects.igloo.excludes = [ den.policies.add-marker ]; + + expr = { + atTux = excludedAt (scopeMatching ".*user=tux.*"); + atPingu = excludedAt (scopeMatching ".*user=pingu.*"); + messages = handlers.unmatchedRawRefExcludes st; + }; + expected = { + atTux = true; + atPingu = false; + messages = [ ]; + }; + } + ); + }; +} From 463ae0acc216edfb898c76fc01a05af2f1080f79 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 15 Sep 2026 12:20:51 -0700 Subject: [PATCH 57/59] fix: honour includes and excludes written on an entity instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `den.hosts...includes` was accepted and dropped in silence. resolveEntity built the entity root aspect's collections from exactly two sources — the entity's self-provide and the schema tier — and nothing read them off the instance, while the instance type is built with `strict = false`, so its freeform type absorbed the key instead of raising "option does not exist". The host built with an empty aspect tree, and the only signal was a warning about a different fact, emitted only when no same-named aspect existed either (#663). `includes` is the activation key at the aspect, schema and default tiers, which is what makes the instance spelling the natural guess, so it is honoured rather than rejected. Read in resolveEntity rather than per entity kind, so every kind declaring `isEntity` gains it at once, and read last in the include order: an instance is the most specific site an entity's content can be written at, so its definitions merge over what its kind's schema supplies. --- nix/lib/resolve-entity.nix | 18 +++- .../deadbugs/issue-663-instance-includes.nix | 82 +++++++++++++++++++ 2 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 templates/ci/modules/features/deadbugs/issue-663-instance-includes.nix diff --git a/nix/lib/resolve-entity.nix b/nix/lib/resolve-entity.nix index b93fe39eb..803e99c53 100644 --- a/nix/lib/resolve-entity.nix +++ b/nix/lib/resolve-entity.nix @@ -28,6 +28,20 @@ let # context. This mirrors config.resolved in options.nix which reads # _module.args for entity-kind keys. entity = ctx.${name} or null; + # The instance's own collections (`den.hosts...includes`, and the + # same key on any other entity kind). Read here rather than per entity + # kind so every kind declaring `isEntity` honours the key at once, and + # read at all because the instance type is built with `strict = false`: + # its freeform type absorbed the key instead of raising "option does not + # exist", so it was accepted and dropped in silence (#663). `includes` is + # the activation key at the aspect, schema and default tiers, which is + # what makes the instance spelling the natural guess. + # + # Last in the include order, after the schema tier: an instance is the + # most specific site an entity's content can be written at, so its + # definitions merge over what its kind's schema supplies. + instanceCollection = + key: if entity == null || !builtins.isAttrs entity then [ ] else entity.${key} or [ ]; entityDerivedBindings = if entity == null || !builtins.isAttrs entity then { } @@ -69,8 +83,8 @@ let handleWith = null; aspect-chain = [ ]; }; - excludes = schemaExcludes; - includes = selfProvide ++ schemaIncludes; + excludes = schemaExcludes ++ instanceCollection "excludes"; + includes = selfProvide ++ schemaIncludes ++ instanceCollection "includes"; __entityKind = name; __scopeHandlers = scopeHandlers; }; diff --git a/templates/ci/modules/features/deadbugs/issue-663-instance-includes.nix b/templates/ci/modules/features/deadbugs/issue-663-instance-includes.nix new file mode 100644 index 000000000..72e03fb9c --- /dev/null +++ b/templates/ci/modules/features/deadbugs/issue-663-instance-includes.nix @@ -0,0 +1,82 @@ +# Issue #663: `includes` written on an entity INSTANCE +# (`den.hosts...includes`) is accepted and silently dropped. +# +# `resolveEntity` built the entity root aspect's collections from exactly two +# sources — the entity's self-provide (`den.aspects.`) and the +# schema-level collection (`den.schema..includes`) — and nothing read +# them off the instance. The instance type is constructed with +# `strict = false`, so its freeform type absorbed the key instead of raising +# "option does not exist": the two behaviours are individually reasonable and +# jointly silent, and the host built with an empty aspect tree. +# +# `includes` is the activation key at every other tier, so the instance +# spelling is the natural guess. Read there too, rather than rejected, and +# read in `resolveEntity` rather than per entity kind, so every kind that +# declares `isEntity` gains it at once. +{ denTest, ... }: +{ + flake.tests.deadbugs.issue-663-instance-includes = { + + test-host-instance-includes-honoured = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.includes = [ den.aspects.base ]; + den.aspects.base.nixos.environment.etc."from-base".text = "yes"; + + expr = igloo.environment.etc ? "from-base"; + expected = true; + } + ); + + # CONTROL: the same aspect through the aspect-level spelling, guarding + # against a false green from `den.aspects.base` never having delivered at + # all. The failure mode here is "produces a plausible value", so the pair + # is what discriminates it. + test-control-aspect-includes-honoured = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo = { }; + den.aspects.igloo.includes = [ den.aspects.base ]; + den.aspects.base.nixos.environment.etc."from-base".text = "yes"; + + expr = igloo.environment.etc ? "from-base"; + expected = true; + } + ); + + # `excludes` shares the instance position and was dropped by the same + # omission, so it is pinned in the same place: the schema tier includes a + # policy for every host, the instance excludes it. + test-host-instance-excludes-honoured = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.excludes = [ den.policies.marker ]; + + den.policies.marker = + { host, ... }: [ (den.lib.policy.include { nixos.environment.etc."marker".text = host.name; }) ]; + den.schema.host.includes = [ den.policies.marker ]; + + expr = igloo.environment.etc ? "marker"; + expected = false; + } + ); + + # CONTROL for the excludes cell: without the instance exclude the schema + # policy does fire, so the assertion above reads a suppression rather + # than a policy that never delivered. + test-control-schema-policy-fires = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo = { }; + + den.policies.marker = + { host, ... }: [ (den.lib.policy.include { nixos.environment.etc."marker".text = host.name; }) ]; + den.schema.host.includes = [ den.policies.marker ]; + + expr = igloo.environment.etc.marker.text or ""; + expected = "igloo"; + } + ); + + }; +} From eb16ea5bbfd455b5649f2107c47558e5eb940344 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 15 Sep 2026 12:41:45 -0700 Subject: [PATCH 58/59] fix: make the two schema-tier collections share one element check `den.schema..excludes` rejected a list-wrapped policy reference while `den.aspects.*.excludes` had just been fixed to honour one (6127cbc), so one user-written shape excluded at the aspect tier and threw at the schema tier. `providerType` names a list of policy records as a valid element and both tiers take the same references, so the divergence had no basis. The `includes` collection already recursed into nested lists and admitted a function; `excludes` did neither, and the two checks were separate copies diverging by inspection. One check parameterised by collection name now serves both, which is what keeps them from drifting again rather than re-stating the recursion twice. A nested bad leaf still errors with the `den:` message: the list arm admits the shape without dropping the check. --- modules/options.nix | 77 ++++++++----------- ...ema-tier-excludes-string-form-rejected.nix | 58 ++++++++++++++ 2 files changed, 92 insertions(+), 43 deletions(-) diff --git a/modules/options.nix b/modules/options.nix index bd1473c74..88a3abcbf 100644 --- a/modules/options.nix +++ b/modules/options.nix @@ -20,6 +20,38 @@ let # lazily at eval time, so they safely use den.lib.schema. schemaLib = import ./../nix/lib/schema.nix { inherit inputs lib; }; + # Element check for BOTH schema-tier collections. gen-schema has no + # per-collection `type` to route a bad element through, so the check that the + # aspect tier gets from `providerType` has to live here — and it has to be + # ONE check: the two collections take the same references, so a shape that + # excludes at the aspect tier must not throw at the schema tier. + # + # A bare string was the original defect at both. Unchecked, it reached + # children.nix's aspect walk and crashed with a raw Nix `expected a set but + # found a string` from propagateScope's `//` on the includes side, and on the + # excludes side `identity.key` reduced it to "", which matches no + # policy and so excluded nothing in silence. + # + # Recurses into nested lists because `providerType` names a list of policy + # records as a valid element and children.nix walks nested lists the same way + # at both tiers (`processInclude`, and `lib.flatten` over excludes since + # 6127cbc). Admits a function because a parametric aspect reference is one. + checkCollectionElement = + collection: + let + check = + v: + if builtins.isList v then + map check v + else if builtins.isAttrs v || lib.isFunction v then + v + else + throw "den: den.schema..${collection}: expected a policy or aspect reference, got ${ + if builtins.isString v then ''"${v}"'' else builtins.typeOf v + }"; + in + check; + classSchemaType = lib.types.submodule ( { ... }: { @@ -74,52 +106,11 @@ in collections = { includes = { default = [ ]; - # A bare-string (or other non-aspect) element used to reach - # children.nix's aspect walk unchecked and crash with a raw Nix - # `expected a set but found a string` from propagateScope's `//` — - # the aspect tier catches this via providerType's `check`, but this - # freeform collection has no type to route through, so validate here - # instead, same as excludes below. Recurses into nested lists: - # children.nix's processInclude walks nested lists the same way, so a - # bad leaf at any depth must still be caught, just with a den: - # message instead of the raw one. - merge = - acc: val: - let - check = - v: - if builtins.isList v then - map check v - else if builtins.isAttrs v || lib.isFunction v then - v - else - throw "den: den.schema..includes: expected a policy or aspect reference, got ${ - if builtins.isString v then ''"${v}"'' else builtins.typeOf v - }"; - in - acc ++ map check val; + merge = acc: val: acc ++ map (checkCollectionElement "includes") val; }; excludes = { default = [ ]; - # Bare-string elements used to be accepted and silently exclude - # nothing: `identity.key` (nix/lib/aspects/fx/identity.nix) reduces a - # string to "", which matches no policy. gen-schema has no - # per-collection `type` to route this through, so validate here — - # the same defect at the aspect tier (den.aspects.*.excludes) was - # fixed by routing it through a type; this is the equivalent - # declaration-time check for the untyped schema-tier collection. - merge = - acc: val: - acc - ++ map ( - v: - if builtins.isAttrs v then - v - else - throw "den: den.schema..excludes: expected a policy or aspect reference, got ${ - if builtins.isString v then ''"${v}"'' else builtins.typeOf v - }" - ) val; + merge = acc: val: acc ++ map (checkCollectionElement "excludes") val; }; isEntity = { default = false; diff --git a/templates/ci/modules/features/schema-tier-excludes-string-form-rejected.nix b/templates/ci/modules/features/schema-tier-excludes-string-form-rejected.nix index 8229b5f26..5f665e6c1 100644 --- a/templates/ci/modules/features/schema-tier-excludes-string-form-rejected.nix +++ b/templates/ci/modules/features/schema-tier-excludes-string-form-rejected.nix @@ -66,5 +66,63 @@ }; } ); + + # A list-wrapped policy reference excludes at the ASPECT tier as of + # 6127cbc, so it must not throw at the schema tier: `providerType` names a + # list of policy records as a valid element, and the two tiers take the + # same references. The schema-tier check had no list arm and rejected the + # shape its sibling tier had just been fixed to honour. + test-schema-excludes-list-wrapped-record-fires = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo = { }; + den.policies.s2-marker-d = _: [ + (den.lib.policy.include { + nixos.environment.variables.S2_MARKER_D = "yes"; + }) + ]; + den.aspects.igloo.includes = [ den.policies.s2-marker-d ]; + den.schema.host.excludes = [ [ den.policies.s2-marker-d ] ]; + + expr = igloo.environment.variables.S2_MARKER_D or "absent"; + expected = "absent"; + } + ); + + # CONTROL for the cell above: the same policy with no exclude at all does + # fire, so the "absent" there reads a suppression rather than a policy + # that never delivered. + test-schema-excludes-list-control-unexcluded-fires = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo = { }; + den.policies.s2-marker-e = _: [ + (den.lib.policy.include { + nixos.environment.variables.S2_MARKER_E = "yes"; + }) + ]; + den.aspects.igloo.includes = [ den.policies.s2-marker-e ]; + + expr = igloo.environment.variables.S2_MARKER_E or "absent"; + expected = "yes"; + } + ); + + # A bad leaf at depth is still caught, with the den: message rather than + # the raw Nix one — the list arm admits the shape without dropping the + # check, matching the includes collection. + test-schema-excludes-nested-string-still-errors = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo = { }; + den.schema.host.excludes = [ [ "s2-marker-f" ] ]; + + expr = igloo.networking.hostName; + expectedError = { + type = "ThrownError"; + msg = "den: den.schema..excludes"; + }; + } + ); }; } From cb5439b69a24524d427f54a97abc0d1e3fcd5b42 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 15 Sep 2026 12:43:33 -0700 Subject: [PATCH 59/59] docs: correct three claims this branch left stale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of the three sit in the aspect reference's own structural-key table, in the file this branch already edited: - `excludes` reads `listOf unspecified`. It is `listOf provider` since the type was routed through `providerType`, which is what makes a bare string an error rather than a silent no-op. - the `meta` row still lists `provider`. The same file's caution block 60 lines down documents the rename to `aspect-chain` and says a stale `meta.provider` is absorbed silently, so the table contradicted the prose beside it. The third is `.claude/skills/den-debugging.md`, the last `__provider` reference in the repo and the skill CLAUDE.md points debugging work at — an agent following it greps for a marker that no longer exists. The file is tracked but its directory is now ignored, so it needs an explicit add. `reference/schema.mdx`'s `listOf raw` for the schema tier is left alone: that collection really is raw-typed, with the element check in its merge. --- .claude/skills/den-debugging.md | 2 +- docs/src/content/docs/reference/aspects.mdx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude/skills/den-debugging.md b/.claude/skills/den-debugging.md index 5bfb30fa9..c38c6d011 100644 --- a/.claude/skills/den-debugging.md +++ b/.claude/skills/den-debugging.md @@ -152,7 +152,7 @@ When a bug involves content wrappers, check whether the forwarded (shallow-merge Look for structural markers that distinguish the working path from the broken path. In den's pipeline, common differentiators: - `__contentValues` — present on content wrappers from `aspectContentType`, absent on sub-aspects from `emitNestedAspect` and full aspects from `aspectSubmodule` -- `__provider` — tracks the definition path through nested aspects +- `__aspectChain` — tracks the definition path through nested aspects - `__providesForwarded` — keys forwarded from `provides` onto the aspect - `__fn` / `__args` — parametric wrappers - `__scopeHandlers` — context propagation diff --git a/docs/src/content/docs/reference/aspects.mdx b/docs/src/content/docs/reference/aspects.mdx index 4af687bb5..bdfb5add2 100644 --- a/docs/src/content/docs/reference/aspects.mdx +++ b/docs/src/content/docs/reference/aspects.mdx @@ -65,10 +65,10 @@ registered class), *nested aspect keys*, or one of these structural keys: |-----|------|---------| | `` | module / config | Config merged into entities of that class | | `includes` | `listOf provider` | Providers (aspects, sub-aspects, functions) pulled into this aspect | -| `excludes` | `listOf unspecified` | Aspects or policies excluded from this subtree | +| `excludes` | `listOf provider` | Aspects or policies excluded from this subtree | | `provides` / `_` | submodule | Sub-aspect namespace (`_` is an alias for `provides`) | | `policies` | policy registry | Named policy functions, activated by placing in `includes` | -| `meta` | submodule | Attached metadata (`handleWith`, `provider`, `collisionPolicy`; `guard`/`aspects` for conditional aspects) | +| `meta` | submodule | Attached metadata (`handleWith`, `aspect-chain`, `collisionPolicy`; `guard`/`aspects` for conditional aspects) | | `classes` | `lazyAttrsOf raw` | Class schemas declared by this aspect, merged into `den.classes` | | `name` / `description` | `str` | Aspect name and description |