Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions docs/src/content/docs/explanation/entities.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>`.

### 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.<user>` and host-specific
additions in `den.aspects."<user>@<host>"` 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 ];
```

<Aside title="Which name is which">
A home declared as `den.homes.<system>."tux@igloo"` has `name = "tux@igloo"` — its
registry key, which is what identifies it and what aspect content reading `home.name`
sees. The user it configures is `home.userName` (`"tux"`), and the host it is bound to
is `home.hostName` (`"igloo"`). Two homes for one user on different hosts share a
`userName` and are told apart by `name`.
</Aside>

Users have a `classes` list declaring which home-environment classes they want to use
(e.g., `classes = [ "homeManager" ]`).

Expand Down
21 changes: 21 additions & 0 deletions docs/src/content/docs/guides/home-manager.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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."<user>@<host>"` in addition to
`den.aspects.<user>`:

```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.<system>.igloo.users.tux`), so the aspects resolve identically whether
`tux` is host-managed or a standalone home. Reach for `provides.<host>` 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:
Expand Down
10 changes: 5 additions & 5 deletions docs/src/content/docs/reference/schema.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>` | Resolved aspect attrset |
| `aspect` | `raw` | `den.aspects.<name>@<host>` + `den.aspects.<name>` | 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) |
Expand Down Expand Up @@ -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.<name>` | Resolved aspect attrset |
| `description` | `str` | auto | `home.name@system` |
| `aspect` | `raw` | `den.aspects.<userName>@<hostName>` + `den.aspects.<userName>` | 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.<userName>@<system>` |
| `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/). |
Expand Down
40 changes: 38 additions & 2 deletions modules/options.nix
Original file line number Diff line number Diff line change
Expand Up @@ -176,11 +176,47 @@ 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 ];
config.den.schema.home.imports = [ den.schema.conf ];
# `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.
#
# 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
{
options.system = lib.mkOption {
type = lib.types.str;
visible = false;
description = "platform system";
};
}
];
config.den.classes = {
nixos.description = "NixOS system configuration";
darwin.description = "nix-darwin system configuration";
Expand Down
20 changes: 20 additions & 0 deletions nix/lib/aspects/fx/key-classification.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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;
'';
}
51 changes: 45 additions & 6 deletions nix/lib/entities/_types.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? <entity name>`
# 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 = "<aspects:${lib.concatStringsSep "+" present}>";
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
Expand Down Expand Up @@ -188,6 +226,7 @@ in
inherit
strOpt
lookupAspect
lookupAspectBy
deepMergeAttrs
mainModuleOption
resolveResultOption
Expand Down
41 changes: 31 additions & 10 deletions nix/lib/entities/home.nix
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ let
inherit (import ./_types.nix { inherit lib den; })
strOpt
lookupAspect
lookupAspectBy
deepMergeAttrs
mainModuleOption
resolveResultOption
Expand Down Expand Up @@ -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;
Expand All @@ -158,9 +162,26 @@ let
description = "Aspect that configures this home.";
type = lib.types.raw; # no merging
defaultText = "den.aspects.<name>";
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.
Expand Down
12 changes: 10 additions & 2 deletions nix/lib/entities/host.nix
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ let
inherit (import ./_types.nix { inherit lib den; })
strOpt
lookupAspect
lookupAspectBy
deepMergeAttrs
mainModuleOption
resolveResultOption
Expand Down Expand Up @@ -163,8 +164,15 @@ let
aspect = lib.mkOption {
description = "Aspect that configures this user.";
type = lib.types.raw; # no merging
defaultText = "den.aspects.<name>";
default = lookupAspect den config;
defaultText = "den.aspects.<name>@<host> or den.aspects.<name>";
# 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;
Expand Down
36 changes: 29 additions & 7 deletions nix/lib/schema.nix
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading