Skip to content

feat: move to gen-schema HEAD via the gen hub, and resolve host-qualified aspects - #680

Merged
sini merged 6 commits into
mainfrom
feat/gen-schema-head
Sep 15, 2026
Merged

sini merged 6 commits into
mainfrom
feat/gen-schema-head

Conversation

@sini

@sini sini commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

#672

Summary

Resolution:

  • Resolves gen-schema through the gen hub instead of pinning gen-schema directly, as gen-schema's root declares { prelude, merge, algebra, identity } with no ... while den's fallback passed { lib }, which is a hard error at the eleven templates that do not declare the input.
  • Gains closure coherence from that routing, because pinning gen-schema directly left its transitive closure to den and resolved three revisions of gen-prelude in one lock.

Identity:

  • Declares the home kind's identity keys at the kind, since 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.
  • Hides those keys with visible = false rather than internal = true, as isPrimitiveOption reads internal and identity and not visible, so marking them internal would un-declare the very keys _identity.keys names.
  • Records in the source that the visible route rests on an implementation fact rather than a stated contract, so a later folding of visible into the same presentation exclusion would drop these keys from identity in silence.

Module arguments:

  • Supplies nixpkgs lib to schema modules on den.schema.conf, which every kind imports, because gen's module system ships no nixpkgs lib and den documents den.schema.host = { host, lib, ... }: at five sites.

Aspects:

  • Resolves den.aspects."<user>@<host>" for a user declared under a host and for a standalone home keyed user@host, as the qualified spelling was accepted by the aspect option and consulted by nothing.
  • Composes the qualified and bare aspects rather than preferring either, because modules/aspects/definition.nix registers a stub aspect per entity so den.aspects ? <name> is true for every declared entity, and a stub is structurally indistinguishable from a written aspect.
  • Keys a home's name to its registry key rather than the bare user name, so the field that identifies a home is the one that tells two homes for one user apart, and userName carries the user it configures.

Diagnostics:

  • Reaches gen-merge 08fcdd1 through gen 0b6fbd8, so a mk* wrapper in a schema module's options position refuses by name rather than failing frames away as a contents option of the wrong type.
  • Names the structuralKeysSet removal at the point of use, as closing the structural-key registry by rule dropped that export and a consumer reading it got a bare attribute 'structuralKeysSet' missing. It cannot be aliased, because isStructuralKey derives the __-prefixed half by rule over an infinite domain and no attrset can answer ? for that.

Behaviour changes

Content that was previously inert becomes live, so a rebuild can emit modules it did not emit before:

  • den.aspects."<user>@<host>" is resolved where it was accepted and ignored.
  • An entity with both a qualified and a bare aspect applies both.

A field changes meaning, which can change an existing configuration:

  • home.name read from a policy or class module is the registry key such as tux@igloo, not the bare user name. Read home.userName for the user.
  • den.batteries.import-tree's home variant therefore imports <root>/<user>@<host> rather than <root>/<user>. It keeps reading home.name deliberately, as its host and user variants both key on the entity's own .name. No test covers this path.

Input that was previously accepted is now refused:

  • A schema module declaring options through mkMerge or another mk* wrapper refuses by name, where it previously declared options called _type and contents and failed on a later read.
  • _identity.keys naming a key the kind does not declare refuses, where it previously succeeded and minted over whatever the reflection reached.
  • keyClassification.structuralKeysSet throws by name with its migration, where it previously read as a missing attribute. Replace structuralKeysSet ? k with isStructuralKey k.

Validation

  • nix develop -c just ci: 1212/1212, exit 0, zero failures and zero errors, read off stderr with the collected count agreeing with the numerator.
  • Falsified every new cell against the unfixed tree with the tests held in place, so the red arm measures the fix rather than the cells' absence.
  • Ran nix flake check per template rather than reading just all check's exit code, which aborts at ci on a flake.packages double declaration that reproduces on main. Nine templates pass, and diagram-demo, fleet-demo and terranix-demo fail for reasons that also reproduce on main.
  • Confirmed the identity change by reading id-hash.nix rather than only the suite, as explicit _identity.keys still drive the hash and membership is validated rather than substituted, so the same-name and cross-system distinctness cells are not passing for the wrong reason.
  • Measured the lib regression across three pins, since it passes before the port, fails on the port and fails after the bump, which places it in the port rather than the bump.
  • Drove discussion Standalone home-manager build failure after upgrade #672's shape through den rather than relaying the upstream report, and the refusal is catchable where its predecessor was an abort that escaped tryEval.
  • Re-derived the hub sha rather than taking it from a report, as an earlier reading was already stale by the time it was written.
  • Evaluated a real multi-machine configuration against this branch and against main, and all seven NixOS machines produce byte-identical derivations while the darwin machine evaluates identically. That sweep is what surfaced the structuralKeysSet removal, as the configuration consumes it.
  • Rebased onto main after fix: resolve identity by definition value and split the provenance field #678 merged, with the rebased tree diffing empty against the pre-rebase commit, so the replay changed no content.

Known limitation, not addressed here

lookupAspect's missing-aspect warning is dead code in the normal path, because the per-entity stub above makes den.aspects ? <entity> always true. Issue #663 cites that warning as the only signal a user gets. Making it fire needs a design decision rather than a predicate change, as an entity legitimately may have no own aspect when it is configured through den.default or schema includes.

@sini
sini requested a review from vic as a code owner September 15, 2026 22:46
@github-actions github-actions Bot added the allow-ci allow all CI integration tests label Sep 15, 2026
Base automatically changed from refactor/aspect-chain-rename to main September 15, 2026 23:27
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.
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.
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.<system>.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.
`den.aspects."<user>@<host>"` 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.<user>`, 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 ? <name>` 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
  `<root>/<user>@<host>` rather than `<root>/<user>`. 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.<host>` routing, and the home/user schema reference tables, whose
`name` row stated the superseded "part before `@`" rule.
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.<sys>.<host>.users.<user>' 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.
…ng 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.
@sini
sini force-pushed the feat/gen-schema-head branch from 7dc89f7 to 8253c01 Compare September 15, 2026 23:29
@sini
sini merged commit 6c11a13 into main Sep 15, 2026
18 checks passed
@sini
sini deleted the feat/gen-schema-head branch September 15, 2026 23:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

allow-ci allow all CI integration tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant