Scope Partitioning
Every entity in the pipeline gets its own scope. Scopes partition pipeline state so that emissions from one entity don’t contaminate another. This is what makes multi-host resolution work in a single pipeline run.
Scope identity: mkScopeId
Section titled “Scope identity: mkScopeId”Each scope has a deterministic string ID computed from its context attrset.
The format is comma-separated key=value pairs, sorted lexicographically:
{ host = { name = "igloo"; }; user = { name = "tux"; }; } → "host=igloo,user=tux"Value extraction:
- Attrsets with
name: usev.name - Strings: use directly
- Anything else:
<type:key>(e.g.,<int:priority>)
Injectivity: separator characters (=, ,, <, >) cannot appear in
Nix identifiers, so distinct contexts always produce distinct scope IDs.
Edge cases:
- Empty context
{}→""(root scope) - Single key →
"host=igloo"(no commas)
The scope tree
Section titled “The scope tree”Scopes form a tree tracked by two flat maps:
scopeParent — child-to-parent mapping:
{ "host=igloo" = ""; # parent is root "host=igloo,user=tux" = "host=igloo"; # parent is host "host=igloo,user=pingu" = "host=igloo";}scopeContexts — scope-to-context mapping:
{ "" = {}; "host=igloo" = { host = { name = "igloo"; ... }; }; "host=igloo,user=tux" = { host = ...; user = { name = "tux"; ... }; };}Both grow as the pipeline creates new scopes. The tree shape comes from
policy-driven context expansion — host-to-users creates child scopes
under each host scope.
"" ← root (flake)├── "system=x86_64-linux" ← flake-system│ ├── "host=igloo" ← host│ │ ├── "host=igloo,user=tux" ← user│ │ └── "host=igloo,user=pingu" ← user│ └── "host=server" ← host│ └── "host=server,user=admin" ← userScope-partitioned state
Section titled “Scope-partitioned state”Pipeline state fields fall into three categories:
Scoped fields
Section titled “Scoped fields”Per-scope partitions keyed by scope ID. Emission handlers write to
scopedX.${state.currentScope}:
| Field | Contents |
|---|---|
scopedClassImports | Class modules emitted per scope, keyed by class name |
scopedAspectPolicies | Policies discovered during tree walk |
scopedDeferredIncludes | Includes deferred until context widens |
scopedConstraintRegistry | Constraint predicates per scope |
scopedConstraintFilters | Constraint filter functions per scope |
scopedIncludesChain | Include nesting depth tracking |
scopedRoutes | policy.route specs per scope |
scopedInstantiates | policy.instantiate specs per scope |
Prefixed fields
Section titled “Prefixed fields”Shared maps with scope-prefixed keys for dedup:
| Field | Key format | Purpose |
|---|---|---|
seen | ${scopeId}/${ctxKey} | Context-aware emission dedup |
includeSeen | ${scopeId}/${identity} | Include dedup per scope |
pathSet | ${scopeId}/${path} | Per-scope aspect tracking |
Global fields
Section titled “Global fields”Pipeline-wide values:
| Field | Purpose |
|---|---|
currentScope | Active scope ID — controls which partition receives emissions |
class | Target class name (immutable after init) |
scopeParent | Child-to-parent scope map |
scopeContexts | Scope-to-context map |
Scope transitions
Section titled “Scope transitions”When a policy.resolve effect fires, the pipeline creates a new scope
via resolve-schema-entity:
- Enrich context — merge the resolve effect’s bindings into current context
- Compute scope ID —
childScopeId = mkScopeId enrichedCtx - Dedup check — skip if
childScopeIdalready exists inscopeContexts - Record tree — set
scopeParent,scopeContexts, andcurrentScope - Install handlers —
scope.provideinstalls entity context handlers for the child scope - Walk entity — resolve the entity’s aspect tree; all emissions write
to
scopedX.${childScopeId} - Dispatch policies —
installPoliciesfires any policies whose args are now satisfied, potentially creating recursive child scopes - Restore —
scope.providereturns, reverting handlers;currentScoperestores to parent
The sync invariant
Section titled “The sync invariant”Two mechanisms must stay synchronized during scope transitions:
scope.provide— controls which context handlers are visible to parametric aspect resolutionstate.currentScope— controls which partition emissions write to
If these diverge, emissions land in the wrong scope or aspects resolve
against the wrong context. Both are set in the same fx.bind chain,
and scope.provide’s lexical scoping guarantees automatic handler
cleanup.
Sequential sibling processing: within a scope, siblings (e.g., multiple users of a host) are processed sequentially in the bind chain. Nix’s strict evaluation prevents interleaving. Scope set/restore for sibling A completes before sibling B begins.
Post-pipeline composition
Section titled “Post-pipeline composition”After the walk completes, fxResolve composes the final output from
scope-partitioned state:
-
Per-scope wrapping — each scope’s class imports are wrapped with that scope’s entity context via
wrapClassModule. This pre-applies den args (host,user) so the module system only sees its own args. -
Pipe assembly —
assemblePipesprocesses quirk data: bottom-up expose collection, per-scope pipe effect application, context enrichment with assembled pipe data. -
Route application —
policy.routespecs read source content from scope partitions and inject into target classes. -
Per-host extraction —
applyInstantiatesfilters shared state to each host’s subtree (host + descendants + ancestors), re-runs wrapping and routing with the host as root scope. -
Instantiation — each extracted subtree is passed to
lib.nixosSystem/darwinSystem/homeManagerConfiguration.
Why ancestors are included
Section titled “Why ancestors are included”During per-host extraction, ancestor scopes (up to root) are included
because routes and provides registered at flake-system level must
remain visible to host-level resolution. Without ancestors, routes from
system-level batteries would be lost.
See also
Section titled “See also”- Fleets & Multi-Host — user-facing multi-host patterns
- Resolution Pipeline — pipeline overview
- Policy Activation — how dispatch and enrichment work
- ABC on Den Effects — the algebraic effects foundation