fix: four silent-drop defects in forward classes and standalone homes - #643
Merged
Conversation
A custom class built with `_.forward` fails to evaluate when the `host-aspects` battery is also included: The option `home-manager.users.<u>.den.fwd."<from>/<into>/<path>"' is already declared #603 made a spawned node apply the parent pipeline's subtree routes, so a user-schema route (homeLinux->homeManager) fires against the content the spawn re-emits. Adapter-bearing routes are not safe under that rule: `mkAdapterAspect` / `mkAdapterFunctor` materialize an option DECLARATION, and the parent pipeline already materializes the same route at the same scope. Both folds land in one home-manager evaluation, so the second declaration is a hard conflict — content definitions merge, declarations do not. Exclude declaration-bearing parent routes from the spawn's route merge, leaving the parent's copy as the single owner. That copy 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. Simple routes keep re-applying, preserving #603. Four regression cases (user-defined content, host-defined content, host-defined across two users, and a no-battery control); the three battery cases fail at the parent commit with the reported error. Reported in discussion #642.
Two custom forward classes chained (`inner` -> `mid` -> `homeManager`) collide on the inner hop: The option `home-manager.users.<u>.den.fwd."inner/mid/<path>"' is already declared Same #603 cause as the direct case, one hop removed. 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 in the spawn does not help here: the spawn's OWN walk registers the inner forward too, and `spawnKeys` gave the spawn's copy precedence over the parent's, leaving one producer on each side. Flip the precedence for declaration-bearing routes only. A declaring route is dropped from `freshParent` always, and additionally from `spawnHere` when the parent registered the same identity at that scope, so the parent is the sole owner. A declaration only the spawn registers still stands — there is no competing producer for it. Matching by route identity rather than `intoClass == class` is what catches the indirect hop; a target-class test would miss it. Three cases: user-defined, host-defined (the projection this must not strand), and a no-battery control.
A host-attached parametric aspect feeding a custom forward class handed
every user every other user's content, silently:
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, distinguishing them only by the `@<kind>=<name>` pairs in each
identity's {ctxId}. So the host's bucket 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` passed that bucket to every child-scope forward
whole, and only narrowed it by class ownership — never by binding. Each
user's forward therefore collected the union.
Filter root modules 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 and always passes, 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, as a
wrapped NixOS module cannot carry extra attributes.
Reproduces with no host-aspects battery, hence no spawn; a static-content
control covers the sharing case.
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
enabled auto-merge (squash)
July 31, 2026 19:59
sini
disabled auto-merge
July 31, 2026 20:59
Collaborator
Author
|
User confirmed fix addresses their regression, merging. |
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 #642, #644, #645, #640.
The first three are regressions from 41aa255 (#603) in how custom
_.forwardclasses 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) —a2dbe027A custom class built with
_.forwardfails to evaluate as soon as thehost-aspectsbattery is also included:_.forwardwith a guard compiles to a__complexForwardroute whose materialization (applyComplexRouteEdge→buildForwardAspect→mkAdapterAspect) emits a functor that declaresoptions.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-schemahomeLinux→homeManagerroute 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
_filewithsourceScopeId|foldRootnamed both producers: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
__complexForwardbeforeadapterKeyis load-bearing — every complex forward carries anadapterKey, so a bareadapterKey != nulltest 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) —
14d07a1finner → mid → homeManagercollides onden.fwd."inner/mid/…". The inner adapter declares into themidbucket, 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, andspawnKeysgave the spawn precedence — one producer left on each side.Precedence is now flipped for declaring routes only: dropped from
freshParentalways, and fromspawnHerewhen the parent registered the same identity at that scope. A declaration only the spawn registers still stands. Matching on route identity rather thanintoClass == classis what catches the indirect hop.3. Per-user host content leaking across users (#644) —
71ff2b47No error, just a wrong configuration:
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:filterRootModuleshanded 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.ctxBindingssits besidestripCtxSuffix, 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-aspectsbattery 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) —8f07b0c5Pre-existing, not a #603 regression. A
homeManagerclass module requestinguservanished on any standalone home whose user could not be resolved from a declared host — a bareden.homes.<sys>.tux, ortux@astrawithastraundeclared. Silent for the same reason as the others:wrapFunctionModulereturnsunsatisfied = trueand thelib.warnit attaches rides on the discarded module, so it is never forced.The schema DAG offers no fallback —
user.parentandhome.parentare bothhost, makingusera sibling ofhome, 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 reasonuserwas deliberately left null when the synthetic host was introduced. Two supports for that gate:user-to-hostnow gates onhost ? class, matchingos-to-host,hostname,unfreeandinsecure. It readhost.classunguarded and only ever escaped becauseuserwas absent from a standalone home's ctx.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-hostasserted the superseded contract (user = null, host withoutsystem) and is updated. Itskeyboard.model = "standalone"is unchanged — that is the evidenceosConfigis 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:
issue-642-host-aspects-custom-class.nixissue-645-chained-forward-classes.nixissue-644-parametric-host-forward-per-user.nixissue-640-standalone-home-user-arg.nix{ home, … }controlEvery 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 fmtclean.