fix: fan class-module entity args over scope descendants (#629) - #634
Merged
Conversation
A static aspect whose class-content module names a strict descendant of
the emitting scope's entity kind (e.g. `nixos = { user, ... }:` at host
scope) had no such binding in scope, so wrapClassModule marked it
unsatisfied and wrap-classes dropped it silently — while the equivalent
aspect-level `{ user, ... }:` form worked, because bind fans that over
the scope's descendants.
Promote such aspects to be parametric on the named descendant kinds in
the compile shape router, so the bind handler fans them per descendant
instance via the SAME machinery the aspect-level form uses, making the
two equivalent. Descendant classification (not mere ctx-absence) keeps
scope-kind-self and ancestor args on the static path; the
__parametricResolvedArgs guard stops the re-resolved body from
re-promoting into a loop.
fmway
approved these changes
Jul 18, 2026
sini
enabled auto-merge (squash)
July 18, 2026 00:34
sini
added a commit
that referenced
this pull request
Jul 31, 2026
A `homeManager` class module requesting `user` was silently dropped on any standalone home whose user could not be resolved from a DECLARED host — a bare `den.homes.<sys>.tux`, or `tux@astra` where `astra` is undeclared. No error and no warning: `wrapFunctionModule` puts the module on the `missingDenArgNames` path, and the `lib.warn` it attaches rides on the discarded module, so it is never forced. home.nix bound `user` only via `den.hosts.<host>.users.<user>`. The schema DAG offers no fallback either — `user.parent` and `home.parent` are both `host`, making `user` a SIBLING of `home`, so #634's descendant fan-out does not reach it. But a standalone home always names its user, so bind it from the home itself; a declared host still wins and keeps the full record. The synthetic user is identity-only and, like the synthetic host, carries no `class`. That is what keeps OS batteries inert, which is the reason `user` was left null when the synthetic host was introduced. Two supports for that gate: - `user-to-host` now gates on `host ? class`, matching `os-to-host`, `hostname`, `unfree` and `insecure`. It read `host.class` unguarded and only ever escaped because `user` was absent from a standalone home's ctx. - the synthetic host carries `system`, which the home knows for certain and which host-keyed content needs for platform-dependent values (define-user's home directory). `standalone-homes.test-home-standalone-without-existing-host` asserted the superseded contract (`user = null`, host without `system`) and is updated. Its `keyboard.model = "standalone"` is unchanged, which is the evidence `osConfig` is still not wired and OS routing still does not fire. Pre-existing, not a #603 regression. Fixes #640.
sini
added a commit
that referenced
this pull request
Jul 31, 2026
…#643) Fixes #642, #644, #645, #640. The first three are regressions from 41aa255 (#603) in how custom `_.forward` classes are delivered; #640 is a pre-existing standalone-home defect. They share a shape — content or a declaration goes missing with nothing said about it — but four distinct mechanisms, so four commits. ## 1. Duplicate adapter declaration with `host-aspects` (#642) — `a2dbe027` A custom class built with `_.forward` fails to evaluate as soon as the `host-aspects` battery is also included: ``` The option `home-manager.users.<u>.den.fwd."<from>/<into>/<path>"' is already declared in `…/flake.nix'. ``` `_.forward` with a guard compiles to a `__complexForward` route whose materialization (`applyComplexRouteEdge` → `buildForwardAspect` → `mkAdapterAspect`) emits a functor that **declares** `options.den.fwd.<adapterKey>`. #603 made a spawned node also apply the parent pipeline's subtree routes — right for simple routes, since that is what makes a user-schema `homeLinux→homeManager` route fire against re-emitted content, but not for adapter-bearing ones. The parent already materializes the same route at the same scope and both folds land in one home-manager evaluation. Content *definitions* merge; a duplicated option *declaration* is a hard error. Tagging each emitted adapter module's `_file` with `sourceScopeId|foldRoot` named both producers: ``` in `…:host=igloo,…,user=tux|host=igloo,…,user=tux' ← host-aspects spawn fold is already declared in `…:host=igloo,…,user=tux|host=igloo,system=x86_64-linux' ← host pipeline fold ``` Declaration-bearing parent routes are excluded from the spawn's route merge, leaving the parent's copy as the single owner. It is always there to take over: an excluded route is by construction inside the spawned subtree, and the redundant-root suppression only fires on a route *at* the fold root. Testing `__complexForward` before `adapterKey` is load-bearing — every complex forward carries an `adapterKey`, so a bare `adapterKey != null` test would also have excluded non-declaring forwards, including the home-manager battery's own delivery route. ## 2. Same collision one hop out, on chained classes (#645) — `14d07a1f` `inner → mid → homeManager` collides on `den.fwd."inner/mid/…"`. The inner adapter declares into the `mid` bucket, which the second hop nests into the target, so the declaration arrives indirectly. Suppressing the parent's copy does not help: the spawn's own walk registers the inner forward too, and `spawnKeys` gave the *spawn* precedence — one producer left on each side. Precedence is now flipped for declaring routes only: dropped from `freshParent` always, and from `spawnHere` when the parent registered the same identity at that scope. A declaration only the spawn registers still stands. Matching on route identity rather than `intoClass == class` is what catches the indirect hop. ## 3. Per-user host content leaking across users (#644) — `71ff2b47` No error, just a wrong configuration: ``` expected { pinguSees = ["pingu"]; tuxSees = ["tux"]; } actual { pinguSees = ["pingu" "tux"]; tuxSees = ["pingu" "tux"]; } ``` `bind`'s descendant-arg fan-out emits every instance at the *emitting* scope, distinguished only by the `@<kind>=<name>` pairs in each identity's `{ctxId}`. The host bucket therefore legitimately holds one instance per user: ``` myshell@per-user/<anon>:0/{per-user/<anon>:0@user=pingu} myshell@per-user/<anon>:0/{per-user/<anon>:0@user=tux} ``` `filterRootModules` handed that bucket to every child-scope forward whole, narrowing only by class ownership, never by binding — so each user collected the union. Root modules are now filtered by the bindings their identity carries: a module naming an entity the child's ctx also names must name the same one. Bindings for a kind the child has no record of cannot disqualify it, and unfanned content binds nothing, so shared host content still reaches every user. `ctxBindings` sits beside `stripCtxSuffix`, which already reads this suffix; the binding survives nowhere else by the time routes fold, since a wrapped NixOS module cannot carry extra attributes. Reproduces with **no** `host-aspects` battery and therefore no spawn at all, which is what places it in the parent pipeline's source collection rather than anywhere the other two fixes touch. ## 4. `{ user, ... }` dropped on standalone homes (#640) — `8f07b0c5` Pre-existing, not a #603 regression. A `homeManager` class module requesting `user` vanished on any standalone home whose user could not be resolved from a *declared* host — a bare `den.homes.<sys>.tux`, or `tux@astra` with `astra` undeclared. Silent for the same reason as the others: `wrapFunctionModule` returns `unsatisfied = true` and the `lib.warn` it attaches rides on the discarded module, so it is never forced. The schema DAG offers no fallback — `user.parent` and `home.parent` are both `host`, making `user` a *sibling* of `home`, so #634's descendant fan-out does not reach it. But a standalone home always names its user, so it is bound from the home itself; a declared host still wins and keeps the full record. The synthetic user is identity-only and carries no `class`, which is what keeps OS batteries inert — the reason `user` was deliberately left null when the synthetic host was introduced. Two supports for that gate: - `user-to-host` now gates on `host ? class`, matching `os-to-host`, `hostname`, `unfree` and `insecure`. It read `host.class` unguarded and only ever escaped because `user` was absent from a standalone home's ctx. - the synthetic host carries `system`, which the home knows for certain and which host-keyed content needs for platform-dependent values (define-user's home directory). `standalone-homes.test-home-standalone-without-existing-host` asserted the superseded contract (`user = null`, host without `system`) and is updated. Its `keyboard.model = "standalone"` is unchanged — that is the evidence `osConfig` is still not wired and OS routing still does not fire. ## Tests Fourteen cases across four deadbug files, each red before its fix and green here: | file | cases | |---|---| | `issue-642-host-aspects-custom-class.nix` | user-defined, host-defined, host-defined across two users, no-battery control | | `issue-645-chained-forward-classes.nix` | with battery, host-defined, no-battery control | | `issue-644-parametric-host-forward-per-user.nix` | no battery, with battery, static-content sharing control | | `issue-640-standalone-home-user-arg.nix` | bare home, synthetic host, declared-host control, `{ home, … }` control | Every fix ships a control for the failure it could itself cause — stranding projected content (#642, #645), breaking legitimate host-content sharing (#644), or disturbing the declared-host path (#640). Full CI: `1079/1079`, 0 failures, 0 eval errors. `just fmt` clean.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #629.
Problem
Requesting an entity arg (e.g.
user) inside a class module was silently dropped when the aspect is included at a scope where that entity is a descendant — the reporter'snixos = { user, ... }:at host scope:The whole
nixosmodule vanished — no config, no options, no error. The only workaround was lifting the entity arg to the aspect level ({ user, ... }: { nixos = ...; }), which does fan per-user.Root cause
At host scope the emit-time ctx has
hostbut nouser, sowrapFunctionModule(class-module.nix) marks the moduleunsatisfiedandwrap-classes.nixreturns[ ]for it — dropped. The aspect-level form works because thebindhandler fans the aspect over the scope's descendant entities (host.users); class-module function args never reached that fan-out.(The
lib.warnmeant to flag this is attached to the discarded module, so it never fires — hence "silent". Left as a separate observability concern: the descendant case no longer skips, and reviving the warn unconditionally would noise up the intentional guard-skip pattern.)Fix
In the compile shape router, a static aspect whose class-content module names a strict descendant of the emitting scope's entity kind is promoted to a parametric aspect on those kinds. The
bindhandler then fans it per descendant instance via the exact same machinery the aspect-level{ user, ... }:form uses — making the two forms equivalent.argClass.isDescendantOf), not mere ctx-absence: scope-kind-self and ancestor args stay on the static path (bind would only satisfy/inert them), sonixos = { host, ... }:and user-scope resolution are unchanged.__parametricResolvedArgsguard: excludes kinds already bound by an earlier fan, so the re-resolved body (which still names the kind) can't re-promote into a loop.Testing
templates/ci/modules/features/deadbugs/issue-629-class-module-user-arg.nix— the class-module form now fans; the aspect-level control still works.user-scoped-host-class-fanout,nested-class-module-args(incl.test-guard-skips-without-context),top-level-parametric,auto-parametricall green.