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/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 c6afc5b74..a4777dea1 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 @@ -48,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. @@ -55,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 . \ @@ -68,8 +78,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 { @@ -81,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) @@ -91,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 @@ -101,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..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 | @@ -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/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/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-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/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-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/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/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/modules/options.nix b/modules/options.nix index ea8a1f77f..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,9 +106,11 @@ in collections = { includes = { default = [ ]; + merge = acc: val: acc ++ map (checkCollectionElement "includes") val; }; excludes = { default = [ ]; + merge = acc: val: acc ++ map (checkCollectionElement "excludes") val; }; isEntity = { default = false; 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/default.nix b/nix/lib/aspects/default.nix index e90b39960..4008ee165 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.isStructuralKey k && !(functorRootOwnedKeys ? ${k}) + ) resolved else resolved; @@ -99,7 +123,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/fx/aspect.nix b/nix/lib/aspects/fx/aspect.nix index 6501dcf88..b6a176ac7 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 = @@ -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,8 +73,7 @@ let fnArgNames = builtins.attrNames (aspect.__args or { }); }; } - // lib.optionalAttrs (aspect ? into) { inherit (aspect) into; } - // lib.optionalAttrs (aspect ? provides) { inherit (aspect) provides; }; + // lib.filterAttrs (k: _: isStructuralKey k && !(parametricOwnedKeysSet ? ${k})) aspect; # Merge the resolved value into the parametric base. mkParametricNext = @@ -159,7 +185,6 @@ in emitIncludes emitAspectPolicies chainWrap - structuralKeysSet wrapClassModule ctxFromHandlers enterScope diff --git a/nix/lib/aspects/fx/aspect/children.nix b/nix/lib/aspects/fx/aspect/children.nix index f64126cde..1c847d94b 100644 --- a/nix/lib/aspects/fx/aspect/children.nix +++ b/nix/lib/aspects/fx/aspect/children.nix @@ -7,6 +7,13 @@ let inherit (den.lib) fx; inherit (den.lib.aspects.fx) identity; inherit (import ./normalize.nix { inherit lib den; }) wrapChild isMeaningfulName; + # 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; }) resolveClaim; nameIndexed = state: base: idx: ctxId: @@ -23,13 +30,17 @@ 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 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: shouldPush: comp: + 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 = identity.pathKey nodeSegments; + segments = nodeSegments; + }) (_: fx.bind comp (result: fx.bind (fx.send "chain-pop" null) (_: fx.pure result))) else comp; @@ -51,13 +62,92 @@ 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 — 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, 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.send "register-aspect-policy" { - inherit (p) fn; - ownerIdentity = identity.key 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 [ ]; + 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 + # 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}#${toString claimIndex}" ]); + 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; @@ -93,13 +183,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 @@ -142,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 [ ] @@ -153,26 +262,43 @@ 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; - 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/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..65dfe98a8 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 = identity.ownChain aspect ++ [ aspectName ]; selfProvide = true; }; in @@ -130,7 +130,7 @@ let // ( if isParamWrapper then builtins.removeAttrs (providerVal.meta or { }) [ - "provider" + "aspect-chain" "selfProvide" ] else 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/edge-trace.nix b/nix/lib/aspects/fx/edge-trace.nix index f243abbd0..554fb52f9 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 @@ -43,7 +39,6 @@ let sortEdges collected rewalk - synthesize rootTarget outputTarget ; @@ -62,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 @@ -75,15 +71,14 @@ 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. 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, @@ -131,7 +126,8 @@ rec { # ===== 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). @@ -245,24 +241,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/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/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/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/handlers/bind.nix b/nix/lib/aspects/fx/handlers/bind.nix index 650e8782e..48822ed74 100644 --- a/nix/lib/aspects/fx/handlers/bind.nix +++ b/nix/lib/aspects/fx/handlers/bind.nix @@ -205,8 +205,28 @@ 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. + # + # 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.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 @@ -222,6 +242,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/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..fbe9d8f5f 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.aspectPath condNode) true ( emitIncludes { __parentScopeHandlers = condNode.__scopeHandlers or null; __parentCtxId = condNode.__ctxId or null; @@ -149,6 +149,17 @@ 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, + # `__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 = { name = condNode.name or ""; meta = diff --git a/nix/lib/aspects/fx/handlers/compile-static.nix b/nix/lib/aspects/fx/handlers/compile-static.nix index cbd59ba6d..6fe4ce8fb 100644 --- a/nix/lib/aspects/fx/handlers/compile-static.nix +++ b/nix/lib/aspects/fx/handlers/compile-static.nix @@ -30,9 +30,106 @@ 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. + # + # __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. + defPos = withoutParametricKeys.meta.__defPos or null; + defValue = withoutParametricKeys.meta.__defValue or null; + chainRegistry = ((state.chainByDefPos 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, 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. + 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; + claimedChain = if matchingClaim == null then null else matchingClaim.chain; + 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 = filledChain; + }; + } + else + withoutParametricKeys; nodeIdentity = identity.key aspect; - chainIdentity = identity.pathKey ((aspect.meta.provider or [ ]) ++ [ (aspect.name or "") ]); + nextState = + if fillsChain && defPos != null && matchingClaim == null then + let + updated = chainRegistry // { + ${defPos} = claimedEntries ++ [ + { + 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 + # producer of a chain-push agrees on what a segment list means. + chainSegments = identity.aspectPath aspect; isMeaningful = isMeaningfulName (aspect.name or ""); in { @@ -70,14 +167,14 @@ in _: fx.bind (fx.send "resolve-children" { aspect = tagged; - inherit isMeaningful chainIdentity; + inherit isMeaningful chainSegments; }) (resolved: fx.pure [ resolved ]) ) ) ) ) ); - inherit state; + state = nextState; }; }; } diff --git a/nix/lib/aspects/fx/handlers/constraint.nix b/nix/lib/aspects/fx/handlers/constraint.nix index d6ce25210..2da76de3a 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,127 @@ 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: + # (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. + # + # 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. + # 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. + # + # 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: + let + 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 || 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 @@ -137,6 +279,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 +354,9 @@ in lookupEntries isAncestorChain foldScopeAncestors + resolveClaim + isPolicyExcluded + unmatchedRawRefExcludes collectScopedConstraints scopedConstraintsFor scopedConstraintsForScope 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/defer.nix b/nix/lib/aspects/fx/handlers/defer.nix index 23d442bbe..43cfacc68 100644 --- a/nix/lib/aspects/fx/handlers/defer.nix +++ b/nix/lib/aspects/fx/handlers/defer.nix @@ -26,6 +26,17 @@ 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, `__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. stub = { name = child.name or ""; meta = (child.meta or { }) // { diff --git a/nix/lib/aspects/fx/handlers/dispatch-policies.nix b/nix/lib/aspects/fx/handlers/dispatch-policies.nix index 00f97ce80..c878fd637 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; + # 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/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/handlers/resolve-children.nix b/nix/lib/aspects/fx/handlers/resolve-children.nix index b5c2f83ac..f5dd44a4a 100644 --- a/nix/lib/aspects/fx/handlers/resolve-children.nix +++ b/nix/lib/aspects/fx/handlers/resolve-children.nix @@ -50,7 +50,10 @@ 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. + chainSegments = param.chainSegments or [ ]; in { resume = @@ -77,7 +80,7 @@ in ) ); in - fx.bind (chainWrap chainIdentity isMeaningful (resolveChildSequence aspect)) ( + fx.bind (chainWrap chainSegments isMeaningful (resolveChildSequence aspect)) ( allChildren: fx.bind (maybeDrain allChildren) ( finalChildren: diff --git a/nix/lib/aspects/fx/identity.nix b/nix/lib/aspects/fx/identity.nix index 3e2c2a5ac..89e68ed9f 100644 --- a/nix/lib/aspects/fx/identity.nix +++ b/nix/lib/aspects/fx/identity.nix @@ -4,9 +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.provider 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; @@ -15,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.provider or [ ]) ++ [ (a.name or "") ]); + baseKey = a: pathKey (ownChain a ++ [ (a.name or "") ]); # True when an identity string refers to an anonymous/unresolved node. isAnonIdentity = @@ -134,6 +145,7 @@ let in { inherit + ownChain aspectPath pathKey key diff --git a/nix/lib/aspects/fx/key-classification.nix b/nix/lib/aspects/fx/key-classification.nix index bb51ec886..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,23 +17,20 @@ let "policies" "into" "classes" - "__fn" - "__args" - "__functor" - "__functionArgs" - "__scopeHandlers" - "__ctxId" - "__entityKind" - "__parametricResolvedArgs" - "__contentValues" - "__provider" - "__providesForwarded" "_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 @@ -74,7 +72,7 @@ let builtins.isAttrs val && builtins.any ( sk: - structuralKeysSet ? ${sk} + isStructuralKey sk || pipeRegistry ? ${sk} || (classRegistry ? ${sk} && looksLikeClassContent val.${sk}) ) (builtins.attrNames val); @@ -83,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 @@ -111,5 +109,5 @@ let }; in { - inherit structuralKeysSet classifyKeys pipeRegistry; + inherit isStructuralKey classifyKeys pipeRegistry; } diff --git a/nix/lib/aspects/fx/pipeline.nix b/nix/lib/aspects/fx/pipeline.nix index c58f85954..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 @@ -155,13 +156,34 @@ let pathSetByScope = _: { }; # Full resolved nodes keyed by unique identity, for entity.aspects. resolvedNodes = _: { }; + # 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 = _: { }; + # 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 = _: { }; scopedAspectPolicies = _: { }; scopedDeferredIncludes = _: { }; + scopedInertAspects = _: { }; scopedDeferredConditionals = _: { }; scopedIncludesChain = _: { }; + scopedIncludesChainSegments = _: { }; scopedConstraintRegistry = _: { }; # Flat filter list only (excludes/substitutes are entity-scoped via # scopedConstraintRegistry; filters have no scoped registry). diff --git a/nix/lib/aspects/fx/policy/schema.nix b/nix/lib/aspects/fx/policy/schema.nix index 4a805d910..529093f58 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; + # 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; }; diff --git a/nix/lib/aspects/fx/resolve.nix b/nix/lib/aspects/fx/resolve.nix index dfe66dee1..80df90bf8 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. @@ -693,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. @@ -735,46 +704,112 @@ 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; + }; + # 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 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 + # 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. + 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" + "scopedInertAspects" + ]; 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 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; 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 @@ -1022,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 @@ -1033,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 @@ -1042,139 +1085,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/nix/lib/aspects/fx/trace.nix b/nix/lib/aspects/fx/trace.nix index d64032f96..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; - provider = param.meta.provider 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.provider 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.provider or [ ]); + provPath = lib.concatStringsSep "/" (ownChain param); entityKind = let direct = param.__entityKind or null; @@ -268,7 +271,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..26b39b334 100644 --- a/nix/lib/aspects/types.nix +++ b/nix/lib/aspects/types.nix @@ -67,12 +67,113 @@ 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 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 + # 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; + + # 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) isStructuralKey; + + # 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 + # 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 + # A provides child's NAME lives in a different namespace than the + # 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: { + name = "${lib.concatStringsSep "." path}._"; + includes = map (k: own.${k}) childKeys; + }; + }; + in + { + inherit providesChildren forwardable functor; + syntheticProvides = providesChildren // functor; + }; + aspectType = typeCfg: 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 +205,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 +216,7 @@ let ++ [ { file = (lib.last defs).file; - value = aspectMeta loc defs; + value = aspectMeta typeCfg loc defs; } ] ); @@ -128,33 +229,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. @@ -163,17 +244,29 @@ let // { __functor = if originalFunctor != null then originalFunctor else resolveAspectWith; __providesForwarded = unshadowedProvides; - provides = syntheticProvides; - _ = syntheticProvides; + provides = underscore.syntheticProvides; + _ = underscore.syntheticProvides; }; 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.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 @@ -232,33 +325,18 @@ 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" ]; - 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; + normalizedFn = foldUnderscoreIntoProvides fn; 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 ]); + inherit (underscore) forwardable; + unshadowedProvides = builtins.filter (k: !(normalizedFn ? ${k})) (builtins.attrNames forwardable); in - result + forwardable + // normalizedFn // { - provides = syntheticProvides; - _ = syntheticProvides; + __providesForwarded = unshadowedProvides; + provides = underscore.syntheticProvides; + _ = underscore.syntheticProvides; } else let @@ -268,7 +346,7 @@ let { name = nameFromLoc; meta = { - provider = typeCfg.providerPrefix or [ ]; + aspect-chain = typeCfg.chain or typeCfg.origin; }; __fn = fn; __args = args; @@ -311,15 +389,50 @@ 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 - && (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 +442,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 +455,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,14 +463,24 @@ 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 - # identity instead of an anonymous include index. - // lib.optionalAttrs (provName != null) { - name = provName; - meta.provider = lib.init d.value.__provider; - }; + # __aspectChain so aspectSubmodule.merge produces a meaningful + # 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) 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 @@ -449,13 +572,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 @@ -525,7 +655,7 @@ let bv ) b; subForwarded = builtins.foldl' deepMerge { } subAttrVals; - provBase = (typeCfg.providerPrefix or [ ]) ++ [ + provBase = (typeCfg.chain or typeCfg.origin) ++ [ keyName k ]; @@ -534,7 +664,7 @@ let annotatedSub // { __contentValues = defsForKey; - __provider = provBase; + __aspectChain = provBase; _ = underscoreAt provBase annotatedSub; } ); @@ -544,54 +674,25 @@ 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 # 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` / `__provider` / `_` - # 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. - providesChildren = lib.filterAttrs (k: _: !(structuralKeysSet ? ${k}) && !(lib.hasPrefix "__" k)) ( - (merged.provides or { }) // writtenUnderscore - ); - unshadowedProvides = builtins.filter (k: !(merged ? ${k})) (builtins.attrNames providesChildren); - provider = (typeCfg.providerPrefix or [ ]) ++ [ 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)); - }; - }; - # Annotate nested attrset children with __provider so deeply nested + # 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. + provider = (typeCfg.chain or typeCfg.origin) ++ [ keyName ]; + # 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 # only at this wrapper. Without the recursion, navigation through a @@ -609,29 +710,41 @@ 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 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) forwardable; + unshadowedProvides = builtins.filter (k: !(annotatedMerged ? ${k})) ( + builtins.attrNames forwardable + ); in - providesChildren + forwardable // 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 # 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; @@ -650,7 +763,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"; @@ -659,11 +772,11 @@ 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: - 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. @@ -684,7 +797,7 @@ let ); default = null; }; - options.provider = lib.mkOption { + options.aspect-chain = lib.mkOption { internal = true; visible = false; description = "Provider path tracking aspect provenance"; @@ -694,25 +807,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."; @@ -732,20 +854,27 @@ let lib.types.submodule ( { name, config, ... }: let - # The chain this aspect's children hang off. `meta.provider` defaults to - # `typeCfg.providerPrefix`, but providerType.merge overrides it when it + # The chain this aspect's children hang off. `meta.aspect-chain` defaults to + # `typeCfg.chain or typeCfg.origin`, 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 ]; + # + # 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 ( aspectKeyType ( typeCfg // { - providerPrefix = childProviderPrefix; + chain = childProviderPrefix; } ) ); @@ -776,12 +905,17 @@ let }; includes = lib.mkOption { description = "Providers to ask aspects from"; - type = lib.types.listOf (providerType typeCfg); + # 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 { 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 { @@ -792,7 +926,7 @@ let providerType ( typeCfg // { - providerPrefix = childProviderPrefix; + chain = childProviderPrefix; } ) ); 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/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/nix/lib/resolve-entity.nix b/nix/lib/resolve-entity.nix index bf957632f..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 { } @@ -67,10 +81,10 @@ let inherit name; meta = { handleWith = null; - provider = [ ]; + 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/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/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; + } + ); + }; +} 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..d9f6f314a --- /dev/null +++ b/templates/ci/modules/deadbugs/d1-undeliverable-residue.nix @@ -0,0 +1,216 @@ +{ 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 ]; + } + ]; + }; + }; + + # 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 = { + # 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 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 ( + { den, igloo, ... }: + (fixturePlain den) + // { + expr = + builtins.elem 10305 igloo.networking.firewall.allowedTCPPorts + && builtins.elem 10320 igloo.networking.firewall.allowedTCPPorts; + 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/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"; + } + ); + }; +} 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..e3ccbd754 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 @@ -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; + }; } ); diff --git a/templates/ci/modules/features/deadbugs/aspect-chain-doubling.nix b/templates/ci/modules/features/deadbugs/aspect-chain-doubling.nix deleted file mode 100644 index 028a376c0..000000000 --- a/templates/ci/modules/features/deadbugs/aspect-chain-doubling.nix +++ /dev/null @@ -1,40 +0,0 @@ -# `meta.provider` 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. -{ denTest, ... }: -{ - flake.tests.deadbugs.aspect-chain-doubling = { - - test-agreeing-definitions-collapse = denTest ( - { den, ... }: - { - imports = [ - { den.aspects.igloo.provides.shared = den.aspects.a.tools; } - { den.aspects.igloo.provides.shared = den.aspects.a.tools; } - ]; - - 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 [ ]; - expected = [ "a" ]; - } - ); - - # CONTROL: a single definition was never affected, so a passing multi-def - # case above is only meaningful next to this. - test-control-single-definition = denTest ( - { den, ... }: - { - den.hosts.x86_64-linux.igloo.users.tux = { }; - 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 [ ]; - expected = [ "a" ]; - } - ); - - }; -} 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" + ]; + }; + } + ); + + }; +} 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; + }; + } + ); + }; +} 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" ]; + } + ); + + }; +} 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; + }; + } + ); + + }; +} 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"; + } + ); + + }; +} 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"; + } + ); + }; +} 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/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"; + } + ); + + }; +} 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..855da04b2 --- /dev/null +++ b/templates/ci/modules/features/deadbugs/policy-record-provenance.nix @@ -0,0 +1,251 @@ +# 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) 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, ... }: + { + 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; + }; + } + ); + + # 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 + # 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"; + } + ); + + }; +} 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..99057b90d --- /dev/null +++ b/templates/ci/modules/features/deadbugs/shared-raw-include-splits.nix @@ -0,0 +1,424 @@ +# 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"; + }; + } + ); + + # 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"; + }; + } + ); + + # 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"; + }; + } + ); + + }; +} 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; + } + ); + + }; +} 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..a653fd369 --- /dev/null +++ b/templates/ci/modules/features/deadbugs/structural-name-provides-child.nix @@ -0,0 +1,174 @@ +# `mkUnderscore` (nix/lib/aspects/types.nix) filtered a provides child's NAME +# 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. +# +# 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 = { + + # 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; + } + ); + + # 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; + }; + } + ); + }; +} 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..38229453d --- /dev/null +++ b/templates/ci/modules/features/deadbugs/underscore-provides-spelling-merge.nix @@ -0,0 +1,217 @@ +# `_` 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-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 = { + + 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; + }; + } + ); + + # 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.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.conflictRoot.hn + den.aspects.conflictNested.sub.tz + ]; + + expr = + let + tryOr = + v: + let + a = builtins.tryEval v; + in + if a.success then a.value else "ERROR"; + in + { + root = tryOr igloo.networking.hostName; + nested = tryOr igloo.time.timeZone; + }; + expected = { + root = "ERROR"; + nested = "hB"; + }; + } + ); + + # 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; + }; + } + ); + + # 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; + }; + } + ); + }; +} 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; + }; + } + ); + + }; +} 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 = [ ]; + } + ); + + }; +} 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..5f665e6c1 --- /dev/null +++ b/templates/ci/modules/features/schema-tier-excludes-string-form-rejected.nix @@ -0,0 +1,128 @@ +# `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"; + }; + } + ); + + # 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"; + }; + } + ); + }; +} 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"; + }; + } + ); + }; +} 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 = [ ]; + }; + } + ); + }; +} 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..1b13d76b3 --- /dev/null +++ b/templates/ci/modules/internal-api/aspect-chain-absence.nix @@ -0,0 +1,50 @@ +# `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; + } + ); + + # 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"; + } + ); + + }; +} diff --git a/templates/ci/modules/internal-api/aspect-chain-doubling.nix b/templates/ci/modules/internal-api/aspect-chain-doubling.nix new file mode 100644 index 000000000..58f914ba3 --- /dev/null +++ b/templates/ci/modules/internal-api/aspect-chain-doubling.nix @@ -0,0 +1,71 @@ +# `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. +{ denTest, ... }: +{ + flake.tests.aspect-chain-doubling = { + + test-agreeing-definitions-collapse = denTest ( + { den, ... }: + { + imports = [ + { den.aspects.igloo.provides.shared = den.aspects.a.tools; } + { den.aspects.igloo.provides.shared = den.aspects.a.tools; } + ]; + + 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.aspect-chain or [ ]; + expected = [ "a" ]; + } + ); + + # CONTROL: a single definition was never affected, so a passing multi-def + # case above is only meaningful next to this. + test-control-single-definition = denTest ( + { den, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + 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.aspect-chain or [ ]; + expected = [ "a" ]; + } + ); + + # 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" ]; + } + ); + + }; +} diff --git a/templates/ci/modules/internal-api/aspect-content-type.nix b/templates/ci/modules/internal-api/aspect-content-type.nix index 58a7992fa..602580b75 100644 --- a/templates/ci/modules/internal-api/aspect-content-type.nix +++ b/templates/ci/modules/internal-api/aspect-content-type.nix @@ -129,11 +129,11 @@ in } ); - # aspectContentType wraps values with __contentValues and __provider. + # aspectContentType wraps values with __contentValues and __aspectChain. 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; } @@ -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 = { @@ -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 f3ae3e9bb..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; } @@ -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; }; @@ -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; } @@ -113,7 +113,7 @@ in { expr = { hasContentValues = val ? __contentValues; - hasProvider = val ? __provider; + hasProvider = val ? __aspectChain; valueCount = builtins.length val.__contentValues; }; expected = { @@ -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; } 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/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 new file mode 100644 index 000000000..d072b1e81 --- /dev/null +++ b/templates/ci/modules/internal-api/entity-scale.nix @@ -0,0 +1,107 @@ +# 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, +# 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; + }; + } + ); + + }; +} 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-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-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; 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-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, ... }: diff --git a/templates/ci/modules/internal-api/fx-diag-capture.nix b/templates/ci/modules/internal-api/fx-diag-capture.nix index d673d20a8..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; }; @@ -53,17 +53,18 @@ fxLib = den.lib.aspects.fx; target = { name = "drop"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; }; root = { name = "root"; meta = { + aspect-chain = [ ]; handleWith = fxLib.constraints.exclude target; }; includes = [ { name = "keep"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; nixos = { a = 1; }; @@ -71,7 +72,7 @@ } { name = "drop"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; nixos = { b = 2; }; @@ -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 = [ ]; } ]; @@ -152,22 +153,23 @@ fxLib = den.lib.aspects.fx; target = { name = "drop"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; }; root = { name = "root"; meta = { + aspect-chain = [ ]; handleWith = fxLib.constraints.exclude target; }; includes = [ { name = "keep"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; includes = [ ]; } { name = "drop"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; includes = [ ]; } ]; @@ -187,7 +189,7 @@ let root = { name = "root"; - meta = { }; + meta.aspect-chain = [ ]; nixos = { a = 1; }; @@ -221,11 +223,12 @@ fxLib = den.lib.aspects.fx; target = { name = "x"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; }; root = { name = "root"; meta = { + aspect-chain = [ ]; handleWith = fxLib.constraints.exclude target; }; includes = [ ]; 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-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-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-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-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/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-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/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; - } - ); - }; -} 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..dc01fbd0d 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,48 @@ } ); + # 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.aspect-chain = [ ]; + includes = [ shared ]; + }; + ctx = { }; + }; + sharedNode = ((result.state.resolvedNodes or (_: { })) null)."shared" or null; + in + { + expr = sharedNode.meta.aspect-chain or null; + expected = [ ]; + } + ); + + # 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, ... }: @@ -296,7 +338,7 @@ let shared = { name = "shared"; - meta.provider = [ ]; + meta.aspect-chain = [ ]; nixos = { x = 1; }; 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 = [ ]; + }; + } + ); + + }; +} 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 ( diff --git a/templates/ci/modules/public-api/policy-excludes.nix b/templates/ci/modules/public-api/policy-excludes.nix index db5426a0a..00bcfce4a 100644 --- a/templates/ci/modules/public-api/policy-excludes.nix +++ b/templates/ci/modules/public-api/policy-excludes.nix @@ -63,6 +63,112 @@ } ); + # 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; + }; + } + ); + + # 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, ... }: 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" ] )) ]; };