Skip to content

fix: fan class-module entity args over scope descendants (#629) - #634

Merged
sini merged 1 commit into
mainfrom
fix/class-module-user-arg-fanout
Jul 18, 2026
Merged

fix: fan class-module entity args over scope descendants (#629)#634
sini merged 1 commit into
mainfrom
fix/class-module-user-arg-fanout

Conversation

@sini

@sini sini commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

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's nixos = { user, ... }: at host scope:

den.aspects.desktop.cosmic.nixos =
  { user, ... }:
  { services.displayManager.autoLogin.user = user.userName; };
den.aspects.void.includes = [ den.aspects.desktop.cosmic ];  # host has user `lucas`

The whole nixos module 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 host but no user, so wrapFunctionModule (class-module.nix) marks the module unsatisfied and wrap-classes.nix returns [ ] for it — dropped. The aspect-level form works because the bind handler fans the aspect over the scope's descendant entities (host.users); class-module function args never reached that fan-out.

(The lib.warn meant 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 bind handler then fans it per descendant instance via the exact same machinery the aspect-level { user, ... }: form uses — making the two forms equivalent.

  • Descendant classification (argClass.isDescendantOf), not mere ctx-absence: scope-kind-self and ancestor args stay on the static path (bind would only satisfy/inert them), so nixos = { host, ... }: and user-scope resolution are unchanged.
  • __parametricResolvedArgs guard: 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

  • New regression: 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-parametric all green.
  • Full suite: 1059/1059 successful.

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.
@sini
sini requested a review from vic as a code owner July 17, 2026 23:41
@github-actions github-actions Bot added the allow-ci allow all CI integration tests label Jul 17, 2026
@sini
sini enabled auto-merge (squash) July 18, 2026 00:34
@sini
sini merged commit 99cc0c5 into main Jul 18, 2026
31 of 38 checks passed
@sini
sini deleted the fix/class-module-user-arg-fanout branch July 18, 2026 00:37
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.
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.

can't request user inside an aspect's nixos class module

2 participants