Date: 2026-04-28 Branch: feat/fx-pipeline Status: Draft Prerequisite for: Provides removal (2026-04-27-provides-removal-post-unified-effects.md)
Users migrating from main to the merged feat/fx-pipeline branch hit hard errors and silent behavioral breakage:
error: attribute 'mutual-provider' missing
at /home/pol/Code/drupol/infra/modules/dendritic.nix:18:29:
17|
18| den.ctx.user.includes = [ den._.mutual-provider ];
| ^
19| den.schema.user.classes = lib.mkDefault [ "homeManager" ];
trace: den: aspect 'x1c' uses 'provides.{to-users}' — migrate to direct nesting
On main, cross-entity routing was handled by the mutual-provider battery: users included it via den.ctx.user.includes = [ den._.mutual-provider ], and it auto-wired provides.to-users, provides.to-hosts, and named-target provides.alice patterns. On this branch, mutual-provider.nix was deleted without a translation layer. The provides.X cross-provide patterns still parse (the provides option exists on the aspect submodule) but their routing mechanism is gone — config silently drops.
Note: emitCrossProvider in transition.nix is a branch-era addition (commit ed1301a4, part of the provide-to policy implementation). It handles provides.${entityKind} patterns (e.g., provides.user) during transitions — a different key space from the mutual-provider patterns (to-users, to-hosts, named targets). It does not exist on main and does not overlap with the compat shims designed here.
Ship compatibility shims that make all main-era patterns work with deprecation warnings and migration advice. No silent breakage. Users' existing configs evaluate correctly and produce the same outputs while warnings guide them to the new patterns.
| Surface | Status before this spec | Action |
|---|---|---|
den.ctx.* |
Shimmed (modules/compat/ctx-shim.nix) |
None |
den.stages |
Shimmed (modules/removed-stages.nix) |
None |
den.lib.take / den.lib.parametric |
Already shimmed to coerce to correct shape | None |
den.provides.host-aspects |
Behavior unchanged (verified) | None |
den.provides.* factory namespace |
Unchanged | None |
den-brackets.nix provides fallback |
Still present, still works | None |
_ → provides alias on aspects |
Still present | None |
aspect.into manual transitions |
emitTransitions still reads it |
None |
den._.mutual-provider |
Hard error — file deleted | New shim |
provides.X cross-provides |
Silent drop — routing deleted | New handler |
File: nix/lib/aspects/fx/handlers/provides-compat.nix
Entry point: emitCrossProvideShims : aspect → fx computation
During tree-walk of each aspect, detects cross-provide keys (provides.X where X != aspect.name) and synthesizes register-aspect-policy effects that replicate the old mutual-provider routing.
Detection:
crossKeys = builtins.filter (k: k != aspectName)
(builtins.attrNames (aspect.provides or {}));Self-provide (provides.${self.name}) is untouched — emitSelfProvide handles that path and it still works.
Entity-kind keys (e.g., provides.user, provides.host) are handled by emitCrossProvider in transition.nix during transition resolution. The compat handler skips keys that are registered schema kinds to avoid double emission:
schemaKinds = builtins.attrNames (den.schema or {});
compatKeys = builtins.filter (k: k != aspectName && !builtins.elem k schemaKinds) crossKeys;Value resolution:
Cross-provide values can be static attrsets, plain functions, __fn attrsets (parametric wrappers or partially processed), or functor attrsets. The helper mirrors the shape detection from emitSelfProvide (aspect.nix lines 696-703):
applyProvide = value: ctx:
if builtins.isAttrs value && value ? __fn then value.__fn ctx
else if builtins.isAttrs value && value ? __functor then (value.__functor value) ctx
else if lib.isFunction value then value ctx
else value;The first branch covers both full parametric wrappers ({ __fn, __args }) and bare __fn-only attrsets — both resolve the same way for cross-provides.
Policy synthesis per pattern:
Synthesized policies use named argument signatures so the dispatch system's functionArgs-based context matching works correctly. On main, to-users only fired when both host and user were in context; to-hosts fired when host was in context. The signatures replicate this scoping:
| Pattern | Signature | Guard | Body |
|---|---|---|---|
to-users |
{ host, user, ... }: |
None (fires for all users) | policy.include the value |
to-hosts |
{ host, user, ... }: |
None (fires for all hosts) | policy.include the value |
| Named target | { host, user, ... }: |
Entity name match | policy.include the value |
All three use { host, user, ... }: — on main, mutual-provider ran in the user pipeline where both host and user were always in context. This prevents premature dispatch at host-only level.
# to-users / to-hosts: fires for every entity pair
mkWildcardPolicy = value: { host, user, ... }:
[ (policy.include (applyProvide value { inherit host user; })) ];
# Named target: fires only when entity name matches
mkNamedTargetPolicy = key: value: { host, user, ... }:
lib.optional
(host.name == key || user.name == key)
(policy.include (applyProvide value { inherit host user; }));Behavioral note: policy.include feeds into aspectToEffect, the same resolution pipeline as the old mechanism. Deep nesting (provides values with their own includes) is an edge case that needs test coverage.
Named target guard: User names and host names do not overlap (existing restriction on main). The guard checks both host.name and user.name.
Effect emitted per cross-provide key:
fx.send "register-aspect-policy" {
name = "${aspectName}/compat:${key}";
fn = warnedPolicyFn;
ownerIdentity = nodeIdentity; # supports exclusion rollback
}The ownerIdentity comes from identity.pathKey (identity.aspectPath aspect), same as real aspect policies. This ensures that if the aspect is excluded via policy.exclude, its compat-synthesized policies are also rolled back.
Exclusion rollback verification needed: The registerAspectPolicyHandler in tree.nix stores policies keyed by param.name. Verify that the exclusion mechanism (registerExcludes in transition.nix) actually filters state.aspectPolicies by ownerIdentity, not just by name. If exclusion only matches by name, the ownerIdentity field provides no rollback and the spec's claim is incorrect. In that case, compat policies need their names to include the owner identity for exclusion to work.
Deprecation warning:
Each synthesized policy wraps fn with lib.warn:
den: aspect 'igloo' uses provides.to-users — migrate to:
den.aspects.igloo.policies.to-users = { host, user, ... }:
[ (policy.include { <config> }) ];
The warning fires once per policy evaluation (Nix deduplicates identical lib.warn strings).
Integration with resolveChildren:
Single fx.bind call, inserted before emitAspectPolicies. Replaces the existing deprecation-only trace block (aspect.nix lines 757-761):
# Before (current):
childResolution = fx.bind (builtins.seq _ (emitSelfProvide aspect)) (
selfProvResults:
fx.bind (emitAspectPolicies aspect) (
# After:
childResolution = fx.bind (emitSelfProvide aspect) (
selfProvResults:
fx.bind (providesCompat.emitCrossProvideShims aspect) (
_:
fx.bind (emitAspectPolicies aspect) (The builtins.seq _ deprecation trace block is deleted — the handler owns warnings now.
File: modules/compat/mutual-provider-shim.nix
Makes den.provides.mutual-provider (and den._.mutual-provider via the _ alias) evaluate to a valid but inert aspect:
{ lib, ... }:
{
den.provides.mutual-provider = lib.warn
"den.provides.mutual-provider is deprecated — cross-entity routing is now built-in via policies. Remove from includes."
{
name = "mutual-provider";
description = "Deprecated compat shim — remove from includes.";
# On main, mutual-provider was a parametric aspect. Some users may apply it
# with arguments (den._.mutual-provider { ... }). The __functor accepts and
# ignores any arguments to prevent hard errors.
__functor = _: _:
{ name = "mutual-provider"; description = "Deprecated compat shim."; };
};
}On main, mutual-provider was a parametric aspect included in den.ctx.user.includes. Its job was cross-entity routing via provides.X. That routing is now handled by the provides-compat pipeline handler, so the include is a no-op. The shim produces a valid aspect shape (attrset with name) so the include mechanism doesn't error. The __functor ensures that applying it as a function (den._.mutual-provider { ... }) also doesn't error.
Import wiring: This module must be added to the compat module import list (alongside ctx-shim.nix and removed-stages.nix). Check modules/default.nix or wherever compat modules are aggregated and add the import.
- Self-provide removal (
provides.${self.name}) — still works viaemitSelfProvide, removal is Phase 2 providesoption removal fromtypes.nix— the option must remain for the shim to readaspect.providesprovidesinstructuralKeysSet— must remain so provides keys aren't treated as freeform content- Template migration — users migrate on their own schedule guided by warnings
_alias removal — must remain soden._.Xcontinues to workden.fxPipelineoption — this option was introduced onfeat/fx-pipelineand later removed on the same branch. It never existed onmain. Users migrating frommaincannot have set it. No shim needed.
All of the above (except fxPipeline) are Phase 2 (provides removal spec) and blocked until the compat layer has been in place for a release cycle.
When migration period ends:
- Delete
nix/lib/aspects/fx/handlers/provides-compat.nix - Remove the
emitCrossProvideShimscall fromresolveChildreninaspect.nix - Delete
modules/compat/mutual-provider-shim.nix - Proceed with Phase 2 of provides removal spec
Two files deleted, one line removed. Clean cut.
- Aspect with
provides.to-users = { homeManager... }(static) → config reaches user entity - Aspect with
provides.to-users = { host, ... }: { homeManager... }(parametric) → config reaches user entity with correct host context - Aspect with
provides.alice = { homeManager... }(named target) → config reaches only useralice - Aspect with
provides.igloo = { nixos... }(named target, reverse direction) → config reaches only hostigloo - Aspect with
provides.to-userswhere value is{ __fn = ...; __args = ...; }(parametric wrapper) → resolved correctly - Aspect with
provides.to-userswhere value is{ __fn = ...; }(bare __fn, no __args) → resolved correctly provides.${entityKind}keys (e.g.,provides.user) are NOT handled by compat (deferred toemitCrossProviderin transition.nix)- Excluded aspect's compat policies are rolled back (verify ownerIdentity linkage — see note above)
to-userspolicy does NOT fire at host-only level (named args{ host, user, ... }prevent premature dispatch)- All patterns emit deprecation warnings
den.ctx.user.includes = [ den._.mutual-provider ]evaluates without errorden.ctx.user.includes = [ den.provides.mutual-provider ]evaluates without errorden._.mutual-provider { }(applied as function) evaluates without error- Deprecation warning fires
- Parametric wrapper shapes: The
applyProvidehelper must handle all value shapes that the aspect submodule's freeform type can produce (__fnattrsets, functor attrsets, plain functions, static attrsets). If a shape is missed, the provide value passes through unevaluated and produces a type error downstream. Mitigated by mirroring the shape detection fromemitSelfProvide(aspect.nix lines 696-703). - Named target ambiguity: The guard checks both
host.nameanduser.name. If a future entity kind (e.g.,home) has a.namefield that could match, the guard needs extending. Currently safe — only host and user entities have named targets inprovides.Xpatterns on main. - Evaluation order:
emitCrossProvideShimsruns beforeemitAspectPoliciesinresolveChildren. If an aspect has bothprovides.Xcompat policies and realpolicies.Xentries for the same routing, both fire. This is correct (the user is mid-migration) but could produce duplicate config. The deprecation warning should make this visible. - Deep nesting: Cross-provide values that themselves contain nested sub-aspects with their own
includesare an edge case. Thepolicy.includepath feeds intoaspectToEffectrecursion, which should handle this, but it's the least-tested path. Add explicit test coverage for nested provides values. - Exclusion rollback: Verify that
ownerIdentityon compat policies actually enables rollback on exclusion. If the exclusion mechanism doesn't filter by owner, compat policies for excluded aspects would continue firing. See verification note in design section.