Skip to content

Policy Activation Deep Dive

den.policies is a registry — a named collection of policy values. Registering a policy does not activate it:

# This only registers. The policy will never fire.
den.policies.my-policy = { host, ... }: [
(policy.resolve { flag = true; })
];

Activation happens when a policy value appears in an includes list:

# Now it fires for all hosts
den.schema.host.includes = [ den.policies.my-policy ];

This separation is the core design principle: policies are reusable values that can be activated, deactivated, and filtered independently of where they’re defined.

The module system wraps bare functions into tagged records:

den.policies.foo = { host, ... }: [ ... ]
↓ (module system merge)
{ __isPolicy = true; name = "foo"; fn = { host, ... }: [ ... ]; }

The __isPolicy tag lets the pipeline distinguish policies from aspects in includes lists. Both are valid include entries — the pipeline checks the tag and routes accordingly.

Policies can be activated at any level of the include hierarchy:

LevelWhereScope
Globalden.default.includesAll entities
Per entity kindden.schema.host.includesAll hosts
Per aspectden.aspects.igloo.includesThe igloo subtree
Inline{ includes = [ den.policies.foo ]; }The enclosing aspect

When a policy is activated at a scope, it cascades to all descendant scopes. A policy in den.schema.host.includes fires at the host scope and is also available at user scopes resolved from that host.

Aspects can define policies inline using the policies attribute:

den.aspects.igloo = {
policies.to-users = { host, user, ... }:
lib.optional someCondition
(policy.include { nixos.some.option = true; });
includes = [ den.aspects.igloo.policies.to-users ];
};

The policy is registered on the aspect and activated via includes in the same definition.

excludes is a first-class top-level key on aspects, symmetric with includes. It prevents policies from firing in the aspect’s subtree:

den.aspects.igloo = {
includes = [ den.policies.add-marker ];
excludes = [ den.policies.add-marker ];
};

Parent excludes are authoritative — child scopes cannot re-enable an excluded policy:

parentAspect = {
excludes = [ den.policies.blocked ];
includes = [ childAspect ];
};
childAspect = {
# This does NOT re-enable the policy. The parent's exclude wins.
includes = [ den.policies.blocked ];
};

This prevents downstream aspects from bypassing security or correctness constraints established by parent scopes.

Excludes match on policy identity. When a policy is wrapped with policy.for or policy.when, the inner policy’s identity is preserved:

wrapped = den.lib.policy.for entity den.policies.my-policy;
# This exclude still works — it matches the inner policy identity
excludes = [ den.policies.my-policy ];

Wraps a policy to fire only when a specific entity is in context. Matching uses id_hash for robust identity comparison:

den.schema.host.includes = [
(den.lib.policy.for den.hosts.x86_64-linux.igloo
den.policies.igloo-specific)
];

The wrapper checks whether any entity kind key in the current context has a matching id_hash. If not, the policy returns [].

Accepts a single policy value or a list:

den.lib.policy.for entity [
den.policies.policy-a
den.policies.policy-b
]
# Returns a list of two wrapped policies

Wraps a policy to fire only when a predicate returns true:

den.schema.host.includes = [
(den.lib.policy.when ({ host, ... }: host.wsl.enable)
den.policies.wsl-support)
];

The predicate receives the full context attrset. If it returns false, the policy returns [].

Wrappers compose — when wrapping for wrapping a raw policy:

den.lib.policy.when (ctx: ctx.flag or false)
(den.lib.policy.for entity den.policies.my-policy)

The outer wrapper runs first. If the predicate fails, the inner wrapper never executes.

When the pipeline reaches an entity scope, policy dispatch follows a fixed-point iteration:

  1. Collect — gather all active policies from the scope’s include chain (direct includes, schema includes, default includes).
  2. Check satisfaction — for each policy, introspect its function args. A policy fires only when all required args (non-default) are present in the current context.
  3. Fire — call satisfied policies, collect their effects.
  4. Enrich — if any effects add new context bindings (policy.resolve with enrichment), merge them via scope.provide and drain deferred includes.
  5. Iterate — check if new bindings satisfy previously-unsatisfied policies. If so, fire them and repeat from step 4.
  6. Converge — when no new policies fire, emit all collected effects.

This fixed-point iteration means policies can depend on context that other policies provide. The iteration is capped at 10 rounds to prevent infinite loops.

A policy does not apply to its own outputs. The source policy name is threaded through the resolve chain, seeding firedPolicies at child scopes. This prevents infinite recursion when a policy’s effects would satisfy its own firing condition.

Policy includes are deduplicated via identity tags. Each policy include gets a <policy:name:idx> identity key. Anonymous policy includes get global dedup keys. This prevents the same policy from being registered multiple times when included from multiple scopes.

Contribute Community Sponsor