Policies are effect-producing functions. Their function signature determines when they activate (context matching), and they return typed policy effects that the pipeline processes. Evaluation is commutative — order between policies doesn't matter; dependencies are implicit in signatures.
Function signatures are the universal API. Aspects declare what context they need ({ host, user, secrets }:), the pipeline matches arg names to handlers — whether the value comes from an entity binding, trait collection, or policy injection.
Policies are functions from context to a list of typed policy effects:
den.policies.<name> = { host, user, ... }: [
(policy.resolve { ... })
(policy.include den.aspects.foo)
(policy.exclude den.aspects.bar)
];Effect constructors (policy.resolve, policy.include, policy.exclude) are provided via den.lib.policy and return tagged attrsets (e.g., { __policyEffect = "resolve"; value = {...}; }). The pipeline dispatches on the tag.
The current policyType submodule (from, to, as, resolve, aspects, handlers, isolateFanOut, _core) is replaced:
| Current field | New model |
|---|---|
from / to |
Implicit in function signature — { host }: replaces from = "host" |
as |
The key name in policy.resolve — policy.resolve { user = ...; } |
resolve |
The function body itself — policies ARE resolve functions |
aspects |
policy.include effects in the return list |
handlers |
Aspect-included handlers or scope.provide in the pipeline |
isolateFanOut |
Default behavior for policy.resolve — each resolve creates an isolated sub-pipeline. Opt-out via policy.resolve.shared if needed (to be specified during implementation) |
_core |
Core policies are registered in den.lib.corePolicies and always active. User policies are registered in den.policies. Both use the same effect types. |
Entity kind is inferred from the policy.resolve binding key: policy.resolve { user = ...; } creates a context with key user, which the pipeline matches against den.schema.user if it exists. Keys not in den.schema (like flake-system) are valid — they create context scopes without entity schema association.
Three effect types. No others are needed — substitution is expressed as exclude + include.
policy.resolve — Create a new context scope (fan-out). Each resolve effect creates a parallel branch — a sibling context with the new bindings merged into the parent context. Multiple policy.resolve effects from the same or different policies create multiple branches. For colliding keys, the new value shadows the existing value. This matches handler semantics (scope.provide overlays onto existing handlers). policy.resolve {} (empty bindings) is a no-op.
# Fan-out: produce user contexts from host — each user is a parallel branch
den.policies.host-users = { host }:
map (user: policy.resolve { inherit user; })
(builtins.attrValues host.users);policy.include — Inject an aspect into the current resolution context. Accepts both aspect references and inline attrsets (coerced to anonymous aspects, same as how includes = [ { nixos.foo = true; } ] works today).
# Conditional injection: admin users get extra aspects
den.policies.admin-users = { host, user }:
lib.optionals (builtins.elem "wheel" user.groups) [
(policy.include den.aspects.sudo)
(policy.include den.aspects.admin-tools)
];
# Inline attrset — coerced to anonymous aspect
den.policies.igloo-to-alice = { host, user }:
lib.optional (user.name == "alice")
(policy.include { homeManager.programs.vim.enable = true; });policy.exclude — Remove/gate an aspect from the current resolution tree. Scoping is context-matched: the exclude applies to all contexts matching the policy's function signature. A policy taking { host }: applies per-host — server hosts exclude an aspect, non-server hosts don't. The exclude propagates into sub-contexts (user, home) created from that host. Excludes don't leak into unrelated contexts (other hosts, standalone homes).
# Constraint: no home-manager on servers
den.policies.no-server-hm = { host }:
lib.optional (host.hasAspect den.aspects.server)
(policy.exclude den.aspects.home-manager-base);Policies that produce new contexts enable further policies to match. The pipeline iterates until stable:
# Produces user contexts
den.policies.host-users = { host }:
map (user: policy.resolve { inherit user; })
(builtins.attrValues host.users);
# Runs after user contexts exist — produces additional users
den.policies.sini-test = { host, user }:
lib.optional (user.name == "sini")
(policy.resolve { user = mkUser "sini-test" {}; });
# Runs for every user context (including sini-test)
den.policies.admin-users = { host, user }:
lib.optionals (builtins.elem "wheel" user.groups) [
(policy.include den.aspects.sudo)
];Policy effects are processed in two phases within each entity resolution:
Phase A — Include/exclude (tree-walk time): After an entity's aspects resolve but before transitions fire, the pipeline dispatches matching aspect-included policies and processes policy.include / policy.exclude effects immediately via emit-include / register-constraint. This ensures injected aspects participate in the entity's tree-walk — critically, they're visible to class forwarding sub-pipelines (e.g., HM forward) that collect emissions from the entity's resolution.
Phase B — Resolve (transition time): policy.resolve effects are processed during transitions, creating new parallel context branches. Each branch triggers child entity resolution (same as today's fan-out). The transition handler iterates:
- Match policies by function signature to available context
- Collect all returned effects
- Apply
policy.include/policy.excludeeffects immediately (Phase A, during entity tree-walk) - Process
policy.resolveeffects — each creates a new parallel context branch (merged into parent context) - Re-match policies against new contexts (including newly discovered aspect-included policies)
- Iterate until no new contexts AND no new policies appear (fixed-point)
Why two phases: Class forwarding batteries (home-manager, maid) run sub-pipelines during entity resolution to collect class emissions and place them at the correct NixOS module path (e.g., home-manager.users.${userName}). If policy.include effects were deferred to transition time, the injected aspects would miss the forward sub-pipeline. Processing includes during tree-walk ensures they're captured.
Evaluation is commutative at each context level — the order policies are evaluated does not affect the result. Dependencies between policies are expressed entirely through function signatures (context requirements), not through explicit ordering.
Context dedup determines what counts as "new": two resolve effects producing the same context bindings (by value identity) are deduplicated to one branch. This is the same mechanism as the existing ctxSeen handler. Termination is guaranteed because:
- The set of aspects is finite (each can only be included once due to dedup)
- Each aspect can only contribute a finite number of policies
- Each policy produces a finite set of resolve effects per invocation
- Context dedup prevents unbounded growth
If two policies at the same level both resolve with conflicting values for the same key (e.g., policy A resolves { secrets = x; } and policy B resolves { secrets = y; }), each creates a separate parallel branch — they don't merge with each other. Shadowing only applies within a single policy.resolve effect relative to its parent context. This preserves commutativity.
Traits unify with entity context as "named values provided by handlers." The pipeline doesn't distinguish between entity args (host, user) and trait args (secrets, impermanence) — both are context values matched by function signature.
Aspects emit traits as structural keys:
# Static trait (Tier 1)
den.aspects.wifi.secrets = [ "wifi-password" ];
# Parametric trait (Tier 2) — scoped to pipeline context
den.aspects.steam.impermanence = { user }: {
directories = [ (user.home + "/.local/share/Steam") ];
};Trait keys are registered in den.traits with collection strategy:
den.traits.secrets = { collection = "list"; };
den.traits.impermanence = { collection = "list"; };Trait consumers defer until collection is complete — same mechanism as entity context deferral:
- Trait arg handlers are not installed at pipeline start
- Consumer aspects with
{ secrets }:probe viahas-handler— no handler exists — they defer - Peer aspects resolve, emitting traits into
state.traits - Pipeline signals trait collection complete at current level
- Trait handlers installed, deferred consumers drain with full collection
This mirrors entity context exactly: { user }: defers until user context exists via policy.resolve; { secrets }: defers until secrets are collected from peer emissions. Same drain-deferred code path.
Circular trait dependencies (A emits X, consumes Y; B emits Y, consumes X) are handled:
- Both A and B defer because their consumed trait has no handler yet
- At drain time, neither has emitted (because neither has resolved)
- Pipeline detects: deferred consumers exist but no new emissions occurred — cycle error
- Existing dedup mechanisms (includeSeen, ctxSeen, pathSet) catch within-phase cycles
Policies can shadow trait context for a subtree using policy.resolve. Because policies are parametric on context args (including traits), a policy that takes { secrets }: defers until trait collection is complete — then fires and provides a filtered view to its subtree.
# All secrets collected at host level.
# This policy shadows secrets for the user subtree with a filtered view.
den.policies.user-secrets = { user, secrets }:
let
userSecrets = builtins.filter (s: s.owner == user.name) secrets;
in
[ (policy.resolve { secrets = userSecrets; }) ];- Peer aspects resolve, emitting
secretstrait data - Pipeline signals trait collection complete, installs
secretshandler - Policies with
{ secrets }in signature were deferred — they now drain user-secretsfires, returnspolicy.resolve { secrets = userSecrets; }- New context branch created with shadowed
secretsbinding (merged into parent) - All aspects in the user subtree that request
{ secrets }:see the filtered value
The policy's own signature ({ user, secrets }) guarantees correct ordering: it can't fire until both user context exists AND secrets collection is complete. No explicit phase annotation needed — the deferral mechanism handles it.
If some consumers need the original unfiltered collection alongside the filtered view:
den.policies.user-secrets = { user, secrets }:
let
userSecrets = builtins.filter (s: s.owner == user.name) secrets;
in
[ (policy.resolve { secrets = userSecrets; all-secrets = secrets; }) ];Downstream aspects choose which to consume: { secrets }: for filtered, { all-secrets }: for full.
Policies can do more than filter — they can enrich, normalize, or transform collected trait data before downstream consumers see it. The consumer's function signature stays the same; the policy controls what value it receives.
# Aspects emit raw impermanence paths
den.aspects.steam.impermanence = { user }: {
directories = [ (user.home + "/.local/share/Steam") ];
};
den.aspects.firefox.impermanence = { user }: {
directories = [ (user.home + "/.mozilla") ];
files = [ (user.home + "/.mozilla/firefox/profiles.ini") ];
};
# Policy enriches: merge lists, add metadata, inject defaults
den.policies.impermanence-enrichment = { user, impermanence }:
let
# Merge all directory/file lists from collected trait emissions
allDirs = lib.concatMap (e: e.directories or []) impermanence;
allFiles = lib.concatMap (e: e.files or []) impermanence;
# Enrich: add XDG base dirs that every user gets
enriched = {
directories = allDirs ++ [
(user.home + "/.config")
(user.home + "/.local/state")
];
files = allFiles;
user = user.name;
home = user.home;
};
in
[ (policy.resolve { impermanence = enriched; }) ];
# Consumer sees the enriched, merged result — not raw emissions
den.aspects.impermanence-module = { impermanence, host }: {
nixos.environment.persistence."/persist".users.${impermanence.user} = {
inherit (impermanence) directories files home;
};
};The raw trait emissions are a list of partial attrsets. The policy consumes the list, merges it, adds defaults, and shadows impermanence with a single enriched attrset. The consumer doesn't need to know about merging or defaults — it receives a ready-to-use value.
This pattern applies broadly:
- Normalization: convert heterogeneous trait emissions into a uniform schema
- Validation: reject or warn on trait data that doesn't meet requirements
- Aggregation: merge lists, deduplicate, compute summaries
- Injection: add default values, environment-specific overrides, or metadata
This pattern enables security policies to control data flow without consumer cooperation:
- Secrets filtering owned by a single policy, not scattered across modules
- Consumers don't need to know filtering exists — they see
{ secrets }:and get the appropriate view - Policy is auditable: visible in traces with provenance
Cross-entity configuration requires two phases to prevent cycles. This architecture is unchanged; the payload becomes trait state instead of freeform labeled data.
Each entity's pipeline runs independently:
- Aspects resolve, emitting traits into
state.traits - Trait consumers defer, then drain with collected data
- Policy fan-out creates sub-pipelines per context scope (each
policy.resolvebranch runs in an isolated sub-pipeline) - Sub-pipeline trait state is captured at completion
After all entity pipelines complete:
- Sub-pipeline trait collections are grouped by target entity
- Merged trait state is injected into target entity's context as handler bindings
- Cross-entity trait data is available only to Tier 3 consumers (deferred traits evaluated inside
evalModulesafter the pipeline completes). Pipeline-time consumers (Tier 1/2) that drained in phase 1 see only within-entity data. This is consistent with the current model whereprovide-todata arrives at module evaluation time.
# Client hosts emit trait — unaware of cross-entity routing
den.aspects.postgres-host.backup-targets = {
paths = [ "/var/lib/postgresql" ];
};
# Policy creates the cross-entity fan-out
den.policies.backup-clients = { host }:
map (client: policy.resolve { backup-client = client; })
(getBackupClients host);
# Consumer receives collected trait as context arg
# Same syntax whether within-entity or cross-entity
den.aspects.backup-server = { backup-targets, host }: {
nixos.services.restic.backups = mkBackups backup-targets;
};The policy doesn't explicitly route traits — traits collected in the sub-pipeline flow to phase 2 automatically. The policy creates the context scope; trait transport is a pipeline concern.
The current mutual-provider battery implements bidirectional host-user configuration via nested provides attributes and explicit routing:
# Today: manual nesting with provides syntax
den.schema.user.includes = [ den.provides.mutual-provider ];
den.aspects.igloo = {
provides.alice.homeManager.programs.vim.enable = true;
provides.to-users = { user, ... }: {
homeManager.programs.helix.enable = user.name == "alice";
};
};
den.aspects.alice = {
provides.igloo.nixos.programs.emacs.enable = true;
provides.to-hosts = { host, ... }: {
nixos.programs.nh.enable = host.name == "igloo";
};
};In the unified model, mutual configuration is expressed as aspect-included policies. The provides structural key is replaced by policies that inject inline aspects into peer contexts:
# Host provides config to specific user (targeted)
den.aspects.igloo = {
policies.to-alice = { host, user }:
lib.optional (user.name == "alice")
(policy.include { homeManager.programs.vim.enable = true; });
# Host provides config to all users (broadcast)
policies.to-users = { host, user }:
[ (policy.include {
homeManager.programs.helix.enable = user.name == "alice";
}) ];
};
# User provides config to specific host (targeted)
den.aspects.alice = {
policies.to-igloo = { host, user }:
lib.optional (host.name == "igloo")
(policy.include { nixos.programs.emacs.enable = true; });
# User provides config to all hosts (broadcast)
policies.to-hosts = { host, user }:
[ (policy.include {
nixos.programs.nh.enable = host.name == "igloo";
}) ];
};Targeted vs broadcast is expressed through policy guards — lib.optional (user.name == "alice") for targeted, unconditional for broadcast. No separate mechanism needed.
Important distinction: provides vs traits. provides forwards class configuration (inline aspects with nixos/homeManager keys) — these become policy.include effects (inline attrsets are coerced to anonymous aspects). Traits are for aggregated data (secrets, impermanence) consumed by parametric aspects via function signature. Different concerns, same pipeline.
mutual-providerbattery: replaced by aspect-included policies on each aspectprovidesstructural key on aspects: replaced bypoliciesstructural key withpolicy.includefind-mutual/to-hosts/to-usersrouting logic: replaced by policy guardsmutual-user-usercross-user routing: policies naturally run for all user-user combinations when context matchesmutual-standalone-homespecial case: handled by home-host policy with same{ home }:signature
- No special routing battery — aspects declare their own cross-entity policies inline
- Targeted vs broadcast is just a guard —
lib.optional (name == "alice")vs unconditional - Same effect types everywhere —
policy.includefor injection,policy.resolvefor fan-out - Self-contained — excluding an aspect removes its policies too; no orphaned routing
Aspects can include policies via the policies structural key. The policy is still a first-class policy — visible in traces, typed with standard effect types, evaluated through the same fixed-point iteration. The only difference from top-level den.policies is provenance: the policy was installed because the aspect was included.
# Battery is self-contained: one include brings routing + integration
den.aspects.home-manager-battery = {
policies.user-to-home = { host, user }:
map (home: policy.resolve { inherit home; })
(getHomes user);
includes = [ den.aspects.hm-base ];
};
# MicroVM battery installs its own routing
den.aspects.microvm-battery = {
policies.host-to-vm = { host }:
map (vm: policy.resolve { inherit vm; })
(getVMs host);
includes = [ den.aspects.microvm-base ];
};- Discovery: policies are discovered as aspects resolve — the pipeline processes them through the same fixed-point iteration as top-level policies. The fixed-point converges on both context stability AND policy set stability.
- Provenance: traced as "policy X installed by aspect Y" for auditability
- Removal: excluding an aspect (via
policy.exclude) removes its included policies too. If the excluded aspect's policies have already produced resolve effects in an earlier iteration, those effects are rolled back (same mechanism as the existinginclude-unseenrollback for excluded aspects). - No special mechanism: aspect-included policies use the same
policiesstructural key, same effect types, same evaluation model — they're just included rather than declared at the top level
Aspects and policies remain distinct concepts:
- Aspects are the configuration layer — composable units that emit classes, traits, includes, and optionally include policies
- Policies are the routing layer — effect-producing functions that control context scope, fan-out, constraints, and aspect injection
An aspect can include a policy. A policy can include an aspect. But they serve different roles and are evaluated differently (aspects via tree-walk resolution, policies via signature-matched fixed-point iteration).
How existing batteries map to the unified model. Batteries fall into categories by complexity.
These are plain aspects that take context and return class keys. No migration needed — they already fit the model.
| Battery | Signature | What it does |
|---|---|---|
hostname |
{ host }: |
Sets networking.hostName from host entity |
define-user |
{ user }: |
Creates OS user account with home/name |
primary-user |
{ user, host }: |
Adds wheel/networkmanager groups |
tty-autologin |
__functor(username) |
Enables getty autologin |
user-shell |
__functor(shell) |
Enables shell at OS + HM levels |
unfree and insecure each have a factory + predicate-builder pair (4 files total, 2 logical batteries). The factory emits package names, the predicate-builder collects them into nixpkgs.config. These could optionally migrate to traits:
# Current: factory + auto-included predicate builder
den.provides.unfree [ "discord" "steam" ];
# unfree-predicate-builder collects via NixOS option, builds allowUnfreePredicate
# New (optional): trait-based collection
den.aspects.gaming.unfree-packages = [ "discord" "steam" ];
# pipeline collects trait, predicate builder consumes { unfree-packages }:Not urgent — the current pattern works. Trait migration is a simplification, not a requirement.
forward.nix is a higher-order battery that routes one class to another (e.g., homeManager to home-manager.users.<name>). Used by os-class, os-user, wsl.
These continue to work as-is. The forward pattern is orthogonal to the policy/trait model — it's about class module routing, not entity/context routing.
| Battery | Routes | Used by |
|---|---|---|
os-class |
os to [nixos, darwin] |
Auto-included |
os-user |
user to users.users.<name> |
home-manager battery |
wsl |
wsl to host class |
Guarded by host.wsl.enable |
home-manager and maid are the most complex batteries. Today they install policies + aspects + forwarding routers across multiple files. In the unified model, they become self-contained aspect-included policies:
# Current: spread across multiple modules
# - modules/aspects/provides/home-manager.nix (battery definition)
# - modules/policies/ (host-to-homeManager-users policy)
# - modules/aspects/ (homeManager-host-module, homeManager-user-forward aspects)
# - den.schema.host.imports + den.schema.host.policies (registration)
# New: self-contained battery
den.aspects.home-manager-battery = {
# Routing policy included with the battery
policies.host-to-hm-users = { host, user }:
lib.optional (user.classes ? homeManager)
(policy.resolve { home-env = makeHomeEnv user; });
# Forwarding aspects included directly
includes = [
den.aspects.hm-host-forward # homeManager class to home-manager.users.<name>
den.aspects.hm-user-forward # user homeManager to OS integration
];
};One include brings the entire home-manager integration: routing policy, forwarding aspects, class setup. Removing the battery (policy.exclude den.aspects.home-manager-battery) removes everything.
Same pattern for maid:
den.aspects.maid-battery = {
policies.host-to-maid-users = { host, user }:
lib.optional (user.classes ? maid)
(policy.resolve { home-env = makeMaidEnv user; });
includes = [ den.aspects.maid-user-forward ];
};mutual-provider is eliminated entirely. See "Replacing mutual-provider" section above. Bidirectional host-user config becomes aspect-included policies on each aspect. No battery needed.
host-aspects (projects host aspect's user-class config onto users) becomes a policy:
# Current: manual context injection with __scopeHandlers
den.provides.host-aspects = { host, user, ... }: ...;
# New: policy that includes host-provided user config
den.policies.host-aspects-to-users = { host, user }:
let hostAspect = den.aspects.${host.aspect}; in
lib.optional (hostAspect ? user-config)
(policy.include hostAspect.user-config);import-tree is a factory that recursively loads class-segregated .nix files from a directory. It's a pure include mechanism — no policies, no cross-entity routing. The den.provides namespace is retained for factory batteries (it is a top-level factory registry, distinct from the provides structural key on aspects which is removed):
den.schema.host.includes = [ (den.provides.import-tree.provides.host ./hosts) ];
den.schema.user.includes = [ (den.provides.import-tree.provides.user ./users) ];flakeSystemOutputs, hmConfigurations, osConfigurations are internal batteries driven by core policies. They already use the policy pattern. In the unified model, they become aspect-included policies on the flake-level entity. Context keys like flake-system are not in den.schema — they create context scopes without entity schema association, which is valid:
den.aspects.flake-outputs = {
policies.system-to-os = { flake-system }:
map (host: policy.resolve { flake-os = host; })
(getHosts flake-system);
policies.system-to-hm = { flake-system }:
map (home: policy.resolve { flake-hm = home; })
(getHomes flake-system);
};| Category | Batteries | Migration |
|---|---|---|
| Simple (hostname, user-shell, etc.) | 7 | None — already fits |
| Predicate (unfree, insecure) | 2 (4 files) | Optional trait migration |
| Forward router (os-class, wsl) | 3 | None — orthogonal to model |
| Home environment (HM, maid) | 2 | Aspect-included policies (major simplification) |
| Cross-entity (mutual, host-aspects) | 2 | Eliminated / replaced by policies |
| Import tree | 1 | None |
| Flake outputs | 3 | Aspect-included policies (shape change only) |
- Targeted vs broadcast provides: expressed as policy guards —
lib.optional (user.name == "alice")for targeted, unconditional for broadcast - Trait filtering:
policy.resolveshadows trait context for a subtree; policy signature guarantees correct timing (defers until collection complete) - Provides vs traits:
provides(directed class config injection) becomespolicy.include; traits (collected data) remain separate — different concerns, same pipeline - Aspect-included policies: aspects can include policies via
policiesstructural key; discovery during resolution, removal with aspect exclusion policy.excludescoping: context-matched; the exclude applies to all contexts matching the policy's function signature and propagates into sub-contexts; excludes don't leak into unrelated contexts- No
policy.substitute: substitution is expressed aspolicy.exclude+policy.include— composing existing effects policy.resolvecreates parallel branches: each resolve effect creates a sibling context branch, not a mutation of the current context; this preserves commutativity when multiple policies resolve with conflicting keyspolicy.resolvemerges into parent: within a single resolve effect, new bindings merge into the parent context; colliding keys are shadowed by the new valuepolicy.includeaccepts inline attrsets: coerced to anonymous aspects, same as howincludes = [ { nixos.foo = true; } ]works today- Cross-entity traits are Tier 3 only: phase 2 data arrives at module evaluation time, consistent with current
provide-totiming