Date: 2026-06-11
Status: Design — approved for planning
Target: den core (feat/delivered-child-host-lazy, off 4f317c5a which nix-config pins)
Unblocks: Task 4 of 2026-06-10-delivered-child-host-foundation-plan.md (cortex-cuda rewire)
extractSubtreeModules (den nix/lib/aspects/fx/resolve.nix ~215-254) builds a
host's class config by collecting perScope.<sid>.<targetClass> across the
host's entire scope subtree — with no awareness of entity boundaries. So
when a delivered child host (the cortex-cuda microvm guest) resolves as a child
scope, its nixos-class content — produced by reused fleet aspects
(roles.inference → ollama, hardware.gpu.nvidia, authored as nixos.*) — is
absorbed into the parent host's own nixos config. The guest's
microvm.guest.enable = true (valid only inside the microvm's own system) lands
on cortex's top-level → option 'microvm.guest' does not exist.
The guest's content was only ever meant to reach the parent at one path,
microvm.vms.<name>.config, via its explicit delivery route. It leaks
everywhere else because extraction is class-keyed but scope-blind.
emit-classes keys content by its authored key (nixos), ignoring the
scope's entity class (which is available in ctx). Reused nixos-authored
aspects therefore emit nixos-class content even inside a guest-os scope, and
extractSubtreeModules collects all nixos across the subtree indiscriminately.
Home-manager shows the intended contract — and refutes the naive fix. A
user's home config reaches the host only via a forward route producing
home-manager.users.<u> (nixos-class); extractSubtreeModules(nixos) legitimately
needs that. Crucially, verification found the forward appends at the user
(child) scope, and the host's extraction does cross into the user scope to
collect it. So a blanket "skip all child entities" boundary would break
home-manager.
The real discriminator is the entity's role, not whether it's a child:
- Compose entities (user, home): their forward-transformed content is meant
to become part of the host's config. They author no raw
nixos.*; their nixos bucket holds only the intended forwarded content. The parent must collect it. - Delivered/isolated entities (the guest): their content is their own system, reaching the host through one explicit route to a single path. Their nixos bucket holds raw system config that must never be absorbed.
No class remap (the guest's content is legitimately nixos — it's a
nixos-flavored guest). Fix scope awareness instead, with an explicit marker so
compose entities are untouched.
The delivered-guest kind declares its entities isolated/delivered (it already
sets intoAttr = [] and delivers via route; this names that intent). Mechanism,
mirroring the existing scopeEntityClass/scopeEntityKind plumbing:
- A kind-level schema property (e.g.
den.schema.<kind>.isolated = true), set by the delivered-guest kind inmodules/policies/delivered-child-host.nix. push-scoperecords it in ascopeIsolatedmap{ scopeId → bool }when a scope of that kind is created (alongsidescopeEntityClass).- The map is threaded to
applyInstantiatesviaresult.state(exactly asscopeEntityClassalready is at resolve.nix ~737).
The host's generic instantiate-time extraction skips subtree scopes marked isolated:
subtreeScopes' = filter (sid: !(scopeIsolated.${sid} or false)) subtreeScopes
→ the guest's raw nixos never enters cortex's own config. Compose entities
(user/home — not isolated) are still collected → home-manager unaffected.
Blocker found in spec review, resolved here. The guest's reused content is in
the nixos class (no remap), so the delivery route must collect
fromClass = "nixos" scoped to the guest subtree and land it at the host
scope's microvm.vms.<name>.config. But today route uses a single
sourceScopeId for both the collection root and the append target
(route/apply.nix:181 collects at sourceScopeId; :231 appends at the same
sourceScopeId). So:
- source at the host → collection grabs the host's own nixos too (wrong);
- source at the guest → the append lands at the (isolated, skipped) guest scope → the delivered content is dropped.
Neither achieves "delivered once." (This is exactly why the shipped primitive
used a distinct guest-os class — scope-blind collection was safe because the
host owns no guest-os. But that class only carries content authored as
guest-os; it never carried the reused nixos fleet aspects, which is the whole
problem.)
The real fix — a den-core route enhancement: decouple the collection root
from the append target. Add a collectScopeId (or fromScopeId) to the route
spec, distinct from the append scope. The delivery route then:
collectScopeId = <guest scope>,fromClass = "nixos",collectSubtree = true→ gathers the guest subtree's nixos (and only the guest's, since it's rooted at the guest, not the host);- appends the nested result at the host scope at
path = ["microvm" "vms" "<name>" "config"]→ the host's (non-isolated) extraction picks it up.
This makes the guest-os class unnecessary — the guest authors everything as
ordinary nixos (honest: it's a nixos guest), the decoupled route carries it, and
isolation (§2) prevents the parallel leak. Fully consistent with "no class remap,
fix scope awareness" — the scope awareness is fixed at both the extraction and
the route.
The leak must close at both subtree-collection call sites, not just
extractSubtreeModules. Under no-remap, any other fromClass = "nixos"
collectSubtree route rooted at/above the host would otherwise pull the isolated
guest's nixos (the class no longer discriminates). So collectFromSubtree
(route/apply.nix ~147-163) applies the same rule, with one nuance:
Skip isolated descendant scopes; always include the collection root.
- Other routes (root = host) → skip the isolated guest descendant → no accidental pull.
- The guest's own delivery route (root = guest) → the guest is the root → its content is collected → delivered. Only nested isolated entities (guest-under-guest) are skipped.
extractSubtreeModules is the same rule with root = host (the host is never
isolated, so "skip isolated descendants" == "skip the guest").
- Surgical, scope-correct: one marker + one guard in extraction; the explicit route is the only path across the isolation boundary. Matches the maintainer's framing ("the guest is a different scope; its content shouldn't leak; fix scope awareness, don't change the class").
- Compose entities untouched: the marker is opt-in; user/home aren't isolated, so home-manager's append-at-user-scope + cross-boundary collection still works.
- Generalizes: any future "delivered" entity (not just microvm guests) sets the marker and is isolated for free.
- No class churn: content keeps its honest
nixosclass; the guest re-instantiates as nixos at the microvm boundary, reading the delivered static config.
- Guest isolation: a host with a delivered child whose content includes a
guest-only option (e.g.
microvm.guest.enable) — the parent's toplevel evaluates with no leak (the option does not appear on / break the parent), and the content does appear at…microvm.vms.<name>.config. - Home-manager intact (the load-bearing regression): a host with a
home-manager user still gets
home-manager.users.<u>in its nixos — proving compose entities are still collected across their (non-isolated) boundary. - Delivery carries reused nixos aspects: a delivered guest composed from a
shared
nixos-authored aspect (mirroringroles.inference) lands that content at…microvm.vms.<name>.config(confirms thefromClass = nixosroute). - Full den CI green (the current 909/909 + the new isolation tests); no regression in any existing extraction/route/home test.
- Marker name + level.
den.schema.<kind>.isolated, read inpush-scopedirectly fromden.schema.${entityKind}.isolated(push-scope already hasdenandentityKindin scope — no new schema-param plumbing needed). Simplest, and matches howisEntity/parentare already read. - Route decoupling shape. The exact spec field (
collectScopeId/fromScopeId) and how the delivered-guest policy supplies the guest scopeId (it fires at host scope with the guest's raw binding — it must derive the guest'smkScopeId, or the route effect resolves it). Confirm collection rooted at the guest gathers only the guest subtree and the append lands at the host. - Dual-call-site isolation rule. Implement "skip isolated descendants,
include the root" once and apply at both
extractSubtreeModulesandcollectFromSubtree. Confirm no other extraction site (hostConfigsreusesmkInstantiateArgs— covered;spawn-nodeuses routes — covered via collectFromSubtree) is missed. - Nested isolated entities (guest-under-guest) — confirm the "include root, skip isolated descendants" rule degrades sanely.
Core change touches: push-scope (record scopeIsolated from
den.schema.<kind>.isolated); resolve.nix (extractSubtreeModules skip +
thread the map through mkInstantiateArgs, alongside the existing
scopeEntityClass); **route/apply.nix (decouple collect-root from append-target
- make
collectFromSubtreeskip isolated descendants)**; and the delivered-guest kind (setisolated = true, drop the now-unnecessaryguest-osclass, point the delivery route'scollectScopeIdat the guest withfromClass = "nixos"). Compose-entity paths (user/home/forward) are deliberately untouched — they're not isolated, and the route decoupling is additive (existing routes that omitcollectScopeIddefault to today's behavior). Validated against full den CI + the home-manager regression test before nix-config consumes it.
Note on "no class remap" (spec-review reconciliation). The shipped primitive leaned on the
guest-osclass to make scope-blind collection safe. This design removes that reliance the right way — by teaching the two subtree-collection sites about entity isolation and decoupling the route's collect/append scopes — rather than relabeling user-authorednixoscontent. The class becomes redundant and is dropped; content keeps its honestnixosidentity.