Skip to content

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.

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: use v.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)

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" ← user

Pipeline state fields fall into three categories:

Per-scope partitions keyed by scope ID. Emission handlers write to scopedX.${state.currentScope}:

FieldContents
scopedClassImportsClass modules emitted per scope, keyed by class name
scopedAspectPoliciesPolicies discovered during tree walk
scopedDeferredIncludesIncludes deferred until context widens
scopedConstraintRegistryConstraint predicates per scope
scopedConstraintFiltersConstraint filter functions per scope
scopedIncludesChainInclude nesting depth tracking
scopedRoutespolicy.route specs per scope
scopedInstantiatespolicy.instantiate specs per scope

Shared maps with scope-prefixed keys for dedup:

FieldKey formatPurpose
seen${scopeId}/${ctxKey}Context-aware emission dedup
includeSeen${scopeId}/${identity}Include dedup per scope
pathSet${scopeId}/${path}Per-scope aspect tracking

Pipeline-wide values:

FieldPurpose
currentScopeActive scope ID — controls which partition receives emissions
classTarget class name (immutable after init)
scopeParentChild-to-parent scope map
scopeContextsScope-to-context map

When a policy.resolve effect fires, the pipeline creates a new scope via resolve-schema-entity:

  1. Enrich context — merge the resolve effect’s bindings into current context
  2. Compute scope IDchildScopeId = mkScopeId enrichedCtx
  3. Dedup check — skip if childScopeId already exists in scopeContexts
  4. Record tree — set scopeParent, scopeContexts, and currentScope
  5. Install handlersscope.provide installs entity context handlers for the child scope
  6. Walk entity — resolve the entity’s aspect tree; all emissions write to scopedX.${childScopeId}
  7. Dispatch policiesinstallPolicies fires any policies whose args are now satisfied, potentially creating recursive child scopes
  8. Restorescope.provide returns, reverting handlers; currentScope restores to parent

Two mechanisms must stay synchronized during scope transitions:

  • scope.provide — controls which context handlers are visible to parametric aspect resolution
  • state.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.

After the walk completes, fxResolve composes the final output from scope-partitioned state:

  1. 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.

  2. Pipe assemblyassemblePipes processes quirk data: bottom-up expose collection, per-scope pipe effect application, context enrichment with assembled pipe data.

  3. Route applicationpolicy.route specs read source content from scope partitions and inject into target classes.

  4. Per-host extractionapplyInstantiates filters shared state to each host’s subtree (host + descendants + ancestors), re-runs wrapping and routing with the host as root scope.

  5. Instantiation — each extracted subtree is passed to lib.nixosSystem / darwinSystem / homeManagerConfiguration.

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.

Contribute Community Sponsor