Skip to content

fix: four silent-drop defects in forward classes and standalone homes - #643

Merged
sini merged 4 commits into
mainfrom
fix/642-host-aspects-forward-duplicate
Jul 31, 2026
Merged

fix: four silent-drop defects in forward classes and standalone homes#643
sini merged 4 commits into
mainfrom
fix/642-host-aspects-forward-duplicate

Conversation

@sini

@sini sini commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

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 (applyComplexRouteEdgebuildForwardAspectmkAdapterAspect) 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.

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.
sini added 2 commits July 31, 2026 10:47
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.
@sini sini changed the title fix: don't re-declare forward adapters in host-aspects spawns fix: three #603 regressions in custom forward classes 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 sini changed the title fix: three #603 regressions in custom forward classes fix: four silent-drop defects in forward classes and standalone homes Jul 31, 2026
@sini
sini enabled auto-merge (squash) July 31, 2026 19:59
@sini
sini disabled auto-merge July 31, 2026 20:59
@sini

sini commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

User confirmed fix addresses their regression, merging.

@sini
sini merged commit 2040b61 into main Jul 31, 2026
16 checks passed
@sini
sini deleted the fix/642-host-aspects-forward-duplicate branch July 31, 2026 20:59
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