Skip to content

Instantly share code, notes, and snippets.

@sini
Created May 14, 2026 21:13
Show Gist options
  • Select an option

  • Save sini/e326de73fca4704d663336eae86e546c to your computer and use it in GitHub Desktop.

Select an option

Save sini/e326de73fca4704d663336eae86e546c to your computer and use it in GitHub Desktop.
Den FX Pipeline Reference — handlers, effects, scope management, routing, dedup, policy dispatch

Den FX Pipeline Reference

Den's FX pipeline is the core resolution engine that transforms declarative aspect definitions into NixOS/nix-darwin/home-manager module trees. It operates as an algebraic effects trampoline -- a loop that interprets effect values, dispatches them to handlers, and threads state through each step. Every state change is an effect; pure data transforms stay as functions.

This document covers the pipeline lifecycle from entity entry through final output assembly.

1. Pipeline Overview

The pipeline resolves a single entity (flake, host, home, user) by walking its aspect tree. Each aspect is compiled according to its shape, gated for dedup and constraints, classified into output buckets, emitted as class modules, and then its children (includes, policies) are recursively resolved. The result is a set of NixOS-style module lists keyed by class name.

flowchart TD
    Entry["Entity Entry<br/>(resolveEntity)"] --> mkPipeline
    mkPipeline --> Resolve["resolve effect"]
    Resolve --> Compile["compile (shape router)"]
    Compile --> Gate["gate (dedup + constraint)"]
    Gate --> Classify["classify keys"]
    Classify --> Emit["emit-classes"]
    Emit --> Children["resolve-children"]
    Children --> Policies["installPolicies"]
    Policies --> SchemaResolve["Schema entity resolution<br/>(push-scope, recurse)"]
    SchemaResolve --> PostPipeline["Post-pipeline assembly"]
    PostPipeline --> Output["{ imports = [...]; }"]
Loading

Key properties:

  • Scope-partitioned state -- all collected modules, policies, routes, etc. are bucketed by scope ID, enabling per-entity subtree extraction
  • Dedup by identity -- aspects are identified by provider/name path keys; duplicates within a scope are skipped
  • Lazy evaluation -- state fields wrapped as thunks (_: value) survive builtins.deepSeq at each trampoline step without re-materializing large attrsets

2. Entry Points

Entity resolution (resolve-entity.nix)

Entities enter through den.lib.resolveEntity, called from modules/outputs.nix. An entity is a structural node (flake, host, home, user) that carries:

  • name -- the entity kind (e.g., "host", "home")
  • includes -- self-provide wrapper + schema includes
  • excludes -- schema-level excludes
  • __entityKind -- marks this as an entity root (triggers policy dispatch)
  • __scopeHandlers -- constant handlers from augmented context

For entity kinds with .aspect on their schema entry (host, home, user), a parametric self-provide is injected: { __fn = c: c.${name}.aspect; __args = { ${name} = false; }; }. This defers the entity's own aspect resolution until its scope handlers are established.

mkPipeline (pipeline.nix)

mkPipeline constructs the pipeline for one entity resolution:

  1. Creates a root scope ID from the initial context (mkScopeId ctx)
  2. Composes default handlers with any extra handlers
  3. Initializes default state with the root scope
  4. Sends the initial resolve effect and runs fx.handle -- the trampoline loop
# Simplified mkPipeline flow
bootstrapAndResolve = fx.send "resolve" {
  aspect = self;
  identity = identity.key self;
  ctx = ctx;
  gated = true;
};
fx.handle { handlers; state; } bootstrapAndResolve;

Flake-level resolution (modules/outputs.nix)

The flake pipeline entry resolves the "flake" entity, which triggers the to-systems policy to fan out per system, then to-os-outputs / to-hm-outputs to resolve hosts and homes.

3. Aspect Shapes and the Compile Router

The compile handler (handlers/compile.nix) inspects aspect metadata to determine shape:

Shape Detection Handler Purpose
Forward meta.__forward present compile-forward Cross-class module routing
Conditional meta.guard present compile-conditional Guard-gated includes
Parametric __args != {} compile-parametric Scope-dependent resolution
Static None of the above compile-static Standard aspect with class keys

Static (compile-static.nix)

The most common shape. Steps:

  1. Gate -- dedup + constraint check (skipped if gated = true)
  2. Classify -- partition keys into class, nested, pipe
  3. Emit-classes -- send class modules to collector
  4. Register constraints -- record aspect excludes
  5. Resolve-children -- recurse into includes + policies

Parametric (compile-parametric.nix)

Aspects with __args (scope-dependent functions). Steps:

  1. Gate check
  2. Bind -- probe scope handlers for required args
    • If all args available: call compileFn to resolve the function, producing a new aspect
    • If args missing: defer for later resolution
  3. If bound: re-enter pipeline via resolve with the result (re-routes through compile router)

Depth limit: 10 levels of parametric nesting.

Forward (compile-forward.nix)

Forwards move modules between classes. They bypass dedup and constraints entirely.

  • Tier 1 (simple): source already collected, no adapter needed -- becomes a route spec { fromClass, intoClass, path }
  • Complex: full forward spec stored with __complexForward marker

Resumes [] -- all work happens via route registration in state.

Conditional (compile-conditional.nix)

Guard-gated aspect includes. The guard function receives a context with hasAspect -- an exclude-aware function that checks both the pipeline's pathSet and the constraint registry.

  • Guard passes: emit the guarded aspects as includes
  • Guard fails: defer the conditional for re-evaluation when the pathSet grows (via drain-conditionals)

4. Gate Phase

The gate (handlers/gate.nix) is a composite effect combining dedup and constraint checks.

Dedup (check-dedup.nix)

Identity-based dedup within a scope:

  • Dedup key: "${scopeId}/${identityKey}" where identityKey = identity.key aspect
  • Anonymous and synthetic names (wrapped in <>) are never deduped
  • The includeSeen thunk in state tracks seen keys

Constraints (constraint.nix)

The constraint registry supports three types:

  • exclude -- blocks an aspect by identity (prefix-matched)
  • substitute -- replaces an aspect with an alternative
  • filter -- predicate-based exclusion

Constraints are scoped via ownerChain (the includes chain at registration time). A constraint applies if (scope == "global") || isAncestor(ownerChain, currentChain).

Gate outcomes:

  • { blocked = true; result = [...] } -- aspect excluded or substituted (tombstone emitted)
  • { passed = true; owner? } -- aspect passes, optionally tagged with constraint owner

5. Key Classification

The classify handler (handlers/classify.nix) delegates to classifyKeys, which partitions an aspect's attribute keys into three buckets:

Bucket Condition Example
classKeys Key matches a registered class (den.classes) or an unregistered class key nixos, darwin, homeManager
nestedKeys Key is provides, _, or a sub-aspect namespace _, provides
pipeKeys Key matches a registered quirk (den.quirks) packages, checks

Structural keys (name, meta, includes, __args, __fn, __scopeHandlers, etc.) are excluded from classification.

6. Class Module Emission

emit-classes (handlers/emit-classes.nix)

Iterates class keys and pipe keys, unwrapping content values and sending individual emit-class effects:

aspect.nixos = { networking.hostName = "test"; };
  --> unwrapContentValuesList --> [ module ]
  --> emit-class { class = "nixos"; identity = "myAspect"; module; ctx; ... }

Each class key may contain multiple modules (list-valued content). Multi-module entries get indexed identities: "myAspect[0]", "myAspect[1]".

class-collector (handlers/class-collector.nix)

The emit-class handler collects modules into scope-partitioned state:

  • Location key: "${class}@${baseIdentity}" (e.g., "nixos@networking/hostname")
  • Dedup: scopedEmittedLocs tracks emitted locations per scope; duplicate locations are silently dropped
  • Storage: scopedClassImports -- { scopeId -> { class -> [ module ] } }

Context-dependent aspects (those resolved through parametric binding with context args) preserve their full identity including context suffix, preventing incorrect dedup across scopes.

7. Children Resolution

resolve-children (handlers/resolve-children.nix) is where the tree walk recurses. After an aspect's own class modules are emitted, it processes:

  1. chain-push -- push aspect identity onto the includes chain (for constraint scoping)
  2. emitAspectPolicies -- register self-provide policies from the aspect's provides/_ namespace
  3. emitIncludes -- resolve each child in aspect.includes
  4. installPolicies -- if this is an entity root (__entityKind present), dispatch entity-level policies
  5. drain-conditionals -- at entity boundaries, re-evaluate deferred guards
  6. chain-pop + resolve-complete -- unwind chain and record the resolved aspect in pathSet
flowchart TD
    RC["resolve-children"] --> ChainPush["chain-push"]
    ChainPush --> EmitPolicies["emitAspectPolicies<br/>(provides)"]
    EmitPolicies --> EmitIncludes["emitIncludes<br/>(each child)"]
    EmitIncludes --> EntityCheck{Entity root?}
    EntityCheck -->|Yes| InstallPolicies["installPolicies"]
    EntityCheck -->|No| DrainCheck
    InstallPolicies --> DrainCheck{Drain conditionals?}
    DrainCheck -->|Yes| Drain["drain-conditionals"]
    DrainCheck -->|No| Complete["resolve-complete"]
    Drain --> Complete
Loading

8. Scope Management

Scopes partition the pipeline's state by entity context. Each entity resolution creates a new scope.

Scope tree

flowchart TD
    Flake["flake<br/>scope: ''"] --> FlakeSystem["flake-system<br/>scope: system=x86_64-linux"]
    FlakeSystem --> Host1["host: igloo<br/>scope: host=igloo,system=x86_64-linux"]
    FlakeSystem --> Host2["host: thinkpad<br/>scope: host=thinkpad,system=x86_64-linux"]
    Host1 --> User1["user: tux<br/>scope: host=igloo,system=x86_64-linux,user=tux"]
    Host2 --> User2["user: tux<br/>scope: host=thinkpad,system=x86_64-linux,user=tux"]
Loading

Scope ID generation

mkScopeId ctx produces a canonical comma-separated "key=value" string from the context attrset, sorted by key. Example: "host=igloo,system=x86_64-linux".

push-scope (handlers/push-scope.nix)

Atomically updates state when entering a child entity's scope:

  • Sets currentScope to the new scope ID
  • Records context in scopeContexts
  • Records parent in scopeParent
  • Initializes empty scopedAspectPolicies for the new scope
  • Resets inLateDispatch (each scope level gets its own late-dispatch opportunity)
  • Fans out any scopedDeferredIncludes from the parent scope

restore-scope (handlers/restore-scope.nix)

Pops back to the parent scope after entity resolution completes. Restores currentScope and inLateDispatch from a stack.

resolve-schema-entity (handlers/resolve-schema-entity.nix)

Orchestrates the full scope transition for entity resolution:

  1. push-scope -- create child scope with entity context
  2. scope.provide -- install scope handlers
  3. resolve-entity -- build entity node from schema
  4. resolve -- walk the entity's aspect tree
  5. drain -- resolve deferred includes now satisfiable with the new context
  6. propagate-routes -- propagate forward routes from child scope
  7. restore-scope -- return to parent scope

9. Policy Dispatch

Policies are user-defined functions that fire when their argument signature is satisfied by the current scope context. They produce typed effects that drive entity creation, module routing, and content delivery.

installPolicies (policy/default.nix)

Entry point at entity boundaries. Steps:

  1. Read current state (scope, context, aspect policies)
  2. Check dedup (dispatchKey = "${entityKind}@${scope}")
  3. Call iterate with aspect policies and context

Fixed-point iteration (policy/iterate.nix)

flowchart TD
    Start["iterate"] --> Dispatch["dispatch-policies"]
    Dispatch --> Check{"New enrichment<br/>keys?"}
    Check -->|Yes| Widen["widen-context"]
    Widen --> Dispatch
    Check -->|No| RecordFired["record-fired"]
    RecordFired --> EmitEffects["emit-policy-effects"]
    EmitEffects --> Done["results"]
Loading

Enrichment is key-monotonic -- keys are only added, never changed. Convergence is guaranteed because each iteration adds at least one new key (or terminates). Max iterations: 10.

dispatch (policy/dispatch.nix)

Runs each aspect policy against the resolve context:

  • Filters to policies whose args are satisfied by context AND haven't already fired
  • Calls policy function, validates returned effects
  • Classifies results via classifyPolicyResult
  • Returns tagged effects + enrichment + fired names

classify (policy/classify.nix)

Partitions each policy's effects by type:

  • resolve effects are further split into schema (entity-kind keys like host, user) and enrichment (other keys)
  • include, exclude, route, instantiate, provide, pipe effects pass through tagged with source policy name
  • Cross-provider detection: when a policy produces both schema resolves and includes, includes are attached to the schema entity for scoped resolution

10. Policy Effects

Effect Builder Purpose
resolve den.lib.policy.resolve.to Create child entity scope (host, user, home)
include den.lib.policy.include Add aspects to current entity
exclude den.lib.policy.exclude Block an aspect by identity
route den.lib.policy.route Move modules between classes
instantiate den.lib.policy.instantiate Evaluate modules into flake outputs
provide den.lib.policy.provide Inject modules into target classes
pipe den.lib.policy.pipe Register pipe transform/collect stages

Effect application order (policy/apply.nix)

Effects are applied in a specific order via emitPolicyEffectsThen:

  1. Excludes first -- register constraints before any includes are processed
  2. Routes -- register route specs
  3. Instantiates -- register entity instantiation specs
  4. Provides -- register provide specs
  5. Pipe effects -- register pipe transform stages
  6. Then: schema resolves + includes (the continuation)

This ordering ensures guards in conditional aspects see excludes before evaluating.

Schema entity resolution (policy/schema.nix)

processSchemaResolves handles resolve.to effects:

  • Decomposes each schema effect into target kind, bindings, scoped context, entity class
  • Checks ctx-seen for dedup (prevents re-resolving same entity)
  • First visit: sends resolve-schema-entity to create child scope and walk
  • Repeat visit with new aspects: sends supplemental resolution for just the new aspects
  • Late dispatch: after all sibling entities resolve, re-dispatches policies registered by later siblings that may apply to earlier ones

11. Deferred Handling

Deferral handles aspects whose required scope arguments aren't yet available.

flowchart TD
    Bind["bind: probe args"] --> Available{"All args<br/>available?"}
    Available -->|Yes| CompileFn["compileFn(aspect)"]
    Available -->|No| Defer["defer: store in<br/>scopedDeferredIncludes"]
    Defer --> Stub["emit resolve-complete<br/>stub (deferred marker)"]

    subgraph "Later: context expands"
        Widen["scope-widened"] --> Drain["drain: partition<br/>by satisfiability"]
        Drain --> Satisfiable["Re-resolve<br/>satisfiable aspects"]
        Drain --> Remaining["Keep remaining<br/>deferred"]
    end
Loading

bind (handlers/bind.nix)

Probes for required args using fx.effects.hasHandler:

  • Checks scope handlers on the aspect
  • Falls back to scope context from pipeline state (for child scopes)
  • Detects pipe arg references (unconditionally defers -- pipe data is post-pipeline)
  • Augments __scopeHandlers with available scope context values

defer (handlers/defer.nix)

Stores the deferred aspect in scopedDeferredIncludes and emits a stub with meta.deferred = true.

drain (handlers/drain.nix)

Partitions deferred includes by whether their required args are satisfied by the current context. Returns satisfiable items; remaining stay in state.

scope-widen (handlers/scope-widen.nix)

Triggered by widen-context during policy enrichment. Drains newly-satisfiable deferred includes and re-resolves them.

Post-pipeline drain (resolve.nix)

A final drain pass in fxResolve handles:

  1. Pipe-arg deferred -- aspects needing pipe data, now available from assemblePipes
  2. Enrichment-deferred -- aspects needing parent enrichment keys, resolved by walking scopeParent to inherit ancestor context

12. Conditional Guards

Conditionals (compile-conditional.nix) use an exclude-aware hasAspect that checks both the pathSet and constraint registry. This prevents guards from seeing aspects that have been excluded by policy.

Guard evaluation

guardCtx = mkGuardCtx {
  pathSet;              -- all resolved aspect identities so far
  constraintRegistry;   -- scope-specific excludes (collected from ancestors)
  scopeHandlers;        -- provides entity stubs: { host.hasAspect = ...; }
};
pass = condNode.meta.guard guardCtx;

Fixed-point drain

drain-conditionals runs at entity boundaries with fixed-point iteration:

  1. Re-evaluate all deferred conditionals against current pathSet
  2. If any pass: emit their includes, mark as progressed, loop
  3. If none progress: tombstone the remaining guards (convergence -- guard dependencies can't be satisfied)
  4. Convergence guaranteed: each progressing pass resolves at least one guard

13. Route Application

Routes move modules between classes (e.g., from packages class to flake class at a specific path). Applied post-pipeline in phase 3 of resolve.nix.

Route types

  • Simple routes (Tier 1 forwards): { fromClass, intoClass, path, guard?, adaptArgs? } -- nest source modules at the target path
  • Complex routes (__complexForward): full forward specs with mapModule, evalConfig, adapter support

Application flow (route/apply.nix)

For each route:

  1. Collect source modules from scopedClassImports[sourceScopeId][fromClass]
  2. If no modules collected: resolve source aspect as fallback
  3. Wrap modules with any adapter or guard
  4. Append wrapped modules to target class at target scope

14. Pipe Assembly

Pipes (den.quirks) collect typed data from aspects during the pipeline walk, then assemble it post-pipeline into scope contexts for delivery to modules.

During pipeline

  • Pipe keys on aspects are emitted via emit-class with __isPipeEntry = true
  • Policy pipe effects register stages in scopedPipeEffects

Post-pipeline (assemble-pipes.nix)

assemblePipes processes each scope:

  1. Collect base values from scopedClassImports[scope][pipeName]
  2. Merge exposed data from child scopes (via pipe.expose stages, collected bottom-up)
  3. Apply untargeted effects: filter, transform, fold, append, for, collect stages
  4. Build targeted data (pipe.to): transform and deliver to specific aspects via __pipeTargeted
  5. Process pipe.as: rename pipe data across pipe boundaries
  6. Resolve parametric values: { host, ... }: expr resolved against scope context
  7. Mark config thunks: { config, ... }: expr marked for deferred resolution inside evalModules
  8. Inject assembled pipe data into scope contexts

15. Output Assembly

fxResolve (resolve.nix) orchestrates four post-pipeline phases:

Phase 1: wrapPerScope

Wraps raw class imports per scope using wrapCollectedClasses:

  • Merges enrichment into emit-time context
  • Strips enrichment-only args from module function signatures (prevents NixOS probing for unknown _module.args)
  • Deduplicates keyed modules across scopes (first occurrence wins)

Phase 2: applyProvides

Injects policy.provide modules into target classes:

  • Deduplicates by composite key (policyName/class/path)
  • Wraps modules with class module wrapping (collision policy, location)

Phase 3: applyRoutes

Applies all registered routes (see section 13).

Phase 4: applyInstantiates

Creates flake outputs from entity specifications:

  • Finds the host scope ID by searching scopeParent children
  • Per-host subtree assembly: re-runs phases 1-3 scoped to just the host's subtree, producing correct routing without cross-host contamination
  • Calls spec.instantiate { modules; pkgs?; } to produce the evaluated configuration
  • Merges instantiate outputs into classImports.flake via recursiveUpdate

Final result

{
  imports = phase4.${class} or [];  # e.g., phase4.nixos
}

Appendix: State Fields

Global (unscoped)

Field Type Purpose
seen thunk { } General seen tracking
pathSet thunk { identity -> true } All resolved aspect identities (for hasAspect)
includeSeen thunk { dedupKey -> true } Dedup tracking for includes
flatConstraintRegistry { identity -> [entry] } Pre-merged constraint lookup (avoids O(S) rebuild)
flatConstraintFilters [entry] Pre-merged filter constraints
flatAspectPolicies { name -> policy } Pre-merged policy lookup

Scope-partitioned

Field Type Purpose
scopedClassImports thunk { scope -> { class -> [mod] } } Collected class modules
scopedAspectPolicies thunk { scope -> { name -> policy } } Registered aspect policies
scopedDeferredIncludes thunk { scope -> [deferred] } Pending parametric aspects
scopedDeferredConditionals thunk { scope -> [conditional] } Pending guard evaluations
scopedConstraintRegistry thunk { scope -> { identity -> [entry] } } Per-scope constraints
scopedRoutes thunk { scope -> [routeSpec] } Registered routes
scopedInstantiates thunk { scope -> [instantiateSpec] } Entity instantiation specs
scopedProvides thunk { scope -> [provideSpec] } Provide specs
scopedPipeEffects thunk { scope -> [pipeEffect] } Pipe transform stages
scopedEmittedLocs thunk { scope -> { loc -> true } } Class collector dedup

Scope tree

Field Type Purpose
rootScopeId string Root scope identity
currentScope string Currently active scope
scopeContexts thunk { scope -> ctx } Context attrset per scope
scopeParent thunk { scope -> parentScope } Parent scope mapping

Policy tracking

Field Type Purpose
firedPolicyNames thunk { dispatchKey -> { name -> policyFired } } Which policies fired where
dispatchedPolicies thunk { dispatchKey -> true } Prevents re-dispatching same entity kind at same scope
inLateDispatch bool Per-scope late-dispatch flag (prevents O(N^2) re-dispatch)

Appendix: Identity System

Aspect identity (identity.nix) is a path-based key:

identity.key aspect = concatStringsSep "/" (provider ++ [name] ++ optional ctxId)

Examples:

  • "networking/hostname" -- simple aspect
  • "networking/hostname/{host=igloo,system=x86_64-linux}" -- context-qualified
  • "~networking/hostname" -- tombstone (excluded aspect)

The pathSet tracks all resolved identities. hasAspect checks this set, minus excluded entries from the constraint registry.

Appendix: Effect Handler API Surface

Complete reference for all pipeline effects — their parameters, resume values, and state mutations.

Core pipeline effects

Effect Param Resume State
resolve { aspect, identity, ctx, gated? } forwarded from compile
compile { aspect, identity, ctx } routed to shape handler
compile-static { aspect } [resolved] class emissions via emit-class
compile-parametric { aspect, compileFn } { value } or { deferred }
compile-forward { aspect } [] route in scopedRoutes
compile-conditional { aspect } includes result or [] (deferred) conditional in scopedDeferredConditionals if guard fails
gate { aspect, identity } { passed } or { blocked, result } dedup in includeSeen

Classification and emission

Effect Param Resume State
classify { aspect, targetClass } { classKeys, nestedKeys, pipeKeys }
emit-classes { aspect, classKeys, pipeKeys, identity } seq result via emit-class
emit-class { class, identity, module, ctx, ... } null scopedClassImports, scopedEmittedLocs

Children and scope

Effect Param Resume State
resolve-children { aspect, isMeaningful, chainIdentity } resolved aspect drain-conditionals at entity level
resolve-complete resolved aspect param passthrough pathSet updated (excluded aspects removed)
emit-include { child, __parentScopeHandlers?, ... } include results
push-scope { scopedCtx, entityClass, parentScope } { scopeId, scopeHandlers } scope stack, scopeContexts, scopeParent
restore-scope { parentScope } null currentScope restored
resolve-schema-entity { targetKind, scopedCtx, entityClass, ... } results list full scope lifecycle
resolve-entity { kind } entity record — (reads den.schema)

Policy dispatch

Effect Param Resume State
dispatch-policies { aspectPolicies, firedPolicies, resolveCtx } classified effects
emit-policy-effects { effects, entityKind, enrichedCtx } include/route results constraints, routes registered
record-fired { entityKind, firedPolicies } null firedPolicyNames
widen-context { enrichment, currentCtx } null scopeContexts updated

Constraints

Effect Param Resume State
register-constraint { type, scope, identity, owner } null scopedConstraintRegistry, flatConstraintRegistry
check-constraint { identity, aspect } { action, owner? }
check-dedup aspect { isDuplicate, dedupKey } includeSeen

Deferred handling

Effect Param Resume State
bind { aspect, compileFn } { value } or { deferred }
defer { child, requiredKeys, requiredArgs } [] scopedDeferredIncludes
drain ctx satisfiable list scopedDeferredIncludes cleaned
scope-widened { ctx } drain results scopedDeferredIncludes
defer-conditional condNode null scopedDeferredConditionals
drain-conditionals null emitted + tombstoned results scopedDeferredConditionals cleaned

Other

Effect Param Resume State
chain-push { identity } null scopedIncludesChain
chain-pop null null scopedIncludesChain
register-aspect-policy { name, fn, ownerIdentity } null scopedAspectPolicies, flatAspectPolicies
register-route route spec null scopedRoutes
register-instantiate spec null scopedInstantiates
register-provide spec null scopedProvides
register-pipe-effect spec null scopedPipeEffects
propagate-routes { scopeId } null parent scope scopedRoutes
get-path-set null pathSet attrset

Appendix: Key Invariants

Behavioral contracts validated by the pipeline test suite (templates/ci/modules/features/fx-*.nix):

Dedup and identity

  • Named aspects dedup within a scope (same provider/name → gate blocks second occurrence)
  • Anonymous aspects (<anon>:N) never dedup — each contributes independently
  • Tombstones (excluded aspects) are NOT added to the pathSet

Compile router precedence

  • meta.__forward → forward (highest priority)
  • meta.guard → conditional
  • __args != {} → parametric
  • Else → static

Constraints

  • Scope-filtered via ownerChain ancestry: a subtree exclude only applies within its owning includes chain
  • Global scope excludes apply everywhere regardless of chain
  • Constraint check returns action: "exclude" | "substitute" | "keep"

Policy dispatch

  • Policies only fire when resolveArgsSatisfied passes (all required args present in context)
  • Already-fired policies (tracked per entityKind@scope) are skipped
  • Enrichment convergence: key-monotonic (keys only added, never changed)
  • Effect application order: excludes → routes → instantiates → provides → pipes → includes

Parametric binding

  • Scope handlers are probed first, then scope context from state as fallback
  • Pipe arg references unconditionally defer (pipe data is post-pipeline)
  • Depth limit: 10 levels of parametric nesting

Conditional guards

  • Guards receive exclude-aware hasAspect built from pathSet + scope-specific constraint registry
  • Guards that fail are deferred; drain-conditionals re-evaluates with fixed-point iteration
  • Convergence: each progressing pass resolves ≥1 guard; no progress → cross-dependent → tombstone
  • Entity-shaped stubs ({ host = { hasAspect }; ... }) provided from scope handler keys without evaluating entity config (avoids cycle)

Output shape

  • fxResolve returns { imports = [...]; } — a NixOS module
  • fxFullResolve returns { value, state } with scopedClassImports in state
  • Class modules are keyed modules with _file location for NixOS merge diagnostics
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment