From 2f0505ff26b5986674bd9c6248e9a949146f1cda Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 18 Sep 2026 11:48:58 -0700 Subject: [PATCH 1/3] test: pin quirk collection as entrypoint-agnostic follow-up to the report behind #681, which asked whether a quirk reached through `den.hosts...includes` is collected differently from one reached through `den.aspects..includes`. ten cells, each pairing an instance spelling with its aspect control: host scope, user scope, the flat `den.hosts.` spelling, a producer and consumer split across the two entrypoints, a pipe policy over an instance-included producer, and a flat host declaring `system` in one module and content in another. all ten pass on #681 with no further change, so this records parity rather than fixing anything. --- .../deadbugs/quirk-via-instance-includes.nix | 306 ++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 templates/ci/modules/features/deadbugs/quirk-via-instance-includes.nix diff --git a/templates/ci/modules/features/deadbugs/quirk-via-instance-includes.nix b/templates/ci/modules/features/deadbugs/quirk-via-instance-includes.nix new file mode 100644 index 00000000..daf74ff8 --- /dev/null +++ b/templates/ci/modules/features/deadbugs/quirk-via-instance-includes.nix @@ -0,0 +1,306 @@ +# Follow-up to the report behind #681: is quirk collection entrypoint-agnostic, +# or does a quirk reached through `den.hosts...includes` behave +# differently from one reached through `den.aspects..includes`? +# +# It is agnostic. Both spellings land in the same `host=` scope +# (`resolve-entity.nix` folds the instance's collection into the entity root +# aspect), so the pipe effects share a bucket. #681's merge defect was the only +# divergence and it was not quirk-specific: the instance registry dropped one +# of two list definitions whatever the list carried. +# +# Every cell pairs an instance spelling with its aspect control, so a future +# divergence shows up as one arm going red rather than as both. +{ denTest, lib, ... }: +{ + flake.tests.deadbugs.quirk-via-instance-includes = { + + # CONTROL: the aspect spelling — documented working path. + test-via-aspect-includes = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.quirks.persist.description = "Paths to persist"; + + den.aspects.libraries.producer.persist.directories = [ "/var/lib/alpha" ]; + den.aspects.libraries.consumer.nixos = + { persist, ... }: + { + environment.etc."persisted".text = lib.concatStringsSep "," ( + lib.concatMap (p: p.directories or [ ]) persist + ); + }; + + den.aspects.igloo.includes = [ + den.aspects.libraries.producer + den.aspects.libraries.consumer + ]; + + expr = igloo.environment.etc."persisted".text or ""; + expected = "/var/lib/alpha"; + } + ); + + # The reported case: same producer/consumer, included at the instance. + test-via-host-instance-includes = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.quirks.persist.description = "Paths to persist"; + + den.aspects.libraries.producer.persist.directories = [ "/var/lib/alpha" ]; + den.aspects.libraries.consumer.nixos = + { persist, ... }: + { + environment.etc."persisted".text = lib.concatStringsSep "," ( + lib.concatMap (p: p.directories or [ ]) persist + ); + }; + + den.hosts.x86_64-linux.igloo.includes = [ + den.aspects.libraries.producer + den.aspects.libraries.consumer + ]; + + expr = igloo.environment.etc."persisted".text or ""; + expected = "/var/lib/alpha"; + } + ); + + # Flat host spelling (preprocessHosts groups it by `system`). + test-via-flat-host-instance-includes = denTest ( + { den, igloo, ... }: + { + den.quirks.persist.description = "Paths to persist"; + + den.aspects.libraries.producer.persist.directories = [ "/var/lib/alpha" ]; + den.aspects.libraries.consumer.nixos = + { persist, ... }: + { + environment.etc."persisted".text = lib.concatStringsSep "," ( + lib.concatMap (p: p.directories or [ ]) persist + ); + }; + + den.hosts.igloo = { + system = "x86_64-linux"; + users.tux = { }; + includes = [ + den.aspects.libraries.producer + den.aspects.libraries.consumer + ]; + }; + + expr = igloo.environment.etc."persisted".text or ""; + expected = "/var/lib/alpha"; + } + ); + + # Producer at the instance, consumer on the host aspect. + test-instance-producer-aspect-consumer = denTest ( + { den, igloo, ... }: + { + den.quirks.persist.description = "Paths to persist"; + + den.aspects.libraries.producer.persist.directories = [ "/var/lib/alpha" ]; + + den.aspects.igloo.nixos = + { persist, ... }: + { + environment.etc."persisted".text = lib.concatStringsSep "," ( + lib.concatMap (p: p.directories or [ ]) persist + ); + }; + + den.hosts.x86_64-linux.igloo = { + users.tux = { }; + includes = [ den.aspects.libraries.producer ]; + }; + + expr = igloo.environment.etc."persisted".text or ""; + expected = "/var/lib/alpha"; + } + ); + + # Producer on the host aspect, consumer at the instance. + test-aspect-producer-instance-consumer = denTest ( + { den, igloo, ... }: + { + den.quirks.persist.description = "Paths to persist"; + + den.aspects.igloo.persist.directories = [ "/var/lib/alpha" ]; + + den.aspects.libraries.consumer.nixos = + { persist, ... }: + { + environment.etc."persisted".text = lib.concatStringsSep "," ( + lib.concatMap (p: p.directories or [ ]) persist + ); + }; + + den.hosts.x86_64-linux.igloo = { + users.tux = { }; + includes = [ den.aspects.libraries.consumer ]; + }; + + expr = igloo.environment.etc."persisted".text or ""; + expected = "/var/lib/alpha"; + } + ); + + # Instance-included producer feeding a pipe policy. + test-instance-producer-pipe-policy = denTest ( + { den, igloo, ... }: + { + den.quirks.firewall.description = "Firewall ports"; + + den.aspects.libraries.producer.firewall = [ + { + port = 80; + proto = "tcp"; + } + { + port = 53; + proto = "udp"; + } + ]; + + den.aspects.libraries.consumer.nixos = + { firewall, ... }: + { + networking.hostName = lib.concatMapStringsSep "-" (f: toString f.port) firewall; + }; + + den.policies.filter-tcp = + { host, ... }: + [ (den.lib.policy.pipe.from "firewall" [ (den.lib.policy.pipe.filter (e: e.proto == "tcp")) ]) ]; + + den.default.includes = [ den.policies.filter-tcp ]; + + den.hosts.x86_64-linux.igloo = { + users.tux = { }; + includes = [ + den.aspects.libraries.producer + den.aspects.libraries.consumer + ]; + }; + + expr = igloo.networking.hostName; + expected = "80"; + } + ); + + # User instance includes carrying the producer for that user's own consumer. + test-user-instance-includes = denTest ( + { den, tuxHm, ... }: + { + den.default.homeManager.home.stateVersion = "25.11"; + den.quirks.hmvals.description = "hm values"; + + den.aspects.libraries.producer.hmvals = [ "u" ]; + den.aspects.libraries.consumer.homeManager = + { + hmvals ? [ ], + ... + }: + { + home.sessionVariables.MARKER = lib.concatStringsSep "-" hmvals; + }; + + den.hosts.x86_64-linux.igloo.users.tux.includes = [ + den.aspects.libraries.producer + den.aspects.libraries.consumer + ]; + + expr = tuxHm.home.sessionVariables.MARKER or ""; + expected = "u"; + } + ); + + # The #681 merge, carrying a quirk rather than class content: both + # definitions must survive for the producer to reach the consumer. + test-instance-includes-two-definitions = denTest ( + { den, igloo, ... }: + { + den.quirks.persist.description = "Paths to persist"; + + den.aspects.libraries.producer.persist.directories = [ "/var/lib/alpha" ]; + den.aspects.libraries.consumer.nixos = + { persist, ... }: + { + environment.etc."persisted".text = lib.concatStringsSep "," ( + lib.concatMap (p: p.directories or [ ]) persist + ); + }; + + imports = [ + { den.hosts.x86_64-linux.igloo.users.tux = { }; } + { den.hosts.x86_64-linux.igloo.includes = [ den.aspects.libraries.producer ]; } + { den.hosts.x86_64-linux.igloo.includes = [ den.aspects.libraries.consumer ]; } + ]; + + expr = igloo.environment.etc."persisted".text or ""; + expected = "/var/lib/alpha"; + } + ); + + # CONTROL: the same two-module split on the host ASPECT. + test-aspect-includes-two-definitions = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.quirks.persist.description = "Paths to persist"; + + den.aspects.libraries.producer.persist.directories = [ "/var/lib/alpha" ]; + den.aspects.libraries.consumer.nixos = + { persist, ... }: + { + environment.etc."persisted".text = lib.concatStringsSep "," ( + lib.concatMap (p: p.directories or [ ]) persist + ); + }; + + imports = [ + { den.aspects.igloo.includes = [ den.aspects.libraries.producer ]; } + { den.aspects.igloo.includes = [ den.aspects.libraries.consumer ]; } + ]; + + expr = igloo.environment.etc."persisted".text or ""; + expected = "/var/lib/alpha"; + } + ); + + # A flat host may declare `system` in one module and content in another: + # definitions are normalized individually, so the grouping key is gathered + # across all of them first. + test-flat-host-system-and-content-split = denTest ( + { den, igloo, ... }: + { + den.quirks.persist.description = "Paths to persist"; + + den.aspects.libraries.producer.persist.directories = [ "/var/lib/alpha" ]; + den.aspects.libraries.consumer.nixos = + { persist, ... }: + { + environment.etc."persisted".text = lib.concatStringsSep "," ( + lib.concatMap (p: p.directories or [ ]) persist + ); + }; + + imports = [ + { + den.hosts.igloo = { + system = "x86_64-linux"; + users.tux = { }; + }; + } + { den.hosts.igloo.includes = [ den.aspects.libraries.producer ]; } + { den.hosts.igloo.includes = [ den.aspects.libraries.consumer ]; } + ]; + + expr = igloo.environment.etc."persisted".text or ""; + expected = "/var/lib/alpha"; + } + ); + + }; +} From 6ecefaed05af89f1e28dfc29ce2cf21317a703d2 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 18 Sep 2026 12:06:44 -0700 Subject: [PATCH 2/3] test: cover the reported darwin and flat-host quirk spellings theutz posted two screenshots on #682 showing `den.hosts.kocaeli.includes` yielding an empty `homebrew.taps` where `den.aspects.kocaeli.includes` yields the tap, a two-byte diff between the two runs. that report is darwin and uses the flat `den.hosts.` spelling with the host declared in one module and the collection added from another. every existing cell is nixos, x86_64-linux and two-level, so none of it was covered. three cells mirroring the reported shape, all green at 20d1e76, so the divergence does not reproduce on this tree. --- .../quirk-instance-includes-darwin.nix | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 templates/ci/modules/features/deadbugs/quirk-instance-includes-darwin.nix diff --git a/templates/ci/modules/features/deadbugs/quirk-instance-includes-darwin.nix b/templates/ci/modules/features/deadbugs/quirk-instance-includes-darwin.nix new file mode 100644 index 00000000..3fc21ec4 --- /dev/null +++ b/templates/ci/modules/features/deadbugs/quirk-instance-includes-darwin.nix @@ -0,0 +1,93 @@ +# theutz on #682: a quirk producer reached through `den.hosts..includes` +# yields an empty collection at the consumer, while the same producer reached +# through `den.aspects..includes` yields its value. Two screenshots, a +# two-byte diff (`hosts` -> `aspects`), same eval: +# +# den.hosts.kocaeli.includes = [ rootshell ]; => homebrew.taps == [ ] +# den.aspects.kocaeli.includes = [ rootshell ]; => homebrew.taps == [ {...} ] +# +# Distinct from #681: ONE definition of the collection, so nothing is being +# dropped by the registry merge. The reported config is darwin and uses the +# flat `den.hosts.` spelling, neither of which the linux cells cover. +{ denTest, lib, ... }: +{ + flake.tests.deadbugs.quirk-instance-includes-darwin = { + + # CONTROL: the aspect spelling from the second screenshot. + test-darwin-aspect-includes = denTest ( + { den, apple, ... }: + { + den.hosts.aarch64-darwin.apple = { }; + den.quirks.taps.description = "homebrew taps"; + + den.aspects.rootshell.taps = [ "kitknox/rootshell" ]; + den.aspects.apple = { + includes = [ den.aspects.rootshell ]; + darwin = + { + taps ? [ ], + ... + }: + { + environment.etc."taps".text = lib.concatStringsSep "," (lib.flatten taps); + }; + }; + + expr = apple.environment.etc."taps".text or ""; + expected = "kitknox/rootshell"; + } + ); + + # The reported case, two-level spelling. + test-darwin-instance-includes = denTest ( + { den, apple, ... }: + { + den.quirks.taps.description = "homebrew taps"; + + den.aspects.rootshell.taps = [ "kitknox/rootshell" ]; + den.aspects.apple.darwin = + { + taps ? [ ], + ... + }: + { + environment.etc."taps".text = lib.concatStringsSep "," (lib.flatten taps); + }; + + den.hosts.aarch64-darwin.apple.includes = [ den.aspects.rootshell ]; + + expr = apple.environment.etc."taps".text or ""; + expected = "kitknox/rootshell"; + } + ); + + # The reported case verbatim: flat spelling, and the host declared in one + # module with the collection added from another, as in the screenshots + # (`kocaeli.nix` declares the host, `rootshell.nix` adds the include). + test-darwin-flat-instance-includes-split = denTest ( + { den, apple, ... }: + { + den.quirks.taps.description = "homebrew taps"; + + den.aspects.rootshell.taps = [ "kitknox/rootshell" ]; + den.aspects.apple.darwin = + { + taps ? [ ], + ... + }: + { + environment.etc."taps".text = lib.concatStringsSep "," (lib.flatten taps); + }; + + imports = [ + { den.hosts.apple.system = "aarch64-darwin"; } + { den.hosts.apple.includes = [ den.aspects.rootshell ]; } + ]; + + expr = apple.environment.etc."taps".text or ""; + expected = "kitknox/rootshell"; + } + ); + + }; +} From a4e219cd46fe70964ab713b2567bdeb07a2c0a46 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Fri, 18 Sep 2026 13:28:08 -0700 Subject: [PATCH 3/3] fix: keep unnameable context values out of scope identity `mkScopeId` built the scope identity from every ctx key, including ones whose value it cannot name. Those fell through to the type placeholder branch and rendered as `den=`, `inputs=`, `lib=`. a value that can only render as its own type distinguishes nothing, so it adds no identity. all it does is split one logical scope across two ids depending on which module args happened to be bound on the path. pipe emits are bucketed by scope id and a consumer reads its own bucket, so a producer and a consumer that belong to the same logical scope landed in different buckets and the collection came back empty. reported by theutz on #682: a quirk reached through `den.hosts..includes` arrived empty while the same aspect through `den.aspects..includes` delivered. traced on his host, where the user scope pushed ctx keys `den|host|inputs|lib|system|user` and the producer emitted at `host=kocaeli,system=aarch64-darwin` while the surviving consumer bound at the polluted id. the keys stay in the context, where binding still reads them. only the identity is narrowed. --- nix/lib/aspects/fx/pipeline.nix | 45 +++++++------ .../deadbugs/scope-id-unnameable-ctx.nix | 64 +++++++++++++++++++ 2 files changed, 91 insertions(+), 18 deletions(-) create mode 100644 templates/ci/modules/features/deadbugs/scope-id-unnameable-ctx.nix diff --git a/nix/lib/aspects/fx/pipeline.nix b/nix/lib/aspects/fx/pipeline.nix index c5830a62..7923783e 100644 --- a/nix/lib/aspects/fx/pipeline.nix +++ b/nix/lib/aspects/fx/pipeline.nix @@ -119,28 +119,37 @@ let # It defaults to `name`, so only kinds that rewrite `name` differ here. # Synthetic context values (e.g. a bare `{ name = ...; }` host) carry no # `__scopeName` and fall back to `name`. + # A ctx value that cannot be NAMED carries no identity: it can only render as + # its own type (`den=`), which distinguishes nothing and splits one + # logical scope across two ids depending on which module args happened to be + # bound on the path. Such keys are dropped from the IDENTITY; they stay in the + # context itself, where binding still reads them. + nameScopeValue = + k: v: + if builtins.isAttrs v && v ? __scopeName then + v.__scopeName + else if builtins.isAttrs v && v ? name then + v.name + else if builtins.isString v then + v + else if builtins.isInt v || builtins.isFloat v then + toString v + else + null; + mkScopeId = ctx: lib.concatStringsSep "," ( lib.sort (a: b: a < b) ( - map ( - k: - let - v = ctx.${k}; - in - "${k}=${ - if builtins.isAttrs v && v ? __scopeName then - v.__scopeName - else if builtins.isAttrs v && v ? name then - v.name - else if builtins.isString v then - v - else if builtins.isInt v || builtins.isFloat v then - toString v - else - "<${builtins.typeOf v}:${k}>" - }" - ) (builtins.attrNames ctx) + builtins.filter (s: s != null) ( + map ( + k: + let + named = nameScopeValue k ctx.${k}; + in + if named == null then null else "${k}=${named}" + ) (builtins.attrNames ctx) + ) ) ); diff --git a/templates/ci/modules/features/deadbugs/scope-id-unnameable-ctx.nix b/templates/ci/modules/features/deadbugs/scope-id-unnameable-ctx.nix new file mode 100644 index 00000000..4de5854e --- /dev/null +++ b/templates/ci/modules/features/deadbugs/scope-id-unnameable-ctx.nix @@ -0,0 +1,64 @@ +# theutz on #682: a quirk reached through `den.hosts..includes` arrived +# empty while the same aspect through `den.aspects..includes` delivered. +# +# Cause: `mkScopeId` built the scope IDENTITY from every ctx key, including ones +# whose value it cannot name. Those render as a bare type placeholder +# (`den=`, `inputs=`, `lib=`) — measured on his +# host, where the user scope pushed ctx keys `den|host|inputs|lib|system|user`. +# +# A value that can only render as its own type distinguishes nothing, so it adds +# no identity; all it does is split one logical scope across two ids depending on +# which module args happened to be bound on the path. Pipe emits are bucketed by +# scope id and a consumer reads its own bucket, so producer and consumer landed +# in different buckets and the collection came back empty. +{ denTest, ... }: +{ + flake.tests.deadbugs.scope-id-unnameable-ctx = { + + # An unnameable ctx value must not enter the scope identity. + test-unnameable-ctx-excluded-from-scope-id = denTest ( + { den, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + expr = den.lib.aspects.fx.pipeline.mkScopeId { + host = { + name = "igloo"; + }; + user = { + name = "tux"; + }; + lib = { + genAttrs = "not a name"; + }; + inputs = { + nixpkgs = "not a name"; + }; + }; + expected = "host=igloo,user=tux"; + } + ); + + # CONTROL: nameable coordinates all survive, so the cell above measures the + # exclusion rather than a scope id that dropped everything. + test-nameable-ctx-kept-in-scope-id = denTest ( + { den, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + + expr = den.lib.aspects.fx.pipeline.mkScopeId { + host = { + name = "igloo"; + }; + user = { + __scopeName = "tux@igloo"; + name = "tux"; + }; + system = "x86_64-linux"; + }; + expected = "host=igloo,system=x86_64-linux,user=tux@igloo"; + } + ); + + }; +}