From d7691764bc65668e0d9075b2446ae6528151828e Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 15 Sep 2026 13:02:10 -0700 Subject: [PATCH 1/6] feat: resolve gen-schema through the gen hub, at the current contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves den from gen-schema 4bd0f6e to HEAD 8cf90f5, which is 112 commits and a module-system swap away (gen-merge replaces lib.evalModules + lib.types). Two things had to change, and neither is a gen defect. IDENTITY KEYS ARE DECLARED AT THE KIND. gen-schema closes the identity-key set the moment the kind is a value, so an option contributed through `mkInstanceType`'s `extraModules` cannot be one, and the reflected set drops `internal` options outright. den named `__scopeName` and `system` in `_identity.keys` for the home kind and declared both on the instance, which used to succeed silently and is now a loud, named refusal. Both are declared on the kind instead; the per-system value stays where it is computed, since identity reflects declarations rather than config. RESOLUTION GOES THROUGH THE HUB. gen-schema's root declares `{ prelude, merge, algebra, identity }` with no `...`, so den's old fallback — `import gen-schema { inherit lib; }` — is a hard error at every consumer that does not declare the input, which is eleven of den's twelve templates. The hub's root takes the vestigial `{ }` and hands back a resolved roster, so the fallback has something total to call. It also fixes coherence: pinning gen-schema directly left its transitive closure to den and produced three revisions of gen-prelude in one lock, which reads as one value while being three builds. Through the hub it is one. Validation: `just ci` 1201/1201, exit 0, zero failures and zero errors, with the summary read off stderr and the collected count agreeing with the numerator. `nix flake check` on `minimal` and `default` — both declare no gen input, so both exercise the fetched fallback rather than the flake input. --- modules/options.nix | 26 +- nix/lib/schema.nix | 36 ++- templates/ci/flake.lock | 565 +++++++++++++++++++++++++++++++++++++++- templates/ci/flake.nix | 8 +- 4 files changed, 616 insertions(+), 19 deletions(-) diff --git a/modules/options.nix b/modules/options.nix index 88a3abcbf..b0de06aad 100644 --- a/modules/options.nix +++ b/modules/options.nix @@ -180,7 +180,31 @@ in config.den.schema.fleet = { }; config.den.schema.host.imports = [ den.schema.conf ]; config.den.schema.user.imports = [ den.schema.conf ]; - config.den.schema.home.imports = [ den.schema.conf ]; + # `home`'s identity keys are declared AT THE KIND, not on the instance. + # gen-schema closes the identity-key set the moment the kind is a value, so + # an option contributed through `mkInstanceType`'s `extraModules` cannot be + # an identity key, and the reflected set drops `internal` options outright. + # Naming either from `_identity.keys` is a hard error rather than the silent + # success it used to be. + # + # home is the only kind that needs its own keys: `name` is forced to the bare + # user name so `den.aspects.` resolves, and two `user@host` homes on one + # system share that name. The registry key and the system are what actually + # tell them apart. Declared here, defined on the instance — identity is read + # off declarations, so the per-system value stays where it is computed. + config.den.schema.home.imports = [ + den.schema.conf + { + options.__scopeName = lib.mkOption { + type = lib.types.str; + description = "Registry key of this home, used as its scope and identity."; + }; + options.system = lib.mkOption { + type = lib.types.str; + description = "platform system"; + }; + } + ]; config.den.classes = { nixos.description = "NixOS system configuration"; darwin.description = "nix-darwin system configuration"; diff --git a/nix/lib/schema.nix b/nix/lib/schema.nix index 862ce1edf..60f6f2184 100644 --- a/nix/lib/schema.nix +++ b/nix/lib/schema.nix @@ -1,13 +1,35 @@ -# Resolve gen-schema the same way fx.nix resolves nix-effects: prefer the -# consumer-provided flake input, fall back to the rev pinned in the CI lock so -# den evaluates without forcing every consumer to declare the input. -{ inputs, lib, ... }: +# Resolve gen-schema through the gen HUB, preferring a consumer-provided flake +# input and falling back to the rev pinned in the CI lock, so den evaluates +# without forcing every consumer to declare the input. +# +# THE HUB, NOT gen-schema DIRECTLY, and neither half of that is a preference. +# +# Coherence: the hub binds every sibling's `gen-*` input with `follows`, so one +# input yields one revision per library. Pinning gen-schema directly leaves den +# holding its transitive closure — measured at the bump that motivated this +# file: three revisions of gen-prelude and two of gen-schema in one lock, which +# reads as one value while being several builds. +# +# The fallback: gen-schema's own root declares `{ prelude, merge, algebra, +# identity }` and states "There is NO `...`: an argument this root does not +# declare is a loud error, not a silent drop". So the old +# `import gen-schema { inherit lib; }` below is a loud refusal at every +# consumer that does not declare the input — which is all but one of den's +# templates. The hub's root takes the vestigial `{ }` and hands back the +# resolved roster, so the fallback has something total to call. +{ inputs, ... }: let lock = builtins.fromJSON (builtins.readFile ../../templates/ci/flake.lock); - locked = lock.nodes.gen-schema.locked; - gen-schema = builtins.fetchTarball { + locked = lock.nodes.gen.locked; + genSrc = builtins.fetchTarball { url = "https://github.com/${locked.owner}/${locked.repo}/archive/${locked.rev}.zip"; sha256 = locked.narHash; }; + # Two entry shapes for one roster: the flake publishes `lib.mkGenLibs` (a + # function of a vestigial argument), the standalone root yields the roster + # directly. Dispatch on which channel supplied it rather than probing the + # value, so a member that changes shape is loud here instead of silently + # taking the other arm. + roster = if inputs ? gen then inputs.gen.lib.mkGenLibs { } else import genSrc { }; in -inputs.gen-schema.lib or (import gen-schema { inherit lib; }) +roster.schema diff --git a/templates/ci/flake.lock b/templates/ci/flake.lock index 60f193bf0..ed4cfbdad 100644 --- a/templates/ci/flake.lock +++ b/templates/ci/flake.lock @@ -35,18 +35,400 @@ "type": "github" } }, - "gen-schema": { + "gen": { "inputs": { + "gen-algebra": "gen-algebra", + "gen-aspects": "gen-aspects", + "gen-assemble": "gen-assemble", + "gen-bind": "gen-bind", + "gen-class": "gen-class", + "gen-delivery": "gen-delivery", + "gen-dispatch": "gen-dispatch", + "gen-graph": "gen-graph", + "gen-identity": "gen-identity", + "gen-link": "gen-link", + "gen-memo": "gen-memo", + "gen-merge": "gen-merge", + "gen-prelude": "gen-prelude", + "gen-product": "gen-product", + "gen-program": "gen-program", + "gen-schema": "gen-schema", + "gen-scope": "gen-scope", + "gen-select": "gen-select", + "gen-settings": "gen-settings", + "gen-types": "gen-types", + "gen-view": "gen-view", + "import-tree": "import-tree", "nixpkgs": [ "nixpkgs" ] }, "locked": { - "lastModified": 1779986641, - "narHash": "sha256-KcZuS+hpaloICFcepNXNLpbehh6XoPjWPBteYpTqMRw=", + "lastModified": 1789496047, + "narHash": "sha256-xTCl7YcNJHSXKDfOWs1ooNRsRES87Nhy0zHPfpG5ca8=", + "owner": "sini", + "repo": "gen", + "rev": "f6241d6b9eee8a25e6c596a244e8278cce8972b5", + "type": "github" + }, + "original": { + "owner": "sini", + "repo": "gen", + "type": "github" + } + }, + "gen-algebra": { + "locked": { + "lastModified": 1789490033, + "narHash": "sha256-ZQob24b/1rmtqWSH0yEfiS53zeP3KSGI2TCfIwsCVTU=", + "owner": "sini", + "repo": "gen-algebra", + "rev": "fdd2bc87699da126053e5c375b848acdffbc527c", + "type": "github" + }, + "original": { + "owner": "sini", + "repo": "gen-algebra", + "type": "github" + } + }, + "gen-aspects": { + "inputs": { + "gen-identity": [ + "gen", + "gen-identity" + ], + "gen-merge": [ + "gen", + "gen-merge" + ], + "gen-prelude": [ + "gen", + "gen-prelude" + ], + "gen-schema": [ + "gen", + "gen-schema" + ] + }, + "locked": { + "lastModified": 1789489469, + "narHash": "sha256-vnmLKXRvRxuaXW6V//lJc6kRDZbt67JUPgPs9/quZVE=", + "owner": "sini", + "repo": "gen-aspects", + "rev": "f0d9d14c356210dfe1d20f918785a317bb82a549", + "type": "github" + }, + "original": { + "owner": "sini", + "repo": "gen-aspects", + "type": "github" + } + }, + "gen-assemble": { + "locked": { + "lastModified": 1789464172, + "narHash": "sha256-qQdxjCO798R19Fr4L1sb/TncjwKaHc2tHCwaO5px2iY=", + "owner": "sini", + "repo": "gen-assemble", + "rev": "ef321d26ee57837bbf9e3ba15a6f5a093a394035", + "type": "github" + }, + "original": { + "owner": "sini", + "repo": "gen-assemble", + "type": "github" + } + }, + "gen-bind": { + "inputs": { + "gen-prelude": [ + "gen", + "gen-prelude" + ] + }, + "locked": { + "lastModified": 1789489422, + "narHash": "sha256-7iFjxTPsCiNYZbyD/ZuIvNp0Kk9m1dKKrazSVaoepfY=", + "owner": "sini", + "repo": "gen-bind", + "rev": "15262e1eb3ee4cdd6b7ef075ef2ceedd0b74ca09", + "type": "github" + }, + "original": { + "owner": "sini", + "repo": "gen-bind", + "type": "github" + } + }, + "gen-class": { + "inputs": { + "gen-prelude": [ + "gen", + "gen-prelude" + ] + }, + "locked": { + "lastModified": 1789486550, + "narHash": "sha256-3q79lalCqEIuNTWOAiD0Q9/1oe6ctmkYhd46LXwyqNQ=", + "owner": "sini", + "repo": "gen-class", + "rev": "b0bf7eb33bc56e242e112e1d6164abfd65de19dc", + "type": "github" + }, + "original": { + "owner": "sini", + "repo": "gen-class", + "type": "github" + } + }, + "gen-delivery": { + "locked": { + "lastModified": 1789464171, + "narHash": "sha256-DMx26oPRXKpSpvv+MNS56vEzBY30asS/yXfpoqNMhaQ=", + "owner": "sini", + "repo": "gen-delivery", + "rev": "4618039bcb46e57bec36d38c7f33b154faf94ae1", + "type": "github" + }, + "original": { + "owner": "sini", + "repo": "gen-delivery", + "type": "github" + } + }, + "gen-dispatch": { + "inputs": { + "gen-prelude": [ + "gen", + "gen-prelude" + ] + }, + "locked": { + "lastModified": 1789489525, + "narHash": "sha256-LqU4M+d6sJmPR91A5zGDLY9z+zDWIWZK/gsUhuQlj7A=", + "owner": "sini", + "repo": "gen-dispatch", + "rev": "f9e0a25742ec00a257eb2b0068aa2029ddc3ba51", + "type": "github" + }, + "original": { + "owner": "sini", + "repo": "gen-dispatch", + "type": "github" + } + }, + "gen-graph": { + "inputs": { + "gen-prelude": [ + "gen", + "gen-prelude" + ] + }, + "locked": { + "lastModified": 1789489741, + "narHash": "sha256-7u3cxVKTc5JIs3e45KwqaVxZKfpH1xsTa+7uFinwuHw=", + "owner": "sini", + "repo": "gen-graph", + "rev": "019e34f410fc49af254ceced7e01ade74f29a6e7", + "type": "github" + }, + "original": { + "owner": "sini", + "repo": "gen-graph", + "type": "github" + } + }, + "gen-identity": { + "locked": { + "lastModified": 1789489877, + "narHash": "sha256-Gf2ZkdgAb7zYH38tgi/XMuzmt5Ais1xcL2RTlf63PK0=", + "owner": "sini", + "repo": "gen-identity", + "rev": "0865432c5f0faf8fb1df1381e814d87c3f77161d", + "type": "github" + }, + "original": { + "owner": "sini", + "repo": "gen-identity", + "type": "github" + } + }, + "gen-link": { + "inputs": { + "gen-algebra": [ + "gen", + "gen-algebra" + ], + "gen-aspects": [ + "gen", + "gen-aspects" + ], + "gen-identity": [ + "gen", + "gen-identity" + ], + "gen-prelude": [ + "gen", + "gen-prelude" + ], + "gen-schema": [ + "gen", + "gen-schema" + ], + "gen-scope": [ + "gen", + "gen-scope" + ], + "gen-view": [ + "gen", + "gen-view" + ] + }, + "locked": { + "lastModified": 1789489617, + "narHash": "sha256-WDzF6yJNKJQvY2OXCxN6WYpy6s64EqFQnioMAkKZsrQ=", + "owner": "sini", + "repo": "gen-link", + "rev": "365937e4ba8d896980a0afa521f60746fedcb742", + "type": "github" + }, + "original": { + "owner": "sini", + "repo": "gen-link", + "type": "github" + } + }, + "gen-memo": { + "inputs": { + "gen-graph": [ + "gen", + "gen-graph" + ], + "gen-prelude": [ + "gen", + "gen-prelude" + ] + }, + "locked": { + "lastModified": 1789490439, + "narHash": "sha256-HgHfWjszBBQ2mQldMLOrBGBavYakmbjty70Kj613Ky4=", + "owner": "sini", + "repo": "gen-memo", + "rev": "83c07266eb0cbf31ea9f0c38eef5f7df49381d07", + "type": "github" + }, + "original": { + "owner": "sini", + "repo": "gen-memo", + "type": "github" + } + }, + "gen-merge": { + "inputs": { + "gen-memo": [ + "gen", + "gen-memo" + ], + "gen-prelude": [ + "gen", + "gen-prelude" + ], + "gen-types": [ + "gen", + "gen-types" + ] + }, + "locked": { + "lastModified": 1789483962, + "narHash": "sha256-2b3ZqXhyNIUQpUh4cwbL0THMJvnedTgwC0oHtL4LR6o=", + "owner": "sini", + "repo": "gen-merge", + "rev": "4232991ab1fbb2f223e029341e0dfa95a62ee54d", + "type": "github" + }, + "original": { + "owner": "sini", + "repo": "gen-merge", + "type": "github" + } + }, + "gen-prelude": { + "locked": { + "lastModified": 1789489886, + "narHash": "sha256-0tvv/pOGzxSnfcICG8UKHq4MdHDpl18MCNVYnDwKJtY=", + "owner": "sini", + "repo": "gen-prelude", + "rev": "f7b0957001fa03a652419614da780998ba233f7f", + "type": "github" + }, + "original": { + "owner": "sini", + "repo": "gen-prelude", + "type": "github" + } + }, + "gen-product": { + "inputs": { + "gen-prelude": [ + "gen", + "gen-prelude" + ] + }, + "locked": { + "lastModified": 1789483968, + "narHash": "sha256-z/WZZuM5D//Y9RDa+A4SQJdrezai2Cn9osk3jJEfwXo=", + "owner": "sini", + "repo": "gen-product", + "rev": "d59fd973262f75fc5c0a7545fa1ec48b4c16acd1", + "type": "github" + }, + "original": { + "owner": "sini", + "repo": "gen-product", + "type": "github" + } + }, + "gen-program": { + "locked": { + "lastModified": 1789464163, + "narHash": "sha256-A8hBuJjiddWswzA5EGGPS2IXYQuCo81SZgKmr4mvtQ0=", + "owner": "sini", + "repo": "gen-program", + "rev": "3e054ab7edd1e9c80a7761b915de13f2a213ad75", + "type": "github" + }, + "original": { + "owner": "sini", + "repo": "gen-program", + "type": "github" + } + }, + "gen-schema": { + "inputs": { + "gen-algebra": [ + "gen", + "gen-algebra" + ], + "gen-identity": [ + "gen", + "gen-identity" + ], + "gen-merge": [ + "gen", + "gen-merge" + ], + "gen-prelude": [ + "gen", + "gen-prelude" + ] + }, + "locked": { + "lastModified": 1789491220, + "narHash": "sha256-gnjEUdleL0MDVL5MXFlcqXjuQKR2Wv6HqghjE6L18fM=", "owner": "sini", "repo": "gen-schema", - "rev": "4bd0f6eb1799bf3c38eb3707419157b1f70eb1f5", + "rev": "8cf90f5ce2a8435820cfb24f72a195bc3495ff39", "type": "github" }, "original": { @@ -76,6 +458,155 @@ "type": "github" } }, + "gen-scope": { + "inputs": { + "gen-graph": [ + "gen", + "gen-graph" + ], + "gen-identity": [ + "gen", + "gen-identity" + ], + "gen-prelude": [ + "gen", + "gen-prelude" + ], + "gen-schema": [ + "gen", + "gen-schema" + ] + }, + "locked": { + "lastModified": 1789491559, + "narHash": "sha256-7NyGoDre+w2A2k448WRkfsdnJUtExx0DzprMEkVUVzU=", + "owner": "sini", + "repo": "gen-scope", + "rev": "d24e0d983f55312b1ddada68e5a4737ec91021bb", + "type": "github" + }, + "original": { + "owner": "sini", + "repo": "gen-scope", + "type": "github" + } + }, + "gen-select": { + "inputs": { + "gen-algebra": [ + "gen", + "gen-algebra" + ] + }, + "locked": { + "lastModified": 1789490250, + "narHash": "sha256-G3OWSbw2rBcgQWG4h6HchZxtHcKvDY+NZk50bUEE75s=", + "owner": "sini", + "repo": "gen-select", + "rev": "093b3d1600717c43cc00ad9d3a933e0124035e19", + "type": "github" + }, + "original": { + "owner": "sini", + "repo": "gen-select", + "type": "github" + } + }, + "gen-settings": { + "inputs": { + "gen-algebra": [ + "gen", + "gen-algebra" + ], + "gen-bind": [ + "gen", + "gen-bind" + ], + "gen-graph": [ + "gen", + "gen-graph" + ], + "gen-identity": [ + "gen", + "gen-identity" + ], + "gen-prelude": [ + "gen", + "gen-prelude" + ], + "gen-schema": [ + "gen", + "gen-schema" + ], + "gen-types": [ + "gen", + "gen-types" + ] + }, + "locked": { + "lastModified": 1789474167, + "narHash": "sha256-ZReLcZwpLgGwMA85Z6xFGFY/JNKm4wIjcu1YcmdCKDk=", + "owner": "sini", + "repo": "gen-settings", + "rev": "c6c63e150edb074b4cc85d4f180aa6a51ff0c4ea", + "type": "github" + }, + "original": { + "owner": "sini", + "repo": "gen-settings", + "type": "github" + } + }, + "gen-types": { + "inputs": { + "gen-identity": [ + "gen", + "gen-identity" + ], + "gen-prelude": [ + "gen", + "gen-prelude" + ] + }, + "locked": { + "lastModified": 1789474172, + "narHash": "sha256-OtjUKph/V1LpMmm5gq5WeYwjTY8xa0q7qKqhyciMP3E=", + "owner": "sini", + "repo": "gen-types", + "rev": "3636db0d56c3fd02fc215eb357b919edf44bb794", + "type": "github" + }, + "original": { + "owner": "sini", + "repo": "gen-types", + "type": "github" + } + }, + "gen-view": { + "inputs": { + "gen-graph": [ + "gen", + "gen-graph" + ], + "gen-prelude": [ + "gen", + "gen-prelude" + ] + }, + "locked": { + "lastModified": 1789472671, + "narHash": "sha256-CpB55naQXApJ2hMdX2kGIfEI7AEFFmIQz3pztzfnzpc=", + "owner": "sini", + "repo": "gen-view", + "rev": "2656d3cc383ceb00a812b579dfed37bbd7a04c33", + "type": "github" + }, + "original": { + "owner": "sini", + "repo": "gen-view", + "type": "github" + } + }, "home-manager": { "inputs": { "nixpkgs": [ @@ -97,6 +628,22 @@ } }, "import-tree": { + "locked": { + "lastModified": 1783029876, + "narHash": "sha256-iI88R3wHz8wTKQb5orvpc51L/Xr64AJyxid/0MKa/b8=", + "owner": "denful", + "repo": "import-tree", + "rev": "a164a12202f58eb67559bd33b5592f20660d9baf", + "type": "github" + }, + "original": { + "owner": "denful", + "repo": "import-tree", + "rev": "a164a12202f58eb67559bd33b5592f20660d9baf", + "type": "github" + } + }, + "import-tree_2": { "locked": { "lastModified": 1778781969, "narHash": "sha256-Jjuz5CmSkur8KvLDoGa+vylEp+RkQtv4mt/qcMznpH0=", @@ -130,6 +677,7 @@ }, "original": { "owner": "denful", + "ref": "den", "repo": "nix-effects", "type": "github" } @@ -204,23 +752,22 @@ ] }, "locked": { - "lastModified": 1, - "narHash": "sha256-Hqizev6Ij0Q4O7Xpm6TDhblKxoY2et2BxRc67Gklff4=", "path": "./provider", "type": "path" }, "original": { "path": "./provider", "type": "path" - } + }, + "parent": [] }, "root": { "inputs": { "darwin": "darwin", "den": "den", - "gen-schema": "gen-schema", + "gen": "gen", "home-manager": "home-manager", - "import-tree": "import-tree", + "import-tree": "import-tree_2", "nix-effects": "nix-effects", "nix-unit": "nix-unit", "nixpkgs": "nixpkgs", diff --git a/templates/ci/flake.nix b/templates/ci/flake.nix index 608d35dff..107af3fcd 100644 --- a/templates/ci/flake.nix +++ b/templates/ci/flake.nix @@ -30,7 +30,11 @@ nix-effects.inputs.nixpkgs.follows = "nixpkgs"; nix-effects.inputs.nix-unit.follows = "nix-unit"; - gen-schema.url = "github:sini/gen-schema"; - gen-schema.inputs.nixpkgs.follows = "nixpkgs"; + # The gen HUB, not gen-schema directly: the hub binds every sibling's + # `gen-*` input with `follows`, so one input yields one revision per + # library. Pinning gen-schema here left its transitive closure to den and + # produced three revisions of gen-prelude in this lock. + gen.url = "github:sini/gen"; + gen.inputs.nixpkgs.follows = "nixpkgs"; }; } From c9748f5ff4ea51528ddf00f2e809436559db6dfb Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 15 Sep 2026 13:37:38 -0700 Subject: [PATCH 2/6] fix: hide the home kind's identity options without un-declaring them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kind-level identity declarations were left on the documented option surface on the belief that gen-schema forced it. It does not: `isPrimitiveOption` reads `internal` and `identity`, and NOT `visible`, so `visible = false` keeps both keys out of rendered option docs while leaving them identity-eligible. `internal = true` is still unavailable, and that part is a real constraint rather than a preference — an internal option is dropped from the reflected identity set outright, so marking these internal would un-declare the very keys `_identity.keys` names. Recorded as resting on `visible` not being read, because that is an implementation fact and not a documented contract: if gen-schema ever folds `visible` into the same presentation exclusion, these keys silently leave the identity set. --- modules/options.nix | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/modules/options.nix b/modules/options.nix index b0de06aad..f6ae2238b 100644 --- a/modules/options.nix +++ b/modules/options.nix @@ -195,12 +195,20 @@ in config.den.schema.home.imports = [ den.schema.conf { + # `visible = false`, never `internal = true`: gen-schema's + # `isPrimitiveOption` excludes an `internal` option from the identity set + # outright, so marking these internal would un-declare the very keys + # `_identity.keys` names. It reads `internal` and `identity` and NOT + # `visible`, so this keeps both out of rendered option docs while leaving + # them identity-eligible. options.__scopeName = lib.mkOption { type = lib.types.str; + visible = false; description = "Registry key of this home, used as its scope and identity."; }; options.system = lib.mkOption { type = lib.types.str; + visible = false; description = "platform system"; }; } From 239dbab2270410e2e75f951eb32826f90e2f3480 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 15 Sep 2026 13:53:43 -0700 Subject: [PATCH 3/6] chore: bump den-diagram for the aspect-chain rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up denful/den-diagram#2, which reads the provenance chain under both `provider` and `aspect-chain`. This branch's rename left den-diagram reading a field den no longer emits, so `diagram-demo` aborted with `attribute 'provider' missing` and the fleet and namespace views silently read an empty chain through their `or [ ]` fallbacks. Neither template passes `nix flake check` yet, and neither failure is this: `diagram-demo` hits a `files` input API drift on `perSystem..files.files.*.path` and `fleet-demo` hits incomplete `fileSystems`/`boot.loader.grub` config. Both reproduce unchanged on origin/main, and both are the errors these templates showed BEFORE the rename — so the bump restores them to their prior state rather than fixing them. `attribute 'provider' missing` is gone from both. --- templates/diagram-demo/flake.lock | 18 +++++++----------- templates/fleet-demo/flake.lock | 17 +++++++---------- 2 files changed, 14 insertions(+), 21 deletions(-) diff --git a/templates/diagram-demo/flake.lock b/templates/diagram-demo/flake.lock index 8a55806bd..424b75e1a 100644 --- a/templates/diagram-demo/flake.lock +++ b/templates/diagram-demo/flake.lock @@ -2,29 +2,25 @@ "nodes": { "den": { "locked": { - "lastModified": 1779389874, - "narHash": "sha256-Scr0VAw06OtEcxOfbpBXsSiCxn95bTm/DQQUMg/cWCo=", - "ref": "refs/heads/feat/den-diagram-extraction", - "rev": "2826f70fb538cb0ea8eb183a2a489f29b51a43f9", - "revCount": 624, - "type": "git", - "url": "file:///home/sini/Documents/repos/den" + "path": "../..", + "type": "path" }, "original": { "path": "../..", "type": "path" - } + }, + "parent": [] }, "den-diagram": { "inputs": { "nixpkgs": "nixpkgs" }, "locked": { - "lastModified": 1779680820, - "narHash": "sha256-haGCYE7mpBT8c7NW92WCv/CDbb0HJQsWbMG/j4wHiW4=", + "lastModified": 1789505374, + "narHash": "sha256-McpH6Jk8nIEP4Z2ITypVK2wrMJo7dxC8WWWnP4WwysQ=", "owner": "denful", "repo": "den-diagram", - "rev": "18fe17c21343a49524644749324bf7904c51605a", + "rev": "3ba2e47fa936251d0bab3a37fc754363c682ea98", "type": "github" }, "original": { diff --git a/templates/fleet-demo/flake.lock b/templates/fleet-demo/flake.lock index 1a1e399ac..b8b81ce60 100644 --- a/templates/fleet-demo/flake.lock +++ b/templates/fleet-demo/flake.lock @@ -2,28 +2,25 @@ "nodes": { "den": { "locked": { - "dirtyRev": "2826f70fb538cb0ea8eb183a2a489f29b51a43f9-dirty", - "dirtyShortRev": "2826f70f-dirty", - "lastModified": 1779389874, - "narHash": "sha256-luSCdKJm50GWPrtpd5/erVAzJV+6fvTDgeIo0AC9/6E=", - "type": "git", - "url": "file:///home/sini/Documents/repos/den" + "path": "../..", + "type": "path" }, "original": { "path": "../..", "type": "path" - } + }, + "parent": [] }, "den-diagram": { "inputs": { "nixpkgs": "nixpkgs" }, "locked": { - "lastModified": 1779680820, - "narHash": "sha256-haGCYE7mpBT8c7NW92WCv/CDbb0HJQsWbMG/j4wHiW4=", + "lastModified": 1789505374, + "narHash": "sha256-McpH6Jk8nIEP4Z2ITypVK2wrMJo7dxC8WWWnP4WwysQ=", "owner": "denful", "repo": "den-diagram", - "rev": "18fe17c21343a49524644749324bf7904c51605a", + "rev": "3ba2e47fa936251d0bab3a37fc754363c682ea98", "type": "github" }, "original": { From 356a05af5b5c1511f45f4fa4ca6ae9eb4c64a4b2 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 15 Sep 2026 14:41:45 -0700 Subject: [PATCH 4/6] feat: resolve a host-qualified aspect for users and standalone homes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `den.aspects."@"` was accepted by the aspect option and consulted by nothing — inert config with no diagnostic, the same silent-drop shape as #663. A user-scoped entity now resolves it alongside the bare `den.aspects.`, and the same two names serve a user declared under a host and a standalone home keyed `user@host`, so a user's aspects resolve identically either way. COMPOSITION, NOT PRECEDENCE, and it is forced rather than preferred. `modules/aspects/definition.nix` registers a stub aspect per entity so an entity's class keys always exist, which makes `den.aspects ? ` true for every declared entity whether or not anyone wrote that aspect — measured: a host declared with no aspect at all still answers true. A stub is also structurally indistinguishable from a written aspect (same keys, `name` defaulted from the attr path, no content), so no predicate over `den.aspects` can separate the two. A first-match lookup would therefore match the stub and shadow the bare aspect for every entity. Composing sidesteps the question: a stub contributes empty class keys, so including one is a no-op and nothing has to know which is which. It is also the better semantic — shared and host-specific aspects both apply. A single present candidate is returned as itself rather than wrapped, so the common case keeps its own identity and provenance exactly as before. A home's `name` is now its registry key rather than the bare user name, which is what identifies it and what aspect content reading `home.name` sees; the user it configures is `userName`. `_identity.keys` follows to `name`+`system`, so the `__scopeName` override and its kind-level declaration are gone — identity is native where gen-schema already puts it. BEHAVIOUR CHANGES - `home.name` read from a policy or class module is the registry key (`tux@igloo`), not the bare user name. Read `home.userName` for the user. - `den.batteries.import-tree` home variant therefore imports `/@` rather than `/`. Left reading `home.name` deliberately: the battery's host and user variants both key on the entity's own `.name`, and breaking that symmetry for homes alone would be the odder rule. No test covers this path. - An entity with both a qualified and a bare aspect now applies both, where the qualified one was previously ignored entirely. `fx-coverage`'s self-provide cell reads the declaring aspect rather than `home.aspect`, since a composed home's `aspect` is the wrapper and the provides live on the aspect that declared them; the invariant it tests is unchanged. Docs: host-qualified aspects in explanation/entities.mdx with the applied-name table, the guides/home-manager.mdx standalone section contrasting it with `provides.` routing, and the home/user schema reference tables, whose `name` row stated the superseded "part before `@`" rule. --- .../src/content/docs/explanation/entities.mdx | 43 ++++ docs/src/content/docs/guides/home-manager.mdx | 21 ++ docs/src/content/docs/reference/schema.mdx | 10 +- modules/options.nix | 39 ++-- nix/lib/entities/_types.nix | 51 ++++- nix/lib/entities/home.nix | 41 +++- nix/lib/entities/host.nix | 12 +- .../ci/modules/deprecated/homes-perhome.nix | 8 +- .../ci/modules/internal-api/fx-coverage.nix | 8 +- .../aspect-host-qualified-lookup.nix | 186 ++++++++++++++++++ .../ci/modules/public-api/flat-homes.nix | 42 +++- templates/ci/modules/public-api/homes.nix | 4 +- 12 files changed, 414 insertions(+), 51 deletions(-) create mode 100644 templates/ci/modules/public-api/aspect-host-qualified-lookup.nix diff --git a/docs/src/content/docs/explanation/entities.mdx b/docs/src/content/docs/explanation/entities.mdx index 1af826c20..357efae2d 100644 --- a/docs/src/content/docs/explanation/entities.mdx +++ b/docs/src/content/docs/explanation/entities.mdx @@ -51,6 +51,49 @@ or `"darwin"` (from `*-darwin`); for homes it is `"homeManager"`. Override with that points to the [aspect](/explanation/aspects/) responsible for configuring it — by default `den.aspects.`. +### Host-qualified aspects + +A user-scoped entity looks for a **host-qualified** aspect as well as a bare one, and +applies every match: + +| Entity | Aspects applied | +|--------|-----------------| +| user `tux` on host `igloo` | `den.aspects."tux@igloo"` and `den.aspects.tux` | +| home `tux@igloo` | `den.aspects."tux@igloo"` and `den.aspects.tux` | +| home `tux` | `den.aspects.tux` | + +Both apply, so shared configuration lives in `den.aspects.` and host-specific +additions in `den.aspects."@"` without either shadowing the other: + +```nix +{ + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.hosts.x86_64-linux.laptop.users.tux = { }; + + # Shared by every tux. + den.aspects.tux.homeManager.programs.git.enable = true; + + # Only on igloo. + den.aspects."tux@igloo".homeManager.programs.git.userEmail = "tux@igloo.example"; +} +``` + +The same two names serve a host user and a standalone home, so a user's aspects resolve +identically whether they are declared under a host or as a `user@host` home. This is +composition, not override — to make one depend on the other, include it explicitly: + +```nix +den.aspects."tux@igloo".includes = [ den.aspects.tux ]; +``` + + + Users have a `classes` list declaring which home-environment classes they want to use (e.g., `classes = [ "homeManager" ]`). diff --git a/docs/src/content/docs/guides/home-manager.mdx b/docs/src/content/docs/guides/home-manager.mdx index 6f2b1dd76..e8ac1b6e6 100644 --- a/docs/src/content/docs/guides/home-manager.mdx +++ b/docs/src/content/docs/guides/home-manager.mdx @@ -151,6 +151,27 @@ den.aspects.tux.provides.igloo = { }; See [Mutual provision](/guides/mutual/) for the full cross-entity routing patterns. +There is also a **host-qualified aspect name**, which needs no routing — a +`user@host` home applies `den.aspects."@"` in addition to +`den.aspects.`: + +```nix +den.homes.x86_64-linux."tux@igloo" = { }; + +# shared by every tux +den.aspects.tux.homeManager.programs.git.enable = true; + +# only this home +den.aspects."tux@igloo".homeManager.programs.git.userEmail = "tux@igloo.example"; +``` + +Both apply, so the qualified aspect adds to the shared one rather than replacing +it. The same two names also serve a user declared under a host +(`den.hosts..igloo.users.tux`), so the aspects resolve identically whether +`tux` is host-managed or a standalone home. Reach for `provides.` when the +content has to travel to a *different* entity, and the qualified name when it simply +belongs to this one. + #### Separate host and user configuration This pattern allows you to manage your host and user separately: diff --git a/docs/src/content/docs/reference/schema.mdx b/docs/src/content/docs/reference/schema.mdx index a4e39f9f4..5db5fa020 100644 --- a/docs/src/content/docs/reference/schema.mdx +++ b/docs/src/content/docs/reference/schema.mdx @@ -174,7 +174,7 @@ Type: `attrsOf userType` | `name` | `str` | attr name | User configuration name | | `userName` | `str` | `name` | System account name | | `classes` | `listOf str` | `[ "user" ]` | Nix classes this user participates in | -| `aspect` | `raw` | `den.aspects.` | Resolved aspect attrset | +| `aspect` | `raw` | `den.aspects.@` + `den.aspects.` | Resolved aspect attrset. Both the host-qualified and bare aspect apply where both exist — see [Host-qualified aspects](/explanation/entities/#host-qualified-aspects) | | `host` | `raw` | parent host | The host this user belongs to | | `collisionPolicy` | `null \| enum` | `null` | Class module collision policy: `"error"`, `"den-wins"`, or `"class-wins"`. See [Class Modules](/explanation/class-modules/). | | `resolved` | `raw` | auto | Resolved aspect from context pipeline (see below) | @@ -210,15 +210,15 @@ den.homes."tux@igloo" = { | Option | Type | Default | Description | |--------|------|---------|-------------| -| `name` | `str` | parsed name | Home configuration name (the part before `@` in a `user@host` key) | -| `userName` | `str` | parsed name | User account name | +| `name` | `str` | registry key | Home configuration name — the registry key as declared, e.g. `tux@igloo`. Identifies the home, and is what aspect content reading `home.name` sees | +| `userName` | `str` | parsed name | User account name (the part before `@` in a `user@host` key) | | `hostName` | `null \| str` | parsed host | Bound host name, or `null` for unbound standalone homes (the part after `@`) | | `host` | `raw` | resolved host | Bound host entity, or `null` when standalone | | `user` | `raw` | resolved user | Bound user entity, or `null` when standalone | | `system` | `str` | parent key | Platform system | | `class` | `str` | `"homeManager"` | Home management class | -| `aspect` | `raw` | `den.aspects.` | Resolved aspect attrset | -| `description` | `str` | auto | `home.name@system` | +| `aspect` | `raw` | `den.aspects.@` + `den.aspects.` | Resolved aspect attrset. Both the host-qualified and bare aspect apply where both exist — see [Host-qualified aspects](/explanation/entities/#host-qualified-aspects) | +| `description` | `str` | auto | `home.@` | | `pkgs` | `raw` | `inputs.nixpkgs.legacyPackages.$sys` | Nixpkgs instance | | `instantiate` | `raw` | `inputs.home-manager.lib.homeManagerConfiguration` | Builder | | `collisionPolicy` | `null \| enum` | `null` | Class module collision policy: `"error"`, `"den-wins"`, or `"class-wins"`. See [Class Modules](/explanation/class-modules/). | diff --git a/modules/options.nix b/modules/options.nix index f6ae2238b..571aa7ad1 100644 --- a/modules/options.nix +++ b/modules/options.nix @@ -180,32 +180,27 @@ in config.den.schema.fleet = { }; config.den.schema.host.imports = [ den.schema.conf ]; config.den.schema.user.imports = [ den.schema.conf ]; - # `home`'s identity keys are declared AT THE KIND, not on the instance. - # gen-schema closes the identity-key set the moment the kind is a value, so - # an option contributed through `mkInstanceType`'s `extraModules` cannot be - # an identity key, and the reflected set drops `internal` options outright. - # Naming either from `_identity.keys` is a hard error rather than the silent - # success it used to be. + # `home` keys its identity on the registry key plus the system. `name` IS the + # registry key and gen-schema injects it as an identity key by construction, + # so only `system` needs declaring — and it has to be declared HERE because + # gen-schema closes the identity-key set the moment the kind is a value, and + # an option contributed through `mkInstanceType`'s `extraModules` arrives + # after that. Naming an undeclared key from `_identity.keys` is a hard error. # - # home is the only kind that needs its own keys: `name` is forced to the bare - # user name so `den.aspects.` resolves, and two `user@host` homes on one - # system share that name. The registry key and the system are what actually - # tell them apart. Declared here, defined on the instance — identity is read - # off declarations, so the per-system value stays where it is computed. + # Declared here, defined on the instance: identity reflects declarations, so + # the per-system value stays where it is computed. + # + # `visible = false`, never `internal = true`: gen-schema's `isPrimitiveOption` + # drops an `internal` option from the identity set outright, so marking this + # internal would un-declare the very key `_identity.keys` names. It reads + # `internal` and `identity` and NOT `visible`, so this hides the key from + # rendered option docs while leaving it identity-eligible. That rests on + # `visible` not being read, which is an implementation fact rather than a + # documented contract — if gen-schema ever folds `visible` into the same + # presentation exclusion, this key silently leaves the identity set. config.den.schema.home.imports = [ den.schema.conf { - # `visible = false`, never `internal = true`: gen-schema's - # `isPrimitiveOption` excludes an `internal` option from the identity set - # outright, so marking these internal would un-declare the very keys - # `_identity.keys` names. It reads `internal` and `identity` and NOT - # `visible`, so this keeps both out of rendered option docs while leaving - # them identity-eligible. - options.__scopeName = lib.mkOption { - type = lib.types.str; - visible = false; - description = "Registry key of this home, used as its scope and identity."; - }; options.system = lib.mkOption { type = lib.types.str; visible = false; diff --git a/nix/lib/entities/_types.nix b/nix/lib/entities/_types.nix index 073a3049c..5bf9277aa 100644 --- a/nix/lib/entities/_types.nix +++ b/nix/lib/entities/_types.nix @@ -15,13 +15,51 @@ let inherit description default; }; - # Shared aspect lookup with warning for missing aspects. - lookupAspect = - den: config: - if den.aspects ? ${config.name} then - den.aspects.${config.name} + # Shared aspect lookup: an entity takes EVERY candidate aspect that exists, + # most specific first, composed through `includes`. + # + # NOT "first match wins", and the reason is that existence cannot answer the + # question a first-match lookup needs to ask. `modules/aspects/definition.nix` + # registers a stub aspect per entity (`genAttrs classes (_: { })`) so an + # entity's class keys always exist, which means `den.aspects ? ` + # is TRUE for every declared entity whether or not anyone wrote that aspect + # — measured: a host declared with no aspect at all still answers true. A + # stub is also structurally indistinguishable from a written aspect (same + # keys, `name` defaulted from the attr path, no content), so no predicate + # over `den.aspects` can separate them. + # + # Composing sidesteps that entirely: a stub contributes empty class keys, so + # including one is a no-op, and nothing has to know which is which. It also + # gives the better semantics — a user's shared aspect and their host-specific + # one both apply, rather than the qualified one shadowing config the author + # can still see in their tree. + # + # A single present candidate is returned AS ITSELF rather than wrapped, so + # the overwhelmingly common case keeps its own identity, name and provenance + # exactly as before this existed. + lookupAspectBy = + den: candidates: + let + wanted = lib.unique candidates; + present = builtins.filter (n: den.aspects ? ${n}) wanted; + in + if present == [ ] then + lib.warn + "den.aspects.${lib.concatStringsSep " / den.aspects." wanted} not defined — entity gets empty aspect" + { } + else if builtins.length present == 1 then + den.aspects.${builtins.head present} else - lib.warn "den.aspects.${config.name} not defined — entity gets empty aspect" { }; + { + # Angle-bracketed so den's own synthetic-name handling applies, and + # carrying the candidates so two entities composing different pairs + # cannot collide on one identity. + name = ""; + includes = map (n: den.aspects.${n}) present; + }; + + # Single-candidate form, for a kind whose registry key is its only spelling. + lookupAspect = den: config: lookupAspectBy den [ config.name ]; # Recursive merge without forcing leaf values. Unlike lib.types.anything this # does not inspect values deeply (no mapAttrsRecursiveCond), avoiding infinite @@ -188,6 +226,7 @@ in inherit strOpt lookupAspect + lookupAspectBy deepMergeAttrs mainModuleOption resolveResultOption diff --git a/nix/lib/entities/home.nix b/nix/lib/entities/home.nix index b8718d098..fb75f02b0 100644 --- a/nix/lib/entities/home.nix +++ b/nix/lib/entities/home.nix @@ -9,6 +9,7 @@ let inherit (import ./_types.nix { inherit lib den; }) strOpt lookupAspect + lookupAspectBy deepMergeAttrs mainModuleOption resolveResultOption @@ -123,15 +124,18 @@ let inputs.home-manager.lib.homeManagerConfiguration; in { - # mkInstanceType defaults name to the registry key (e.g. "tux@igloo"); - # den's name is the bare user name, so identity/description stay stable. - # That also makes `name` non-unique across homes (two `user@host` homes - # on one system share it), so the registry key is kept as the scope - # identity — see __scopeName in ./_types.nix. - config.name = lib.mkForce userName; - config.__scopeName = name; + # `name` is left as mkInstanceType's injected registry key (e.g. + # "tux@igloo"). gen-schema treats that key as an identity key by + # construction, so identity needs no shadow field: two `user@host` + # homes on one system differ in `name`, and the same key on two + # systems differs in `system`. `__scopeName` therefore needs no + # override here either — its default IS `config.name`. + # + # The bare user name lives on `userName`, and the two are different + # questions: the registry key identifies the home, `userName` says + # which user it configures. Aspect lookup below asks the second. config._identity.keys = [ - "__scopeName" + "name" "system" ]; config._module.args.host = hostCtx; @@ -158,9 +162,26 @@ let description = "Aspect that configures this home."; type = lib.types.raw; # no merging defaultText = "den.aspects."; - default = lookupAspect den config; + # Registry key first, bare user name second. For a home keyed + # `tux@igloo` the key IS the host-qualified spelling, so these + # are the same two candidates a host user asks for and a user's + # aspect resolves identically either way. For a home keyed + # plainly `tux` the two collapse to one. + # + # `userName` must be in the list: reading the key ALONE would + # miss `den.aspects.tux` and take lookupAspectBy's warn path to + # an EMPTY aspect — which does not fail, it defers the failure + # to whatever that aspect was meant to set (measured: + # home-manager's own `home.username != ""`, five frames away). + default = lookupAspectBy den [ + config.name + config.userName + ]; }; - description = strOpt "home description" "home.${config.name}@${config.system}"; + # `userName`, so this string is unchanged by the `name` promotion: + # a description is presentation, not identity, and interpolating + # the registry key here would read "home.tux@igloo@x86_64-linux". + description = strOpt "home description" "home.${config.userName}@${config.system}"; pkgs = lib.mkOption { description = '' nixpkgs instance used to build the home configuration. diff --git a/nix/lib/entities/host.nix b/nix/lib/entities/host.nix index 8d76d1df7..acf3c489a 100644 --- a/nix/lib/entities/host.nix +++ b/nix/lib/entities/host.nix @@ -9,6 +9,7 @@ let inherit (import ./_types.nix { inherit lib den; }) strOpt lookupAspect + lookupAspectBy deepMergeAttrs mainModuleOption resolveResultOption @@ -163,8 +164,15 @@ let aspect = lib.mkOption { description = "Aspect that configures this user."; type = lib.types.raw; # no merging - defaultText = "den.aspects."; - default = lookupAspect den config; + defaultText = "den.aspects.@ or den.aspects."; + # Host-qualified first, so one user can be configured per host + # without the bare aspect having to branch on `host.name`. Same + # two candidates a standalone home asks for, so a user's aspect + # resolves identically whether they are a host user or a home. + default = lookupAspectBy den [ + "${config.name}@${host.name}" + config.name + ]; }; host = lib.mkOption { default = host; diff --git a/templates/ci/modules/deprecated/homes-perhome.nix b/templates/ci/modules/deprecated/homes-perhome.nix index f5d1f57da..ca36e56a8 100644 --- a/templates/ci/modules/deprecated/homes-perhome.nix +++ b/templates/ci/modules/deprecated/homes-perhome.nix @@ -27,7 +27,11 @@ ( { home, ... }: { - homeManager.home.keyboard.variant = home.name; + # `userName`, because this cell is about a nested include + # resolving `{ home, ... }` at all — not about what `name` + # holds. The registry-key semantics of `home.name` are + # pinned directly in public-api/flat-homes.nix. + homeManager.home.keyboard.variant = home.userName; } ) ]; @@ -51,7 +55,7 @@ keyboard = config.flake.homeConfigurations."tux@igloo".config.home.keyboard; }; expected = { - homeSchema.name = "tux"; + homeSchema.name = "tux@igloo"; homeSchema.userName = "tux"; homeSchema.hostName = "igloo"; # A `user@host` home with no declared host carries synthetic host AND diff --git a/templates/ci/modules/internal-api/fx-coverage.nix b/templates/ci/modules/internal-api/fx-coverage.nix index fc17819b8..2101d3542 100644 --- a/templates/ci/modules/internal-api/fx-coverage.nix +++ b/templates/ci/modules/internal-api/fx-coverage.nix @@ -376,8 +376,12 @@ test-self-provide-host-provider = denTest ( { den, ... }: let - home = den.homes.x86_64-linux."tux@igloo"; - provResult = home.aspect.provides.igloo or null; + # Read off the DECLARING aspect, not `home.aspect`: a home keyed + # `user@host` composes its qualified and bare aspects, so `home.aspect` + # is the composition wrapper and the provides live on the aspect that + # declared them. The invariant under test — self-provide propagating + # __scopeHandlers — is unchanged. + provResult = den.aspects.tux.provides.igloo or null; in { den.hosts.x86_64-linux.igloo.users.tux = { }; diff --git a/templates/ci/modules/public-api/aspect-host-qualified-lookup.nix b/templates/ci/modules/public-api/aspect-host-qualified-lookup.nix new file mode 100644 index 000000000..4946227dd --- /dev/null +++ b/templates/ci/modules/public-api/aspect-host-qualified-lookup.nix @@ -0,0 +1,186 @@ +# `den.aspects."@"` — the host-qualified aspect target. +# +# A user-scoped entity resolves its aspect by trying the host-qualified name +# first and the bare user name second, at BOTH entity kinds: a user declared +# under a host, and a standalone home keyed `user@host`. Before this the +# qualified spelling was accepted by the aspect option and consulted by +# nothing, which is the same silent-drop shape as #663. +# +# COMPOSITION, not precedence: both aspects apply. That is forced rather than +# chosen — `modules/aspects/definition.nix` registers a stub aspect per entity, +# so `den.aspects ? ` is true for every declared entity whether or not +# anyone wrote it, and a stub is structurally identical to a written aspect. A +# "qualified wins" lookup would therefore match the stub and shadow the bare +# aspect for every entity. Composing is also the better semantic: a shared +# aspect and a host-specific one both apply. See nix/lib/entities/_types.nix. +{ denTest, ... }: +{ + flake.tests.aspect-host-qualified-lookup = { + + # --- host users ------------------------------------------------------- + + test-user-takes-host-qualified-aspect = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.aspects."tux@igloo".nixos.environment.etc."qualified".text = "yes"; + + expr = igloo.environment.etc ? "qualified"; + expected = true; + } + ); + + # CONTROL: the bare name still resolves when no qualified aspect exists. + # This is the pre-existing behaviour, and without it the cell above could + # pass for a change that broke bare-name lookup entirely. + test-user-falls-back-to-bare-aspect = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.aspects.tux.nixos.environment.etc."bare".text = "yes"; + + expr = igloo.environment.etc ? "bare"; + expected = true; + } + ); + + # Both defined: BOTH apply. Asserting the presence of the bare marker is + # what discriminates composition from precedence — a cell reading only the + # qualified marker passes under either semantics. + test-user-composes-qualified-with-bare = denTest ( + { den, igloo, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.aspects.tux.nixos.environment.etc."bare".text = "yes"; + den.aspects."tux@igloo".nixos.environment.etc."qualified".text = "yes"; + + expr = { + qualified = igloo.environment.etc ? "qualified"; + bare = igloo.environment.etc ? "bare"; + }; + expected = { + qualified = true; + bare = true; + }; + } + ); + + # The qualified name is per host, so the same user on two hosts can take + # two different aspects. A single-host fixture cannot show this: it passes + # for an implementation that ignores the host part entirely. + test-user-qualified-is-per-host = denTest ( + { den, config, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.hosts.x86_64-linux.iceberg.users.tux = { }; + den.aspects."tux@igloo".nixos.environment.etc."on-igloo".text = "yes"; + den.aspects."tux@iceberg".nixos.environment.etc."on-iceberg".text = "yes"; + + expr = { + iglooHasIgloo = config.flake.nixosConfigurations.igloo.config.environment.etc ? "on-igloo"; + iglooHasIceberg = config.flake.nixosConfigurations.igloo.config.environment.etc ? "on-iceberg"; + icebergHasIceberg = config.flake.nixosConfigurations.iceberg.config.environment.etc ? "on-iceberg"; + icebergHasIgloo = config.flake.nixosConfigurations.iceberg.config.environment.etc ? "on-igloo"; + }; + expected = { + iglooHasIgloo = true; + iglooHasIceberg = false; + icebergHasIceberg = true; + icebergHasIgloo = false; + }; + } + ); + + # --- standalone homes ------------------------------------------------- + + # For a home keyed `tux@igloo` the registry key IS the qualified spelling, + # so the same aspect name serves a home and a host user. + test-home-takes-host-qualified-aspect = denTest ( + { den, config, ... }: + { + den.homes.x86_64-linux."tux@igloo" = { }; + den.aspects."tux@igloo".homeManager.home = { + username = "tux"; + homeDirectory = "/home/tux"; + stateVersion = "25.05"; + sessionVariables.QUALIFIED = "yes"; + }; + + expr = + config.flake.homeConfigurations."tux@igloo".config.home.sessionVariables.QUALIFIED or ""; + expected = "yes"; + } + ); + + test-home-composes-qualified-with-bare = denTest ( + { den, config, ... }: + { + den.homes.x86_64-linux."tux@igloo" = { }; + den.aspects.tux.homeManager.home = { + username = "tux"; + homeDirectory = "/home/tux"; + stateVersion = "25.05"; + sessionVariables.BARE = "yes"; + }; + den.aspects."tux@igloo".homeManager.home = { + username = "tux"; + homeDirectory = "/home/tux"; + stateVersion = "25.05"; + sessionVariables.QUALIFIED = "yes"; + }; + + expr = + let + vars = config.flake.homeConfigurations."tux@igloo".config.home.sessionVariables; + in + { + qualified = vars.QUALIFIED or ""; + bare = vars.BARE or ""; + }; + expected = { + qualified = "yes"; + bare = "yes"; + }; + } + ); + + # CONTROL, and the load-bearing one: a home keyed `tux@igloo` must still + # take `den.aspects.tux`. Reading the registry key ALONE would miss it and + # resolve an EMPTY aspect — which throws nothing, and surfaces frames away + # as home-manager's own `home.username != ""` assertion. + test-home-falls-back-to-bare-aspect = denTest ( + { den, config, ... }: + { + den.homes.x86_64-linux."tux@igloo" = { }; + den.aspects.tux.homeManager.home = { + username = "tux"; + homeDirectory = "/home/tux"; + stateVersion = "25.05"; + sessionVariables.BARE = "yes"; + }; + + expr = config.flake.homeConfigurations."tux@igloo".config.home.sessionVariables.BARE or ""; + expected = "yes"; + } + ); + + # A home keyed with no host has no qualified spelling to try, so both + # candidates collapse to the bare name and the lookup still resolves. + test-unqualified-home-resolves-bare = denTest ( + { den, config, ... }: + { + den.homes.x86_64-linux.tux = { }; + den.aspects.tux.homeManager.home = { + username = "tux"; + homeDirectory = "/home/tux"; + stateVersion = "25.05"; + sessionVariables.BARE = "yes"; + }; + + expr = config.flake.homeConfigurations.tux.config.home.sessionVariables.BARE or ""; + expected = "yes"; + } + ); + + }; +} diff --git a/templates/ci/modules/public-api/flat-homes.nix b/templates/ci/modules/public-api/flat-homes.nix index af894e579..04aa0d4e5 100644 --- a/templates/ci/modules/public-api/flat-homes.nix +++ b/templates/ci/modules/public-api/flat-homes.nix @@ -29,7 +29,10 @@ ; }; expected = { - name = "tux"; + # `name` is the REGISTRY KEY, which is what identifies the home; + # `userName` is the user it configures. The two are different + # questions, and a home keyed `user@host` answers them differently. + name = "tux@igloo"; userName = "tux"; hostName = "igloo"; system = "x86_64-linux"; @@ -37,6 +40,43 @@ } ); + # What ASPECT CONTENT sees, as opposed to what the instance reports above. + # `home.name` is the registry key at both, so a policy or class module + # keyed on it identifies the home; `home.userName` is the user it + # configures. Read through a class module rather than off the instance, + # because that is the surface a user's aspects actually consume. + test-flat-home-name-in-aspect-content = denTest ( + { den, config, ... }: + { + den.homes.x86_64-linux."tux@igloo" = { }; + den.aspects.tux.homeManager = + { home, ... }: + { + home = { + username = "tux"; + homeDirectory = "/home/tux"; + stateVersion = "25.05"; + sessionVariables = { + SAW_NAME = home.name; + SAW_USERNAME = home.userName; + }; + }; + }; + + expr = + let + vars = config.flake.homeConfigurations."tux@igloo".config.home.sessionVariables; + in + { + inherit (vars) SAW_NAME SAW_USERNAME; + }; + expected = { + SAW_NAME = "tux@igloo"; + SAW_USERNAME = "tux"; + }; + } + ); + test-flat-home-coexists-with-legacy = denTest ( { den, ... }: { diff --git a/templates/ci/modules/public-api/homes.nix b/templates/ci/modules/public-api/homes.nix index 59f6a0e59..2c121475b 100644 --- a/templates/ci/modules/public-api/homes.nix +++ b/templates/ci/modules/public-api/homes.nix @@ -84,7 +84,9 @@ hasOsConfig = config.flake.homeConfigurations."tux@igloo".config.home.keyboard.model; }; expected = { - homeSchema.name = "tux"; # re-uses same aspect as hosted HM. + # The instance's name is its registry key; `userName` below is the + # user it configures, and that is what selects the aspect it re-uses. + homeSchema.name = "tux@igloo"; homeSchema.userName = "tux"; homeSchema.hostName = "igloo"; configuredUserName = "tux"; From 35c132d06f55ea836dc1af408a721e6b1e3e8d52 Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 15 Sep 2026 15:44:06 -0700 Subject: [PATCH 5/6] fix: supply nixpkgs lib to schema modules, and bump the hub for #672 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes that belong together: the hub bump is what makes den's documented schema shape fail, and shipping it alone would close one issue while leaving every documented example broken. LIB AS A MODULE ARGUMENT. gen's module system deliberately ships no nixpkgs `lib` — it has its own types — so a schema module written the way den documents it, `den.schema.host = { host, lib, ... }: …`, fails with `gen-merge: module argument `lib' is not defined`. That shape predates gen-schema and is what five doc sites and real user configs use, so den injects `lib` rather than asking gen to default to it or rewriting the docs. Injected on `den.schema.conf`, which every kind imports, so it is one site rather than one per kind. This was introduced by the gen-schema port, not by the bump: measured across three pins — passes on the pre-port tree, fails on the port, fails here. The suite stayed green throughout because den's own schema tests take `lib` from OUTER scope while the docs tell users to take it as a module argument. A contract nothing executed, so nothing caught it. The two cells added here are the execution, and they are red without the injection. HUB BUMP to gen 0b6fbd8, which carries gen-merge 08fcdd1 — the isOptLeaf door. A `mk*` wrapper in a schema module's `options` position now refuses by name and the message reaches den's own frame: gen-merge: option `den.hosts...users.' is declared as the `merge' combinator … move it under `config'/`imports', or write one plain attrset here That is discussion #672, which previously surfaced frames away as `An option declaration for ….contents has type `list'`, naming neither the schema nor the wrapper. Verified through den rather than relayed. The refusal is catchable, where the predecessor was an uncatchable abort. The hub sha was re-derived rather than taken from a report: an earlier reading of f6241d6 was already stale. Closure stays coherent — gen-prelude resolves to one revision, which is the property the hub routing exists for. Validation: `just ci` 1210/1210, exit 0, zero failures and zero errors, read off stderr with the collected count agreeing with the numerator. Both new cells falsified against the unfixed tree with the tests held in place, so the red arm measures the fix rather than the cells' absence. --- modules/options.nix | 11 ++++- templates/ci/flake.lock | 18 ++++---- .../public-api/schema-base-modules.nix | 41 +++++++++++++++++++ 3 files changed, 60 insertions(+), 10 deletions(-) diff --git a/modules/options.nix b/modules/options.nix index 571aa7ad1..953767721 100644 --- a/modules/options.nix +++ b/modules/options.nix @@ -176,7 +176,16 @@ in || throw "den.classes and den.quirks must not share keys, but found: ${builtins.concatStringsSep ", " overlap}"; lib.mapAttrs (name: v: v // { inherit name; }) quirks; }; - config.den.schema.conf = { }; + # nixpkgs `lib` as a module argument for every schema kind. gen's module + # system deliberately ships no nixpkgs lib — it has its own types — so a + # schema module written `{ host, lib, ... }:` gets + # `module argument `lib' is not defined` unless den supplies it. That shape + # is den's documented one and predates gen-schema, so den injects it rather + # than asking gen to default to it or rewriting the docs. + # + # On `conf` because every kind imports it, so this is one site rather than + # one per kind. + config.den.schema.conf._module.args.lib = lib; config.den.schema.fleet = { }; config.den.schema.host.imports = [ den.schema.conf ]; config.den.schema.user.imports = [ den.schema.conf ]; diff --git a/templates/ci/flake.lock b/templates/ci/flake.lock index ed4cfbdad..a16ee16f0 100644 --- a/templates/ci/flake.lock +++ b/templates/ci/flake.lock @@ -64,11 +64,11 @@ ] }, "locked": { - "lastModified": 1789496047, - "narHash": "sha256-xTCl7YcNJHSXKDfOWs1ooNRsRES87Nhy0zHPfpG5ca8=", + "lastModified": 1789511422, + "narHash": "sha256-Bh8GzWUbWoYRX/m56ACocEZ3l7SKPLm2YNav7FRaKJY=", "owner": "sini", "repo": "gen", - "rev": "f6241d6b9eee8a25e6c596a244e8278cce8972b5", + "rev": "0b6fbd8d5d3ec37d96b739b13d957373fb89178c", "type": "github" }, "original": { @@ -340,11 +340,11 @@ ] }, "locked": { - "lastModified": 1789483962, - "narHash": "sha256-2b3ZqXhyNIUQpUh4cwbL0THMJvnedTgwC0oHtL4LR6o=", + "lastModified": 1789510999, + "narHash": "sha256-908aAdx0BfbsjKPS8/ot4hGK221S4Gc2A5wTIOhuQXI=", "owner": "sini", "repo": "gen-merge", - "rev": "4232991ab1fbb2f223e029341e0dfa95a62ee54d", + "rev": "08fcdd1efb47af2a64ae121d1c97a3760263b48b", "type": "github" }, "original": { @@ -424,11 +424,11 @@ ] }, "locked": { - "lastModified": 1789491220, - "narHash": "sha256-gnjEUdleL0MDVL5MXFlcqXjuQKR2Wv6HqghjE6L18fM=", + "lastModified": 1789510821, + "narHash": "sha256-55SiolgfGyaGv2tkn6OH//K7heGiVhilF2gjXzUXIi8=", "owner": "sini", "repo": "gen-schema", - "rev": "8cf90f5ce2a8435820cfb24f72a195bc3495ff39", + "rev": "f8e0e171d45e0ba872afe4a78843ec59f76165e9", "type": "github" }, "original": { diff --git a/templates/ci/modules/public-api/schema-base-modules.nix b/templates/ci/modules/public-api/schema-base-modules.nix index ad18b5080..4c5a6e312 100644 --- a/templates/ci/modules/public-api/schema-base-modules.nix +++ b/templates/ci/modules/public-api/schema-base-modules.nix @@ -3,6 +3,47 @@ { flake.tests.schema-base-modules = { + # The shape the docs use — `lib` as a MODULE ARGUMENT, not from outer + # scope. gen's module system ships no nixpkgs lib (it has its own types), + # so den injects it on `den.schema.conf`, which every kind imports. + # + # Pinned because the rest of this file takes `lib` from outer scope, so the + # suite stayed green while every documented example was broken — a contract + # nothing executed. These two cells are the execution. + test-doc-shape-lib-as-module-arg-on-kind = denTest ( + { den, ... }: + { + den.hosts.x86_64-linux.igloo = { }; + den.schema.host = + { host, lib, ... }: + { + options.docShape = lib.mkOption { default = true; }; + }; + expr = den.hosts.x86_64-linux.igloo.docShape; + expected = true; + } + ); + + test-doc-shape-lib-as-module-arg-on-conf = denTest ( + { den, ... }: + { + den.hosts.x86_64-linux.igloo.users.tux = { }; + den.schema.conf = + { lib, ... }: + { + options.sharedByAllKinds = lib.mkOption { default = "shared"; }; + }; + expr = { + host = den.hosts.x86_64-linux.igloo.sharedByAllKinds; + user = den.hosts.x86_64-linux.igloo.users.tux.sharedByAllKinds; + }; + expected = { + host = "shared"; + user = "shared"; + }; + } + ); + test-host-schema-module-args = denTest ( { den, lib, ... }: { From 8253c010ed6429fe3c50a1d6af13acb05afd3d8e Mon Sep 17 00:00:00 2001 From: Jason Bowman Date: Tue, 15 Sep 2026 16:23:24 -0700 Subject: [PATCH 6/6] fix: name the structuralKeysSet removal instead of failing as a missing attr Closing the structural-key registry by rule dropped the `structuralKeysSet` export, and a consumer reading it got a bare `attribute 'structuralKeysSet' missing` naming neither den nor the replacement. Found by evaluating a real configuration against this branch: it reads the export to skip structural keys while building a settings type. It cannot be aliased faithfully. `isStructuralKey` derives the `__`-prefixed half BY RULE over an infinite domain, so no attrset can answer `?` for it and Nix offers no way to intercept `?`. Re-exporting the listed half alone would answer false for every `__` key, which is the silent-drop hazard closing the registry by rule removed, reintroduced under a compatibility name. So the name throws by itself instead, stating the rule and the migration. A thunk, so `inherit`ing it stays quiet and the message lands at first use, which is where the caller is rather than where the import is. Validation: `just ci` 1212/1212, exit 0, zero failures and zero errors. The message verified by evaluating the unmigrated consumer against this tree and reading the thrown text, rather than by inspecting the string. --- nix/lib/aspects/fx/key-classification.nix | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/nix/lib/aspects/fx/key-classification.nix b/nix/lib/aspects/fx/key-classification.nix index 6b7a826a1..a268fc1e7 100644 --- a/nix/lib/aspects/fx/key-classification.nix +++ b/nix/lib/aspects/fx/key-classification.nix @@ -110,4 +110,24 @@ let in { inherit isStructuralKey classifyKeys pipeRegistry; + + # Removed, and it cannot be aliased faithfully: `isStructuralKey` derives the + # `__`-prefixed half BY RULE over an infinite domain, so no attrset can + # answer `?` for it, and Nix offers no way to intercept `?`. Re-exporting the + # listed half alone would answer false for every `__` key, which is precisely + # the silent-drop hazard closing the registry by rule removed. + # + # So this throws by name instead. It is a thunk, so `inherit`ing it stays + # quiet and the message lands at first use, which is where the caller is. + structuralKeysSet = throw '' + den: `keyClassification.structuralKeysSet` was removed. + + The structural-key registry is now closed by RULE, not by a listed set: any `__`-prefixed key is structural the moment it exists. A set cannot express that. + + Replace a membership test with the predicate: + + structuralKeysSet ? k -> isStructuralKey k + + inherit (den.lib.aspects.fx.keyClassification) isStructuralKey; + ''; }