diff --git a/docs/src/content/docs/explanation/parametric.mdx b/docs/src/content/docs/explanation/parametric.mdx
index aeb34faa4..02b175e75 100644
--- a/docs/src/content/docs/explanation/parametric.mdx
+++ b/docs/src/content/docs/explanation/parametric.mdx
@@ -46,6 +46,35 @@ den.aspects.firewall = {
};
```
+## The binding rule
+
+When a parametric aspect at some scope **S** destructures an **entity-kind** arg
+(`host`, `user`, `home`, …), exactly one of three things happens:
+
+1. **In-context → bind once at S.** If the kind is already in S's context (e.g.
+ a `{ user, … }` aspect included at a user scope), the arg binds and the aspect
+ emits once, at S.
+2. **Schema-DAG descendant → fan out, emit at S.** If the kind is a *descendant*
+ of S in the entity schema (e.g. a `{ user, … }` aspect included at the **host**
+ scope — users live under hosts), the aspect fans out once per matching
+ descendant, each emitting **class-locally at S**. This is how a host-scope
+ `{ user, … }` aspect produces per-user content on the host.
+3. **Neither → inert, silently.** If the kind is neither in-context nor a
+ descendant of S — including a misplaced arg, or **any entity-kind arg at the
+ root/flake scope** — the aspect contributes nothing. There is **no warning**
+ (a warning was considered and deliberately rejected: legitimate fan-out and
+ misplacement are indistinguishable without whole-fleet context, so a warning
+ would fire on correct code).
+
+
+Because case 3 is silent, a parametric aspect that destructures an entity kind
+**not reachable from where it is included** simply does nothing — no error, no
+warning. If a parametric aspect "isn't applying," check that the arg's kind is
+in-context or a schema descendant of the include scope. Cross-entity delivery
+(e.g. a host wanting to configure a *sibling* host) is **not** expressible this
+way — use `provides` or a policy.
+
+
## Where Parametric Args Work
diff --git a/docs/src/content/docs/guides/debug.md b/docs/src/content/docs/guides/debug.md
index 4a21ca42c..daef49652 100644
--- a/docs/src/content/docs/guides/debug.md
+++ b/docs/src/content/docs/guides/debug.md
@@ -131,12 +131,14 @@ nix-repl> cfg.networking.hostName
from `den.default`, but parametric functions in `den.default.includes`
run at every context stage. The pipeline handles dispatch automatically
based on function argument shape — write a bare function with the context
-args you need. (`den.lib.perHost` is deprecated.)
+args you need. The `den.lib.perHost`, `perUser`, and `perHome` shims still
+exist but are deprecated (they warn and forward to the current binding rule);
+prefer plain parametric aspects:
```nix
-# Deprecated: den.lib.perHost ({ host }: { nixos.x = 1; })
-# Modern — bare function; only runs in host contexts:
-({ host }: { nixos.x = 1; })
+# Deprecated (warns): den.lib.perHost, den.lib.perUser, den.lib.perHome
+# Preferred — plain parametric aspect; binds host at its scope / fans out:
+({ host, ... }: { nixos.x = 1; })
```
**Missing attribute**: The context does not have the expected parameter.
diff --git a/docs/src/content/docs/guides/home-manager.mdx b/docs/src/content/docs/guides/home-manager.mdx
index 5187aea61..d931a5e50 100644
--- a/docs/src/content/docs/guides/home-manager.mdx
+++ b/docs/src/content/docs/guides/home-manager.mdx
@@ -236,3 +236,16 @@ den.hosts.x86_64-linux.laptop = {
Both `homeManager` and `hjem` configurations from `den.aspects.alice` will
be forwarded to their respective targets.
+
+## Host-scope parametric aspects no longer deliver homeManager content to users
+
+Earlier releases let a `{ user, ... }` aspect at host scope leak its
+`homeManager` content to each of the host's users. A host-scope parametric
+aspect now fans out over the users but emits class-locally **on the host**,
+where `homeManager` is inert -- so that content never reaches the users'
+Home Manager evaluation. This matches the resolver's emission rule: the bound
+user is the arg source, not the output target.
+
+If you were relying on that leak to push home content from a host-scope aspect,
+route it through an explicit `to-users` policy, `provides`, or the host-aspects
+battery instead.
diff --git a/docs/src/content/docs/reference/lib-deprecated.mdx b/docs/src/content/docs/reference/lib-deprecated.mdx
index f95d2edcc..b13814bf7 100644
--- a/docs/src/content/docs/reference/lib-deprecated.mdx
+++ b/docs/src/content/docs/reference/lib-deprecated.mdx
@@ -105,22 +105,32 @@ helper (does not warn).
## Context shortcuts
-Run an aspect only at a specific context level. Each returns a parametric wrapper
-(declaring deeper context keys as optional) that yields `{}` when resolved at a
-deeper level, and warns. Prefer a plain function with the level's keys, e.g.
-`({ host, user, ... }: { ... })`.
+Wrap an aspect so it requires a specific set of context keys. Each returns a
+parametric wrapper and **warns**. They are now thin aliases for a plain function
+with the same keys — i.e. `den.lib.perHost f` behaves as `({ host, ... }: f { … })`:
+the arg binds once at the emitting scope if in-ctx, fans out class-locally over
+that scope's descendants otherwise, and is inert if misplaced. Prefer the plain
+function form, e.g. `({ host, user, ... }: { ... })`.
+
+
+Earlier releases made these wrappers *self-suppress* — yield `{}` when resolved
+at a deeper context level. That was the cross-scope-deferral behavior fixed in
+the binding-half rewrite (see [Parametric Aspects](/explanation/parametric/)).
+The shims no longer self-suppress; they deliver the current binding rule. A
+config that relied on the old silent `{}` will now see the wrapped aspect bind.
+
### `den.lib.perHost aspect`
-Run `aspect` only in `{ host }` contexts.
+Wrap `aspect` to require `{ host }`.
### `den.lib.perUser aspect`
-Run `aspect` only in `{ host, user }` contexts.
+Wrap `aspect` to require `{ host, user }`.
### `den.lib.perHome aspect`
-Run `aspect` only in `{ home }` contexts.
+Wrap `aspect` to require `{ home }`.
## `den.lib.resolveStage`
diff --git a/docs/src/content/docs/reference/policies.mdx b/docs/src/content/docs/reference/policies.mdx
index 1b5567460..66c286fce 100644
--- a/docs/src/content/docs/reference/policies.mdx
+++ b/docs/src/content/docs/reference/policies.mdx
@@ -66,9 +66,47 @@ Remove an aspect via the constraint registry:
policy.exclude den.aspects.unwanted
```
+### `policy.deliver spec`
+
+The user-facing **delivery primitive**: declare one delivery edge from a source
+into a target class at a path, with an explicit mode. `route` and `provide`
+(below) are sugar shims over `deliver`.
+
+```nix
+# Class source — move a class's collected content (the `route` case):
+policy.deliver {
+ from = "myClass"; # class name → collect that class's bucket
+ to = "nixos"; # target class
+ at = [ "services" "myService" ]; # attrpath ([] = merge at the class root)
+ mode = "nest"; # "merge" | "nest" | "verbatim"
+}
+
+# Module source — inject a NEW module (the `provide` case):
+policy.deliver {
+ from = { module = { pkgs, ... }: { ... }; };
+ to = "nixos";
+}
+```
+
+`mode` is exhaustive and explicit:
+
+- `merge` (default) — union into the target class bucket (use with `at = []`).
+- `nest` — evaluate and place the source at `at`.
+- `verbatim` — place the collected module wrappers _by reference_ so a target
+ whose option `merge` re-instantiates them (e.g. a microvm guest config) sees
+ the live modules together with its base modules. There is **no
+ `reinstantiate` flag** — verbatim is requested by `mode` directly. `verbatim`
+ applies only to class sources, not module sources.
+
+`deliver` deliberately does **not** expose `appendToParent` (parent-targeting is
+constructor-internal) — use a class source whose `to` already names the target.
+
### `policy.route spec`
-Route class or quirk content from one scope partition into a target class:
+Route class or quirk content from one scope partition into a target class. A
+permanent sugar shim over [`policy.deliver`](#policydeliver-spec) —
+`fromClass`→`from`, `intoClass`→`to`, `path`→`at`, and `reinstantiate = true`→
+`mode = "verbatim"`:
```nix
policy.route {
@@ -80,7 +118,10 @@ policy.route {
### `policy.provide spec`
-Deliver a module directly to a target class, bypassing the aspect tree:
+Deliver a module directly to a target class, bypassing the aspect tree. A
+permanent sugar shim over [`policy.deliver`](#policydeliver-spec) with a module
+source (`class`→`to`, `module`→`from.module`, `path`→`at`; `mode` is derived
+from `path` — `merge` at the root, `nest` under a non-empty `path`):
```nix
policy.provide { class = "nixos"; module = { pkgs, ... }: { ... }; }
diff --git a/modules/context/has-aspect.nix b/modules/context/has-aspect.nix
index 401f7285e..5e7d701ea 100644
--- a/modules/context/has-aspect.nix
+++ b/modules/context/has-aspect.nix
@@ -8,6 +8,45 @@ let
entityModule =
{ config, ... }:
+ let
+ # Prefer `classes` (list), fall back to `[class]`, else error.
+ classes =
+ config.classes or (
+ if config ? class then
+ [ config.class ]
+ else
+ throw "den.schema.conf.hasAspect: entity has no `class` or `classes`"
+ );
+ primaryClass =
+ if classes == [ ] then
+ throw "den.schema.conf.hasAspect: entity has empty `classes` list"
+ else
+ lib.head classes;
+ # Lazy thunk throwing at call time (not attribute access), so the
+ # accessors can be referenced safely by tooling and only fire when
+ # actually invoked.
+ err = throw (
+ "hasAspect: ${config.name or ""} has no config.resolved "
+ + "(no matching den.schema. defined)."
+ );
+ # Shared record: `hasAspect` (functor) and `aspects` (node list) are both
+ # read off `info`, so the per-class fxFullResolve runs once and is reused.
+ info =
+ if config ? resolved then
+ den.lib.aspects.mkEntityHasAspect {
+ tree = config.resolved;
+ inherit primaryClass classes;
+ }
+ else
+ {
+ __functor = _: _: err;
+ forClass = _: _: err;
+ forAnyClass = _: err;
+ aspects = err;
+ aspectsForClass = _: err;
+ allAspects = err;
+ };
+ in
{
options.hasAspect = lib.mkOption {
description = ''
@@ -30,40 +69,32 @@ let
readOnly = true;
type = lib.types.raw;
defaultText = lib.literalMD "Computed from `config.resolved` and the entity's class/classes.";
- default =
- let
- # Prefer `classes` (list), fall back to `[class]`, else error.
- classes =
- config.classes or (
- if config ? class then
- [ config.class ]
- else
- throw "den.schema.conf.hasAspect: entity has no `class` or `classes`"
- );
- primaryClass =
- if classes == [ ] then
- throw "den.schema.conf.hasAspect: entity has empty `classes` list"
- else
- lib.head classes;
- # Lazy thunk throwing at call time (not attribute access), so
- # entity.hasAspect / .forClass / .forAnyClass can be referenced
- # safely by tooling and only fire when actually invoked.
- err = throw (
- "hasAspect: ${config.name or ""} has no config.resolved "
- + "(no matching den.schema. defined)."
- );
- in
- if config ? resolved then
- den.lib.aspects.mkEntityHasAspect {
- tree = config.resolved;
- inherit primaryClass classes;
- }
- else
- {
- __functor = _: _: err;
- forClass = _: _: err;
- forAnyClass = _: err;
- };
+ default = info;
+ };
+
+ options.aspects = lib.mkOption {
+ description = ''
+ The flat list of all resolved aspect nodes on this entity (every
+ depth), each the resolved node augmented with:
+ .identity # base FQN, ctx-stripped — e.g. "roles/workstation"
+ .identityKey # full unique key incl {ctxId} (distinguishes anons)
+ .isNamed # false for anonymous aspects
+
+ Each node also retains its `.name`, `.meta`, and `.includes` (its
+ resolved subtree), so callers can inspect, navigate, and re-include
+ it. Excludes the entity root and excluded/tombstoned aspects;
+ anonymous aspects are included.
+
+ Same cyclic caveat as `hasAspect`: do not use to decide an aspect's
+ own `includes`. Reading it post-resolution (module bodies, batteries)
+ is safe.
+ '';
+ internal = true;
+ visible = false;
+ readOnly = true;
+ type = lib.types.raw;
+ defaultText = lib.literalMD "Computed from `config.resolved` and the entity's primary class.";
+ default = info.aspects;
};
};
in
diff --git a/modules/context/perHost-perUser.nix b/modules/context/perHost-perUser.nix
index 299b6a297..818c75c78 100644
--- a/modules/context/perHost-perUser.nix
+++ b/modules/context/perHost-perUser.nix
@@ -1,45 +1,41 @@
-# DEPRECATED: scheduled for removal after first stable release post-fx-pipeline merge.
-# Migration: use den.schema.host / den.schema.user directly.
-# Deprecated context-level guards.
-# Under handler-based resolution, bind.fn resolves each arg independently.
-# Optional args with no handler are skipped (nix-effects c7931d7), so
-# the function body can detect context level by checking which keys
-# were resolved.
+# DEPRECATED context guards — kept as a thin compatibility shim.
+#
+# `den.lib.perHost` / `perUser` / `perHome` shipped in earlier releases. They
+# are RESTORED here as aliases over the current binding rule so existing configs
+# keep evaluating (and get a deprecation warning steering them to plain
+# functions). Migration: use a plain function — `({ host, ... }: { ... })`.
+#
+# IMPORTANT — semantics changed (this is the #609 fix): the old shim returned
+# `{}` whenever a deeper context key was present (a self-suppressing emulation
+# of cross-scope deferral). That behavior was the bug the binding-half rewrite
+# removed. The restored shim drops the self-suppression: an entity-kind arg now
+# binds once at the emitting scope if in-ctx, fans out class-locally over the
+# scope's descendants otherwise, and is inert if misplaced — exactly as a plain
+# `{ host, ... }:` function does. So `den.lib.perHost f` is now an alias for
+# `{ host, ... }: f { inherit host; }`, not the old suppress-at-deeper-scope
+# guard. Configs relying on the old silent suppression were relying on #609.
{ lib, ... }:
let
- # Known context keys. Keys not in the required set are declared as
- # optional — if their handlers exist (deeper level), the function
- # detects the extras and returns {} (no-op).
- allContextKeys = [
- "host"
- "user"
- "home"
- ];
-
+ # Build a parametric wrapper requiring exactly `requiredKeys` (all required —
+ # no optional "extra" keys, hence no self-suppression). The fx bind handler
+ # binds these per the current rule and applies `__fn` with the resolved args.
perCtx =
requiredKeys: aspect:
let
- reqKeysSorted = builtins.sort builtins.lessThan requiredKeys;
- extraKeys = builtins.filter (k: !(builtins.elem k reqKeysSorted)) allContextKeys;
- # Required keys as required (false), extra keys as optional (true)
- funcArgs = lib.genAttrs reqKeysSorted (_: false) // lib.genAttrs extraKeys (_: true);
+ reqSorted = builtins.sort builtins.lessThan requiredKeys;
in
lib.warn
- "den.lib.perCtx [${lib.concatStringsSep "," reqKeysSorted}] is deprecated — use a plain function ({ ${lib.concatStringsSep ", " reqKeysSorted}, ... }: ...) instead; handler-based resolution resolves context args automatically"
+ "den.lib.perCtx [${lib.concatStringsSep "," reqSorted}] is deprecated — use a plain function ({ ${lib.concatStringsSep ", " reqSorted}, ... }: ...) instead; handler-based resolution binds context args automatically"
{
+ __args = lib.genAttrs reqSorted (_: false);
__fn =
resolvedArgs:
- let
- # If any extra key was resolved (handler exists), we're at a deeper level
- hasExtras = builtins.any (k: resolvedArgs ? ${k}) extraKeys;
- in
- if hasExtras then
- { }
- else if lib.isFunction aspect && !builtins.isAttrs aspect then
- aspect (lib.intersectAttrs (lib.genAttrs reqKeysSorted (_: null)) resolvedArgs)
+ if lib.isFunction aspect && !builtins.isAttrs aspect then
+ aspect (lib.intersectAttrs (lib.genAttrs reqSorted (_: null)) resolvedArgs)
else
aspect;
- __args = funcArgs;
+ name = aspect.name or "";
+ meta = aspect.meta or { };
};
perHost = perCtx [ "host" ];
diff --git a/modules/options.nix b/modules/options.nix
index 94974c557..822f73113 100644
--- a/modules/options.nix
+++ b/modules/options.nix
@@ -14,181 +14,11 @@ let
config
;
};
-
- # Context args are derived from the entity's _module.args, filtered to
- # known entity kinds so framework args don't leak through.
- schemaKinds = builtins.filter (n: n != "conf" && !(lib.hasPrefix "_" n)) (
- builtins.attrNames (den.schema or { })
- );
- knownKinds = schemaKinds;
-
- # Option type names whose values are safe for identity hashing.
- primitiveTypeNames = [
- "str"
- "int"
- "bool"
- ];
-
- schemaEntryType =
- let
- base = lib.types.deferredModule;
- in
- base
- // {
- merge =
- loc: defs:
- let
- kind = lib.last loc;
- # Extract includes and excludes from defs, strip before deferred merge
- allIncludes = lib.concatMap (
- d:
- if builtins.isAttrs d.value && d.value ? includes && builtins.isList d.value.includes then
- d.value.includes
- else
- [ ]
- ) defs;
- allExcludes = lib.concatMap (
- d:
- if builtins.isAttrs d.value && d.value ? excludes && builtins.isList d.value.excludes then
- d.value.excludes
- else
- [ ]
- ) defs;
- strippedDefs = map (
- d:
- if builtins.isAttrs d.value then
- d
- // {
- value = builtins.removeAttrs d.value [
- "includes"
- "excludes"
- ];
- }
- else
- d
- ) defs;
- merged = base.merge loc strippedDefs;
-
- resolvedCtx =
- { config, options, ... }:
- {
- # Stable identity hash for entity comparison.
- #
- # Nix's `==` does deep structural comparison which diverges or
- # infinitely recurses when the same entity is accessed via
- # different module system thunks. This hash reflects on all
- # non-internal primitive options (str, int, bool), prefixed by
- # schema kind, to produce a cheap string identity.
- #
- # Automatically includes any primitive option declared on the
- # entity — custom entity types get this for free.
- #
- # Usage: builtins.filter (h: h.id_hash != host.id_hash) allHosts
- options.id_hash = lib.mkOption {
- description = ''
- Auto-computed identity hash for entity comparison.
-
- Derived by reflecting on all non-internal, primitive-typed
- options (str, int, bool) declared on this entity. The schema
- kind is included to prevent cross-kind collisions.
-
- Use `a.id_hash != b.id_hash` instead of `a != b` for entity
- comparison — Nix's `==` does deep structural comparison which
- is fragile across module system boundaries.
- '';
- readOnly = true;
- internal = true;
- type = lib.types.str;
- default =
- let
- isPrimitive =
- name: opt:
- !(lib.hasPrefix "_" name)
- && (opt ? type)
- && builtins.elem (opt.type.name or "") primitiveTypeNames
- && !(opt.internal or false);
- identityKeys = lib.sort (a: b: a < b) (builtins.attrNames (lib.filterAttrs isPrimitive options));
- encode =
- k:
- let
- v = config.${k};
- in
- "${k}=${toString v}";
- fingerprint = "${kind}|${lib.concatMapStringsSep "|" encode identityKeys}";
- in
- builtins.hashString "sha256" fingerprint;
- };
- options.resolved = lib.mkOption {
- description = "The resolved aspect for this ${kind}.";
- readOnly = true;
- type = lib.types.raw;
- default =
- let
- # knownKinds already includes schema-derived kinds.
- isContextArg = n: builtins.elem n knownKinds;
- ctx = lib.filterAttrs (n: v: isContextArg n && v != null) config._module.args // {
- ${kind} = config;
- };
- in
- den.lib.resolveEntity kind ctx;
- };
- options.collisionPolicy = lib.mkOption {
- description = "How to handle collisions between den context args and module-system args in flat-form class modules.";
- type = lib.types.nullOr (
- lib.types.enum [
- "error"
- "class-wins"
- "den-wins"
- ]
- );
- default = null;
- };
- };
- # Entity gating: kind gets pipeline wiring if it has includes, excludes, or structural module content.
- # `conf` is a shared base module, not an entity — always excluded.
- hasEntityContent =
- kind != "conf" && (allIncludes != [ ] || allExcludes != [ ] || hasStructuralContent);
- # A schema entry is "structural" if it has module content beyond just includes/excludes.
- # Only structural entries should get self-provide aspect lookup.
- hasStructuralContent = builtins.any (
- d:
- let
- v = d.value;
- stripped =
- if builtins.isAttrs v then
- builtins.removeAttrs v [
- "includes"
- "excludes"
- ]
- else
- v;
- in
- !builtins.isAttrs stripped || stripped != { }
- ) defs;
- in
- if hasEntityContent then
- {
- __functor =
- _:
- { ... }:
- {
- imports = [
- merged
- resolvedCtx
- ];
- };
- includes = allIncludes;
- excludes = allExcludes;
- isEntity = hasStructuralContent;
- }
- else
- {
- __functor = _: { ... }: merged;
- includes = [ ];
- excludes = [ ];
- isEntity = false;
- };
- };
+ # Imported directly, not via den.lib.schema: this declares options.den.schema,
+ # and den.lib's map includes schema-util which reads den.schema._kindNames —
+ # routing through den.lib here would close that cycle. Entity types consume it
+ # lazily at eval time, so they safely use den.lib.schema.
+ schemaLib = import ./../nix/lib/schema.nix { inherit inputs lib; };
classSchemaType = lib.types.submodule (
{ ... }:
@@ -214,20 +44,62 @@ let
};
}
);
-
- schemaOption = lib.mkOption {
- description = "freeform deferred modules per entity kind";
- defaultText = lib.literalExpression "{ }";
- default = { };
- type = lib.types.submodule {
- freeformType = lib.types.lazyAttrsOf schemaEntryType;
- };
- };
in
{
options.den.hosts = types.hostsOption;
options.den.homes = types.homesOption;
- options.den.schema = schemaOption;
+ options.den.schema = schemaLib.mkSchemaOption {
+ collections = {
+ includes = {
+ default = [ ];
+ };
+ excludes = {
+ default = [ ];
+ };
+ isEntity = {
+ default = false;
+ merge = acc: val: acc || val;
+ };
+ isolated = {
+ default = false;
+ merge = acc: val: acc || val;
+ };
+ };
+ computed = collections: defs: {
+ isEntity =
+ collections.isEntity
+ || builtins.any (
+ d:
+ let
+ v = d.value;
+ collectionKeys = [
+ "includes"
+ "excludes"
+ "isEntity"
+ "isolated"
+ "parent"
+ "collisionPolicy"
+ ];
+ stripped = if builtins.isAttrs v then builtins.removeAttrs v collectionKeys else v;
+ in
+ !builtins.isAttrs stripped || stripped != { }
+ ) defs;
+ };
+ };
+ # Built-in entity topology: users nest inside hosts, homes nest inside hosts.
+ config.den.schema.user.parent = "host";
+ config.den.schema.home.parent = "host";
+
+ options.den.reservedKeys = lib.mkOption {
+ description = "Additional aspect keys reserved from pipeline dispatch. These keys are treated as structural — the pipeline ignores them, letting consumers use them for metadata.";
+ type = lib.types.listOf lib.types.str;
+ default = [ ];
+ example = [
+ "settings"
+ "tags"
+ ];
+ };
+
options.den.classes = lib.mkOption {
description = "Class evaluation domains";
type = lib.types.lazyAttrsOf classSchemaType;
diff --git a/nix/lib/aspects/fx/arg-class.nix b/nix/lib/aspects/fx/arg-class.nix
new file mode 100644
index 000000000..4c34a03c4
--- /dev/null
+++ b/nix/lib/aspects/fx/arg-class.nix
@@ -0,0 +1,68 @@
+# Pure classification of parametric-aspect args against the schema entity DAG.
+# An entity-kind arg at scope-kind `scopeKind` is either bindable from ctx
+# (handled upstream in bind), a DESCENDANT (relationship fan-out at the
+# emitting scope), or misplaced (inert). Non-entity args never reach this.
+#
+# Child enumeration is convention-based: a parent record holds its `kind`
+# children in attr "${kind}s" (host.users; nix-config's guest kind follows
+# the same convention with host.guests — note `guest` is NOT in den's
+# default schema). A schema-declared override is deliberately deferred
+# until a consumer needs one (YAGNI; both known collections follow the
+# convention).
+#
+# Related: resolve.nix's isAncestorOf walks scope-parent chains (different
+# map shape, self-loop guard only) — keep the two in mind if either changes.
+{ ... }:
+rec {
+ # Ancestor chain of `start` over the schema-kind parent DAG, inclusive of
+ # `start` at the head: [ start, parent, grandparent, … ]. Cycle-guarded. The
+ # one schema-DAG ancestry walk — `isDescendantOf` and policy/schema.nix's owner
+ # lookup both derive from it.
+ ancestorChain =
+ schema: start:
+ let
+ walk =
+ k: acc:
+ let
+ p = schema.${k}.parent or null;
+ in
+ if p == null || builtins.elem p acc then acc else walk p (acc ++ [ p ]);
+ in
+ walk start [ start ];
+
+ # True when argKind's parent chain reaches scopeKind (strict descendant).
+ isDescendantOf =
+ schema: scopeKind: argKind:
+ scopeKind != null && argKind != scopeKind && builtins.elem scopeKind (ancestorChain schema argKind);
+
+ # True when `k` names a registered entity kind in `schema`.
+ isEntityKind = schema: k: builtins.isAttrs (schema.${k} or null) && (schema.${k}.isEntity or false);
+
+ childrenAttrFor = argKind: "${argKind}s";
+
+ # Child records of `parentRecord` for `argKind`; [ ] when absent.
+ childrenOf =
+ parentRecord: argKind: builtins.attrValues (parentRecord.${childrenAttrFor argKind} or { });
+
+ # Immediate parent KIND of `argKind` for a fan at scope kind `scopeKind`: the
+ # schema-declared parent, falling back to the scope itself (a direct child).
+ # Pure schema-DAG knowledge — kept here, not inlined in the bind handler.
+ parentKindOf =
+ schema: scopeKind: argKind:
+ schema.${argKind}.parent or scopeKind;
+
+ # Descendants whose immediate parent kind has a record available NOW
+ # (`availRecords` keyed by kind: the scope ctx + intermediates already fanned).
+ # Fanning one of these first lets the recursion reach deeper descendants once
+ # their parent is bound — the shallowest-reachable order a transitive DAG fan
+ # needs (a chain must bind the intermediate before its child).
+ fanableDescendants =
+ schema: scopeKind: availRecords: descendants:
+ builtins.filter (
+ k:
+ let
+ p = parentKindOf schema scopeKind k;
+ in
+ p != null && availRecords ? ${p}
+ ) descendants;
+}
diff --git a/nix/lib/aspects/fx/assemble-pipes.nix b/nix/lib/aspects/fx/assemble-pipes.nix
index fc9a5ac7e..0de6b8dcc 100644
--- a/nix/lib/aspects/fx/assemble-pipes.nix
+++ b/nix/lib/aspects/fx/assemble-pipes.nix
@@ -129,26 +129,67 @@ let
hostConfigs: scopeContexts: scopeId: values:
builtins.concatMap (resolveEntry hostConfigs scopeContexts scopeId) values;
- # Apply a single transform stage to a value list.
- # Config thunk markers (__configThunk) pass through filter/transform unchanged.
- applyStage =
- values: stage:
+ # Value functor: lets ONE stage interpreter run over either bare values (the
+ # plain path) or provenance-tagged values ({ __pv = value; __ps = scopeId; }).
+ # Each functor supplies:
+ # unwrap wrapped -> raw (read the underlying value)
+ # rewrap wrapped -> raw -> wrapped (replace value, keep tag — transform)
+ # seed scopeId -> raw -> wrapped (tag a fresh value at a given scope —
+ # fold/append/for re-tag, collect tags
+ # each value with its SOURCE scope)
+ # passthrough wrapped -> bool (skip filter/transform — the plain
+ # path passes __configThunk markers
+ # through unchanged; provenance never:
+ # the legacy provenance interpreter had
+ # no __configThunk guard, preserved
+ # bug-for-bug — do not "fix")
+ idFunctor = {
+ unwrap = v: v;
+ rewrap = _old: raw: raw;
+ seed = _scope: raw: raw;
+ passthrough = v: v ? __configThunk;
+ };
+ pvFunctor = {
+ unwrap = v: v.__pv;
+ rewrap = old: raw: old // { __pv = raw; };
+ seed = scope: raw: {
+ __pv = raw;
+ __ps = scope;
+ };
+ passthrough = _v: false;
+ };
+
+ # Apply a single filter/transform/fold/append/for stage to a value list,
+ # interpreted through `functor`. `currentScopeId` is the scope new values
+ # (fold/append/for results) are re-tagged to.
+ applyStageWith =
+ functor: currentScopeId: values: stage:
let
t = stage.__pipeStage or "";
+ inherit (functor)
+ unwrap
+ rewrap
+ passthrough
+ ;
+ seed = functor.seed currentScopeId;
in
if t == "filter" then
- builtins.filter (v: v ? __configThunk || stage.fn v) values
+ builtins.filter (v: passthrough v || stage.fn (unwrap v)) values
else if t == "transform" then
- map (v: if v ? __configThunk then v else stage.fn v) values
+ map (v: if passthrough v then v else rewrap v (stage.fn (unwrap v))) values
else if t == "fold" then
- [ (builtins.foldl' stage.fn stage.init values) ]
+ [ (seed (builtins.foldl' (acc: v: stage.fn acc (unwrap v)) stage.init values)) ]
else if t == "append" then
- values ++ [ stage.value ]
+ values ++ [ (seed stage.value) ]
else if t == "for" then
- stage.fn values
+ map seed (stage.fn (map unwrap values))
else
values;
+ # Plain-path single-stage application (identity functor, no provenance tag).
+ # Config thunk markers (__configThunk) pass through filter/transform unchanged.
+ applyStage = applyStageWith idFunctor null;
+
# Apply all transform stages from a pipe effect.
applyTransformStages =
values: stages:
@@ -235,70 +276,16 @@ let
in
builtins.filter predicateMatches candidates;
- # Collect quirks from all scopes matching a predicate (no parent constraint).
- collectFromAll =
- {
- scopeContexts,
- scopeEntityKind ? { },
- scopedClassImports,
- currentScopeId,
- pipeName,
- hostConfigs ? null,
- }:
- predicate:
- let
- matchingScopes = findMatchingAll {
- inherit
- scopeContexts
- scopeEntityKind
- currentScopeId
- ;
- } predicate;
- in
- lib.concatMap (
- sid:
- let
- entries = (scopedClassImports.${sid} or { }).${pipeName} or [ ];
- values = flattenAndExtract entries;
- in
- resolveThunks hostConfigs scopeContexts sid values
- ) matchingScopes;
-
- # Collect quirks from sibling scopes matching a predicate.
- collectFromPeers =
- {
- scopeContexts,
- scopeParent,
- scopeEntityKind ? { },
- scopedClassImports,
- currentScopeId,
- pipeName,
- hostConfigs ? null,
- }:
- predicate:
- let
- matchingScopes = findMatchingSiblings {
- inherit
- scopeContexts
- scopeParent
- scopeEntityKind
- currentScopeId
- ;
- } predicate;
- in
- lib.concatMap (
- sid:
- let
- entries = (scopedClassImports.${sid} or { }).${pipeName} or [ ];
- values = flattenAndExtract entries;
- in
- resolveThunks hostConfigs scopeContexts sid values
- ) matchingScopes;
-
# Process stages sequentially, including collect and withProvenance stages.
# When withProvenance is present, values are internally tagged with source
# scope IDs: { __pv = value; __ps = scopeId; }. The withProvenance stage
# converts these to user-visible { value; source; } format.
+ #
+ # One interpreter, lifted over a value functor (idFunctor for the plain path,
+ # pvFunctor when withProvenance is present). filter/transform/fold/append/for
+ # go through applyStageWith; collect/collectAll resolve matching scopes and
+ # tag each collected value with its SOURCE scope (seed sid), which is the
+ # identity for the plain path and the provenance tag for the provenance path.
processStagesWithCollect =
{
scopeContexts,
@@ -312,6 +299,7 @@ let
initialValues: stages:
let
hasProvenance = builtins.any (s: (s.__pipeStage or "") == "withProvenance") stages;
+ functor = if hasProvenance then pvFunctor else idFunctor;
relevantStages = builtins.filter (
s:
builtins.elem (s.__pipeStage or "") [
@@ -325,14 +313,21 @@ let
"withProvenance"
]
) stages;
- taggedInitial =
- if hasProvenance then
- map (v: {
- __pv = v;
- __ps = currentScopeId;
- }) initialValues
- else
- initialValues;
+ # Tag initial values at the current scope (identity for the plain path).
+ taggedInitial = map (functor.seed currentScopeId) initialValues;
+ # Resolve a list of matching scopes into collected values, each tagged with
+ # its SOURCE scope id (not currentScopeId).
+ collectTagged =
+ matchingScopes:
+ lib.concatMap (
+ sid:
+ let
+ entries = (scopedClassImports.${sid} or { }).${pipeName} or [ ];
+ rawValues = flattenAndExtract entries;
+ resolved = resolveThunks hostConfigs scopeContexts sid rawValues;
+ in
+ map (functor.seed sid) resolved
+ ) matchingScopes;
in
builtins.foldl' (
values: stage:
@@ -340,113 +335,35 @@ let
t = stage.__pipeStage or "";
in
if t == "collect" then
- if hasProvenance then
- let
- matchingScopes = findMatchingSiblings {
- inherit
- scopeContexts
- scopeParent
- scopeEntityKind
- currentScopeId
- ;
- } stage.fn;
- collected = lib.concatMap (
- sid:
- let
- entries = (scopedClassImports.${sid} or { }).${pipeName} or [ ];
- rawValues = flattenAndExtract entries;
- resolved = resolveThunks hostConfigs scopeContexts sid rawValues;
- in
- map (v: {
- __pv = v;
- __ps = sid;
- }) resolved
- ) matchingScopes;
- in
- values ++ collected
- else
- values
- ++ collectFromPeers {
+ values
+ ++ collectTagged (
+ findMatchingSiblings {
inherit
scopeContexts
scopeParent
scopeEntityKind
- scopedClassImports
currentScopeId
- pipeName
- hostConfigs
;
} stage.fn
+ )
else if t == "collectAll" then
- if hasProvenance then
- let
- matchingScopes = findMatchingAll {
- inherit
- scopeContexts
- scopeEntityKind
- currentScopeId
- ;
- } stage.fn;
- collected = lib.concatMap (
- sid:
- let
- entries = (scopedClassImports.${sid} or { }).${pipeName} or [ ];
- rawValues = flattenAndExtract entries;
- resolved = resolveThunks hostConfigs scopeContexts sid rawValues;
- in
- map (v: {
- __pv = v;
- __ps = sid;
- }) resolved
- ) matchingScopes;
- in
- values ++ collected
- else
- values
- ++ collectFromAll {
+ values
+ ++ collectTagged (
+ findMatchingAll {
inherit
scopeContexts
scopeEntityKind
- scopedClassImports
currentScopeId
- pipeName
- hostConfigs
;
} stage.fn
+ )
else if t == "withProvenance" then
map (v: {
value = v.__pv;
source = scopeContexts.${v.__ps};
}) values
- else if hasProvenance then
- if t == "filter" then
- builtins.filter (v: stage.fn v.__pv) values
- else if t == "transform" then
- map (v: v // { __pv = stage.fn v.__pv; }) values
- else if t == "fold" then
- [
- {
- __pv = builtins.foldl' (acc: v: stage.fn acc v.__pv) stage.init values;
- __ps = currentScopeId;
- }
- ]
- else if t == "append" then
- values
- ++ [
- {
- __pv = stage.value;
- __ps = currentScopeId;
- }
- ]
- else if t == "for" then
- map (v: {
- __pv = v;
- __ps = currentScopeId;
- }) (stage.fn (map (v: v.__pv) values))
- else
- values
else
- applyStage values stage
+ applyStageWith functor currentScopeId values stage
) taggedInitial relevantStages;
# Check whether a pipe effect has a pipe.to routing stage.
diff --git a/nix/lib/aspects/fx/default.nix b/nix/lib/aspects/fx/default.nix
index a1c589ec1..a6b6098d9 100644
--- a/nix/lib/aspects/fx/default.nix
+++ b/nix/lib/aspects/fx/default.nix
@@ -14,4 +14,15 @@
pipeline = import ./pipeline.nix { inherit lib den; };
wrapClasses = import ./wrap-classes.nix { inherit lib den; };
keyClassification = import ./key-classification.nix { inherit lib den; };
+ argClass = import ./arg-class.nix { inherit lib den; };
+ edgeTrace = import ./edge-trace.nix { inherit lib den; };
+ edges = {
+ edge = import ./edges/edge.nix { inherit lib; };
+ parity = import ./edges/parity.nix { inherit lib; };
+ pi = import ./edges/pi.nix { inherit lib; };
+ toposort = import ./edges/toposort.nix { inherit lib; };
+ materialize = import ./edges/materialize.nix { inherit lib den; };
+ materializeUnified = import ./edges/materialize-unified.nix { inherit lib den; };
+ instantiateSubtree = import ./edges/instantiate-edges.nix { inherit lib den; };
+ };
}
diff --git a/nix/lib/aspects/fx/edge-trace.nix b/nix/lib/aspects/fx/edge-trace.nix
new file mode 100644
index 000000000..f243abbd0
--- /dev/null
+++ b/nix/lib/aspects/fx/edge-trace.nix
@@ -0,0 +1,268 @@
+# edge-trace.nix — the LEGACY end-state re-derivation of the pipeline's delivery
+# decisions as a normalized, stably-sorted edge list. As of Task 18 this is NO
+# LONGER the live trace: the live `edgeTrace` is the CAPTURED production edge
+# object (resolve.nix — its fold-ordered provides+routes come straight from the
+# production materializeUnified folds). This `extractEdgeTrace` is retained and
+# surfaced as `legacyEdgeTrace` ONLY as the legacy arm of the oracle≡production
+# DIFFERENTIAL (templates/ci/.../fx-oracle-production-differential.nix): it
+# re-derives the edge set from END-STATE, INCLUDING the spawn `rewalk` arm (the
+# undercount the production object eliminates) and the dedup-`suppressed` route
+# twins (which production never folds). It was the migration oracle for the
+# Phase-2 port (spec 2026-06-12 §3a); post-Task-18 its job is to prove, by diff,
+# that production dropped exactly the rewalk undercount + suppressed twins.
+#
+# All edge kinds (default folds, simple + complex routes, provides, spawns,
+# instantiates) render through the SAME constructors production materializes
+# through (edges/default.nix, edges/route.nix, edges/provides.nix,
+# edges/instantiate.nix). The ONE residual annotation is `sourceVia = "unresolved"`
+# for complex-forward (synthesize) edges: the collected-else-rewalk source choice
+# is materialization-time path-dependent, so the trace records identity, not the
+# resolved branch (spec §8; see routeEdges). This stays "unresolved" PERMANENTLY —
+# it is correct, not a convergence-pending approximation.
+#
+# Edge record: { source; target; path; mode; annotations; }
+# S (source) — collected(scopeName, class) | rewalk(aspect, bindings, class)
+# | synthesize(forwardId, fromClass, intoClass)
+# T (target) — { root = scopeName; class; } (instantiation root)
+# | { output = attrpath; } (flake-output)
+# P (path) — attrpath; [] = merge at root
+# M (mode) — "merge" | "nest" | "nest-verbatim"
+#
+# Trace normalization (spec §8): sort key (T, P, S, M); entity scopes named by
+# id_hash (parent-blind identity), non-entity scopes by their mkScopeId string;
+# rewalk/synthesize edges record the identity triple, NOT resolved content.
+{ lib, den, ... }:
+let
+ # The shared edge record, sort key, scope-naming, and S/T constructors — the
+ # ONE edge definition production (edges/default.nix) and this oracle share, so
+ # they can never diverge (spec §3a convergence). EXTRACTED to edges/edge.nix.
+ inherit (import ./edges/edge.nix { inherit lib; })
+ entityKindOf
+ scopeName
+ mkEdge
+ sortEdges
+ collected
+ rewalk
+ synthesize
+ rootTarget
+ outputTarget
+ ;
+ # The default-fold edge constructor — the SAME constructor production resolves
+ # the per-host extraction through (edges/materialize.nix). v0's inline default-
+ # fold arm is REPLACED by this import so extractor and production converge on
+ # one constructor (spec §3a).
+ inherit (import ./edges/default.nix { inherit lib; }) defaultFoldEdges;
+ # The route edge constructor — the SAME constructor production materializes
+ # simple + complex routes through (edges/route.nix applyRoutes). v0's inline
+ # route arm + its own dedup/suppression re-derivation is REPLACED by this
+ # import: the oracle and production now converge on ONE route constructor
+ # (spec §3a). The `suppressed` annotations are now EXACT (the constructor's own
+ # dedup rules), not the v0 path-dependent approximation; `sourceVia` for complex
+ # forwards stays "unresolved" (the collected-else-rewalk source choice is
+ # materialization-time path-dependent — see routeEdges' note).
+ inherit (import ./edges/route.nix { inherit lib den; }) routeEdges;
+ # The provides edge constructor — the SAME constructor production materializes
+ # provides through (resolve.nix phase-2 → edges/provides.nix applyProvidesEdges).
+ # v0's inline provides arm + its own dedup is REPLACED by this import: oracle and
+ # production converge on ONE provides constructor (spec §3a). The two-edge
+ # decomposition (nest into source bucket, merge half = default-fold) is recorded
+ # by the constructor's `mergeHalf` annotation (§B Decision 1).
+ inherit (import ./edges/provides.nix { inherit lib den; }) providesEdges;
+ # The flake-output T-arm constructor — the SAME descriptors + @system
+ # disambiguation production maps to lazy instantiate thunks (resolve.nix
+ # applyInstantiates). The oracle's inline disambiguation re-derivation is
+ # REPLACED by this import (spec §3a convergence): the @system rule is now
+ # production's, not a parallel render.
+ instantiateEdges = import ./edges/instantiate.nix { inherit lib; };
+in
+rec {
+ # extractTopLevelEdges: pipeline end-state → the per-COMPONENT edge lists,
+ # UNSORTED. The shared seam between the read-only oracle (extractEdgeTrace,
+ # which sorts the union) and the production unifiedEdges collector (resolve.nix),
+ # which wants the SAME top-level mechanism lists but drops the `spawnEdges`
+ # rewalk arm (it surfaces the real spawn edges from the drain-fold instead) and
+ # adds the per-host / B′ instantiate edges. Both consume the EXACT SAME
+ # constructor calls over the SAME end-state, so oracle and production can never
+ # diverge on the top-level set (spec §3a).
+ extractTopLevelEdges =
+ {
+ scopeContexts,
+ scopeParent,
+ scopeIsolated,
+ scopeEntityKind,
+ scopedClassImports,
+ scopedRoutes,
+ scopedProvides,
+ scopedSpawns,
+ scopedInstantiates,
+ rootScopeId,
+ }:
+ let
+ nameArgs = { inherit scopeEntityKind scopeContexts; };
+ name = scopeName nameArgs;
+ kindOf = entityKindOf nameArgs;
+
+ allScopeIds = builtins.attrNames scopeContexts;
+
+ # Entity-root scopes: the pipeline root + every isolated scope (an isolated
+ # entity is its OWN collection root — isolation = edge-absence into its
+ # parent, spec §2). Each is a default-fold T.
+ entityRootScopes = lib.unique (
+ [ rootScopeId ] ++ builtins.filter (sid: scopeIsolated.${sid} or false) allScopeIds
+ );
+
+ # ===== default fold edges ==========================================
+ # The SAME constructor production routes the per-host extraction through
+ # (edges/default.nix defaultFoldEdges → edges/materialize.nix). v0's inline
+ # arm is gone; the oracle and production share one constructor (spec §3a).
+ # classContentAt = the per-scope class buckets (only `? class` membership is
+ # read for content presence).
+ defaultFold = defaultFoldEdges {
+ inherit
+ name
+ scopeParent
+ scopeIsolated
+ allScopeIds
+ entityRootScopes
+ ;
+ classContentAt = scopedClassImports;
+ };
+
+ # ===== provides edges (two-edge decomposition, §B Decision 1) ======
+ # Rendered by the SHARED provides constructor (edges/provides.nix
+ # providesEdges) — the SAME constructor production materializes provides
+ # through (resolve.nix phase-2 → applyProvidesEdges). Each spec → a nest edge
+ # into the SOURCE scope's bucket; the merge half is the default-fold edge
+ # (annotated mergeHalf). Dedup key = (policyName, class, path), NOT scope-
+ # keyed (§B Decision 1).
+ providesEdgeList = providesEdges { inherit name scopedProvides; };
+
+ # ===== route edges =================================================
+ # Rendered by the SHARED route constructor (edges/route.nix routeEdges) —
+ # the SAME constructor production materializes simple routes through
+ # (edges/route.nix applyRoutes → materializeRouteEdge). The oracle no longer re-derives
+ # suppression: the constructor's own dedup/suppression rules are EXACT here
+ # (the `suppressed`/`suppressedByChildKey` annotations are the production
+ # decisions, not the v0 approximation). Complex forwards keep
+ # `sourceVia = "unresolved"` — see the header: the collected-else-rewalk
+ # source is materialization-time path-dependent, so the trace is identity-only.
+ rawRoutes = builtins.concatLists (lib.attrValues scopedRoutes);
+ routeEdgeList = routeEdges {
+ inherit
+ name
+ scopeParent
+ rootScopeId
+ rawRoutes
+ ;
+ };
+
+ # ===== spawn (rewalk) edges ========================================
+ # scopedSpawns: each marker lives at an OWN entity scope (ownKind); the
+ # spawned class is re-walked from the PARENT scope's own entity aspect,
+ # with the own entity bound under its kind → delivered to the own scope
+ # root. Identity triple: (parent aspect identity, bound kinds, class).
+ # Content is NOT recorded (spec §8 rewalk determinism).
+ spawnEdges = builtins.concatLists (
+ lib.mapAttrsToList (
+ ownSid: specs:
+ let
+ ownKind = kindOf ownSid;
+ from = scopeParent.${ownSid} or null;
+ parentKind = if from == null then null else kindOf from;
+ parentRec =
+ if parentKind == null then null else (scopeContexts.${from} or { }).${parentKind} or null;
+ ownRec = if ownKind == null then null else (scopeContexts.${ownSid} or { }).${ownKind} or null;
+ # Parent aspect identity: prefer the record's id_hash, else its name.
+ aspectId =
+ if parentRec == null then "" else parentRec.id_hash or parentRec.name or "";
+ defaultClasses = if ownRec == null then [ ] else ownRec.classes or [ ];
+ classes = lib.unique (
+ builtins.concatLists (map (s: if s.classes != null then s.classes else defaultClasses) specs)
+ );
+ in
+ map (
+ cls:
+ mkEdge {
+ source = rewalk aspectId [ ownKind ] cls;
+ target = rootTarget (name ownSid) cls;
+ path = [ ];
+ mode = "merge";
+ annotations = {
+ spawnFrom = if from == null then null else name from;
+ };
+ }
+ ) classes
+ ) scopedSpawns
+ );
+
+ # ===== instantiate edges (flake-output T-arm) ======================
+ # scopedInstantiates → flake-output edges. T = [ "flake" ] ++ intoAttr.
+ # Rendered by the SHARED flake-output T-arm constructor (edges/instantiate.nix)
+ # — the SAME descriptors + @system disambiguation production maps to lazy
+ # instantiate thunks (resolve.nix applyInstantiates). The oracle maps the
+ # disambiguated descriptors to edge records instead; both touch path + system
+ # metadata only (never spec.instantiate), so this is laziness-safe and the
+ # @system rule can never diverge (spec §3a). resolvedRootVia = "scope-link":
+ # the entity scope is resolved from the scopeByEntity link recorded at scope
+ # creation (push-scope), not reconstructed by a name-infix heuristic.
+ allInstantiates = builtins.concatLists (lib.attrValues scopedInstantiates);
+ disambiguated = instantiateEdges.disambiguate (instantiateEdges.specDescriptors allInstantiates);
+ instantiateEdgeList = map (
+ entry:
+ let
+ spec = entry.spec;
+ # @system-qualified when disambiguate rewrote the path-tail (the last
+ # element gained an `@` suffix). The constructor owns the rule;
+ # the oracle reads its decision off the resulting path.
+ baseName = lib.last ([ "flake" ] ++ (spec.intoAttr or [ ]));
+ isMultiSystem = lib.last entry.path != baseName;
+ in
+ mkEdge {
+ # Source content comes from the host subtree (collected); the source is
+ # the spec's source scope + class.
+ source = collected (name (spec.sourceScopeId or rootScopeId)) (spec.class or "nixos");
+ target = outputTarget entry.path;
+ path = [ ];
+ mode = "merge";
+ annotations = {
+ resolvedRootVia = "scope-link";
+ inherit (entry) system;
+ }
+ // lib.optionalAttrs isMultiSystem {
+ disambiguatedTo = lib.concatStringsSep "." entry.path;
+ };
+ }
+ ) disambiguated;
+
+ in
+ {
+ inherit
+ defaultFold
+ providesEdgeList
+ routeEdgeList
+ spawnEdges
+ instantiateEdgeList
+ ;
+ };
+
+ # extractEdgeTrace: pipeline end-state → stably-sorted normalized edge list.
+ # The oracle's union INCLUDES the spawn `rewalk` arm (one rewalk edge per spawn
+ # marker — the undercount the unifiedEdges collector corrects by surfacing the
+ # spawn's real edge set instead).
+ extractEdgeTrace =
+ args:
+ let
+ parts = extractTopLevelEdges args;
+ in
+ sortEdges (
+ parts.defaultFold
+ ++ parts.providesEdgeList
+ ++ parts.routeEdgeList
+ ++ parts.spawnEdges
+ ++ parts.instantiateEdgeList
+ );
+
+ # Re-exported so resolve.nix's unifiedEdges can sort its union without a
+ # second import of edges/edge.nix.
+ inherit sortEdges;
+}
diff --git a/nix/lib/aspects/fx/edges/default.nix b/nix/lib/aspects/fx/edges/default.nix
new file mode 100644
index 000000000..98e6c4a04
--- /dev/null
+++ b/nix/lib/aspects/fx/edges/default.nix
@@ -0,0 +1,88 @@
+# default.nix — edge constructors. The constructor functions that turn recorded
+# pipeline state into delivery edges (spec §3c "edge collection"). Both the
+# read-only oracle (edge-trace.nix) and the production materializer
+# (materialize.nix → resolve.nix) source their edges from HERE, so extractor and
+# production can never disagree on an edge's shape (spec §3a convergence).
+#
+# This file holds the DEFAULT-FOLD constructor. The other mechanisms' edge
+# constructors live in their own siblings: routes + complex forwards in route.nix,
+# provides in provides.nix, the flake-output T-arm in instantiate.nix. The spawn
+# now SURFACES real delivery edges via these shared constructors (its default fold
+# + the captured provides/routes of its materializeUnified fold, see
+# edges/materialize.nix assembleSpawnSubtree.edges) — they enter the production
+# `edgeTrace`. The legacy `rewalk`-edge render (edge-trace.nix spawnEdges) is now
+# ONLY the legacy-differential arm, not the live trace.
+{ lib, ... }:
+let
+ inherit (import ./edge.nix { inherit lib; }) mkEdge collected rootTarget;
+ inherit (import ../scope-walk.nix { inherit lib; }) subtreeScopes;
+in
+{
+ # ===== default fold edges =============================================
+ # Corollary 1 (spec §2): every entity-root scope contributes one merge edge
+ # per class with content —
+ # collected(subtree minus isolated, class) → (root, class), P=[], M=merge.
+ # `isolated` is consumed HERE, at edge construction (the subtree boundary via
+ # subtreeScopes), NOT inside the materializer — corollary 2: isolation is
+ # edge-absence, never a mid-walk filter. An isolated descendant is its OWN
+ # entity-root, so it is ABSENT from its parent's collected subtree and emits
+ # its own fold edge instead.
+ #
+ # The edge records the source as the subtree's ROOT scope name + class (spec
+ # §8 records the collected(scope,class) IDENTITY, not enumerated content). The
+ # collectedScopes annotation surfaces the isolation-aware subtree membership so
+ # the isolation-as-edge-absence corollary can assert with teeth (an isolated
+ # child's scope name must NOT appear in its parent fold's collectedScopes).
+ #
+ # Inputs are already-projected pipeline end-state:
+ # name — sid → stable scope name (edge.nix scopeName, bound to the
+ # pipeline's scopeEntityKind+scopeContexts).
+ # scopeParent — the parent DAG (for the subtree walk).
+ # scopeIsolated — { sid → bool } isolation marks (subtree boundary).
+ # classContentAt — sid → { class → bool|content } presence map (any scope's
+ # class buckets); only `? class` membership is read.
+ # allScopeIds — every scope id in the projection.
+ # entityRootScopes — the roots to emit folds for (pipeline root + isolated
+ # scopes, each its own root per corollary 2).
+ defaultFoldEdges =
+ {
+ name,
+ scopeParent,
+ scopeIsolated,
+ classContentAt,
+ allScopeIds,
+ entityRootScopes,
+ }:
+ builtins.concatLists (
+ map (
+ rootSid:
+ let
+ subtree = subtreeScopes {
+ inherit scopeParent allScopeIds;
+ isolated = scopeIsolated;
+ root = rootSid;
+ };
+ # Classes with any content anywhere in the isolation-aware subtree.
+ classesWithContent = lib.unique (
+ builtins.concatLists (map (sid: builtins.attrNames (classContentAt.${sid} or { })) subtree)
+ );
+ hasContent = cls: builtins.any (sid: (classContentAt.${sid} or { }) ? ${cls}) subtree;
+ # Normalized names of every scope this fold collects from. An isolated
+ # descendant is its OWN root, so it is ABSENT here (corollary 2).
+ collectedScopes = lib.sort (a: b: a < b) (lib.unique (map name subtree));
+ in
+ map (
+ cls:
+ mkEdge {
+ source = collected (name rootSid) cls;
+ target = rootTarget (name rootSid) cls;
+ path = [ ];
+ mode = "merge";
+ annotations = {
+ inherit collectedScopes;
+ };
+ }
+ ) (builtins.filter hasContent classesWithContent)
+ ) entityRootScopes
+ );
+}
diff --git a/nix/lib/aspects/fx/edges/edge.nix b/nix/lib/aspects/fx/edges/edge.nix
new file mode 100644
index 000000000..e149fbe1e
--- /dev/null
+++ b/nix/lib/aspects/fx/edges/edge.nix
@@ -0,0 +1,139 @@
+# edge.nix — the shared delivery-edge record: constructor, the (T,P,S,M) sort
+# key, and the id_hash-based scope-naming helpers. EXTRACTED from edge-trace.nix
+# so that the read-only oracle (edge-trace.nix) and the production edge
+# constructors (edges/default.nix) share ONE edge definition and can never
+# diverge on record shape or normalization (spec §3a: extractor and constructors
+# converge on one edge).
+#
+# Edge record: { source; target; path; mode; annotations; }
+# S (source) — collected(scopeName, class) | rewalk(aspect, bindings, class)
+# | synthesize(forwardId, fromClass, intoClass)
+# T (target) — { root = scopeName; class; } (instantiation root)
+# | { output = attrpath; } (flake-output)
+# P (path) — attrpath; [] = merge at root
+# M (mode) — "merge" | "nest" | "nest-verbatim"
+#
+# Trace normalization (spec §8): sort key (T, P, S, M); entity scopes named by
+# id_hash (parent-blind identity), non-entity scopes by their mkScopeId string;
+# rewalk/synthesize edges record the identity triple, NOT resolved content.
+{ lib, ... }:
+let
+ # --- scope naming -------------------------------------------------------
+
+ # Entity kind for a scope, if any. scopeEntityKind covers scopes created by
+ # `resolve.to`, but NOT the pipeline root (it is seeded from ctx, never
+ # `resolve.to`-created). For the root (and any ctx-seeded entity scope), scan
+ # the scope's own ctx for a kind-keyed record carrying an id_hash.
+ entityKindOf =
+ { scopeEntityKind, scopeContexts }:
+ sid:
+ let
+ viaKind = scopeEntityKind.${sid} or null;
+ ctx = scopeContexts.${sid} or { };
+ # A ctx key whose value is an entity record (has id_hash). Sorted for
+ # determinism; first wins (a scope carries one own-entity record).
+ ctxKinds = lib.filter (k: builtins.isAttrs (ctx.${k} or null) && (ctx.${k} ? id_hash)) (
+ lib.sort (a: b: a < b) (builtins.attrNames ctx)
+ );
+ in
+ if viaKind != null then
+ viaKind
+ else if ctxKinds != [ ] then
+ builtins.head ctxKinds
+ else
+ null;
+
+ # id_hash of the own-entity record at a scope, if the scope is an entity scope.
+ idHashOf =
+ args@{ scopeEntityKind, scopeContexts }:
+ sid:
+ let
+ kind = entityKindOf args sid;
+ erec = if kind == null then null else (scopeContexts.${sid} or { }).${kind} or null;
+ in
+ if erec == null then null else erec.id_hash or null;
+
+ # Stable scope NAME for S/T. Entity scopes → ":" (parent-blind
+ # identity, stable across re-keying and same-name siblings collapse by design,
+ # spec §8). Non-entity scopes (system=…, root "") → the mkScopeId string.
+ scopeName =
+ args@{ scopeEntityKind, scopeContexts }:
+ sid:
+ let
+ kind = entityKindOf args sid;
+ idHash = idHashOf args sid;
+ in
+ if kind != null && idHash != null then
+ "${kind}:${idHash}"
+ else
+ (if sid == "" then "" else sid);
+
+ # --- edge record + sort -------------------------------------------------
+
+ mkEdge =
+ {
+ source,
+ target,
+ path ? [ ],
+ mode,
+ annotations ? { },
+ }:
+ {
+ inherit
+ source
+ target
+ path
+ mode
+ annotations
+ ;
+ };
+
+ # Canonical string keys for stable sort (spec §8: T, P, S, M).
+ targetKey =
+ t: if t ? output then "out:${lib.concatStringsSep "." t.output}" else "root:${t.root}/${t.class}";
+ pathKey = p: lib.concatStringsSep "/" p;
+ sourceKey =
+ s:
+ if s ? collected then
+ "collected:${s.collected.scope}/${s.collected.class}"
+ else if s ? rewalk then
+ "rewalk:${s.rewalk.aspect}/${lib.concatStringsSep "+" s.rewalk.bindings}/${s.rewalk.class}"
+ else if s ? synthesize then
+ "synthesize:${s.synthesize.forwardId}/${s.synthesize.fromClass}>${s.synthesize.intoClass}"
+ else
+ "empty";
+ edgeSortKey =
+ e:
+ lib.concatStringsSep " | " [
+ (targetKey e.target)
+ (pathKey e.path)
+ (sourceKey e.source)
+ e.mode
+ ];
+
+ sortEdges = edges: lib.sort (a: b: edgeSortKey a < edgeSortKey b) edges;
+
+ # --- S/T constructors ---------------------------------------------------
+
+ collected = scope: class: { collected = { inherit scope class; }; };
+ rewalk = aspect: bindings: class: { rewalk = { inherit aspect bindings class; }; };
+ synthesize = forwardId: fromClass: intoClass: {
+ synthesize = { inherit forwardId fromClass intoClass; };
+ };
+ rootTarget = root: class: { inherit root class; };
+ outputTarget = output: { inherit output; };
+in
+{
+ inherit
+ entityKindOf
+ scopeName
+ mkEdge
+ edgeSortKey
+ sortEdges
+ collected
+ rewalk
+ synthesize
+ rootTarget
+ outputTarget
+ ;
+}
diff --git a/nix/lib/aspects/fx/edges/instantiate-edges.nix b/nix/lib/aspects/fx/edges/instantiate-edges.nix
new file mode 100644
index 000000000..3fc88911e
--- /dev/null
+++ b/nix/lib/aspects/fx/edges/instantiate-edges.nix
@@ -0,0 +1,50 @@
+# instantiate-edges.nix — the per-host re-walk's surfaced edge set. The default-
+# fold merge edge is a pure projection of the SAME inputs resolve.nix's
+# mkInstantiateArgs derives for one host subtree (built from the SHARED
+# edges/default.nix constructor, converging on what the read-only oracle consumes,
+# spec §3a). The provides+routes edges are NOT re-derived: they are the CAPTURE
+# from the per-host materializeUnified fold (Task 18.2), so the surfaced per-host
+# set can never drift from what the fold actually dispatched.
+#
+# This helper builds the edge SET only; mkInstantiateArgs still returns
+# { modules; pkgs?; } unchanged (that dict is forwarded into spec.instantiate, so
+# it must NOT carry edges). The B′ hostConfigs pass reuses the per-host projection,
+# so it reuses this too.
+{ lib, den }:
+let
+ inherit (import ./default.nix { inherit lib; }) defaultFoldEdges;
+in
+{
+ # The per-host re-walk's surfaced edge set (default-fold merge @ hostScopeId +
+ # the CAPTURED provides+routes edges the per-host fold dispatched).
+ #
+ # name — sid → stable scope name (edge.nix scopeName); the unified
+ # ":" normalization the oracle/unified set use.
+ # scopeParent — the parent DAG slice (subtree/appendToParent walks).
+ # scopeIsolated — the isolation-AWARE marks the per-host walk uses; governs
+ # the default-fold subtree boundary (corollary 2 edge-absence).
+ # hostScopeId — the host subtree root (the single entity-root + route root).
+ # subtreeScopeIds — the host subtree's scope-id universe (defaultFoldEdges
+ # allScopeIds — its internal subtree walk's membership set).
+ # perScope — sid → { class → bool|content }; only `? class` membership
+ # is read by the default fold (classContentAt).
+ # capturedEdges — the provides+routes edges captured from the per-host
+ # materializeUnified{exposeEdges=true}.edges fold (Task 18.2).
+ mkInstantiateEdges =
+ {
+ name,
+ scopeParent,
+ scopeIsolated,
+ hostScopeId,
+ subtreeScopeIds,
+ perScope,
+ capturedEdges,
+ }:
+ (defaultFoldEdges {
+ inherit name scopeParent scopeIsolated;
+ classContentAt = perScope;
+ allScopeIds = subtreeScopeIds;
+ entityRootScopes = [ hostScopeId ];
+ })
+ ++ capturedEdges;
+}
diff --git a/nix/lib/aspects/fx/edges/instantiate.nix b/nix/lib/aspects/fx/edges/instantiate.nix
new file mode 100644
index 000000000..c74e13e0c
--- /dev/null
+++ b/nix/lib/aspects/fx/edges/instantiate.nix
@@ -0,0 +1,95 @@
+# instantiate.nix — the flake-output T-arm edge constructor (spec §2: T = a
+# flake-output path). An instantiate spec delivers a host/home's collected class
+# content to a flake-output attrpath (nixosConfigurations., etc.). Unlike
+# the entity-root T-arm (default fold / routes / provides), the flake-output arm
+# carries its OWN edge-construction rules — notably @system disambiguation for
+# colliding output names — which are T-arm-LOCAL, never general materializer
+# steps (spec §2 "the flake-output arm carries its own edge-construction rules").
+#
+# Both the read-only oracle (edge-trace.nix) and production (resolve.nix
+# applyInstantiates) source their flake-output descriptors from HERE, so they can
+# never disagree on the @system rule (spec §3a convergence). Production maps the
+# disambiguated descriptors to lazy instantiate thunks; the oracle maps them to
+# edge records. Neither path touches spec.instantiate — only path + system
+# metadata — so the descriptor build is laziness-safe (the thunk tree the
+# materializer builds, resolve.nix:instantiateConfigs, is what forces instantiate
+# on output ACCESS).
+{ lib, ... }:
+let
+ # spec → output descriptor { path; system; spec }, or [] when the spec has no
+ # output (intoAttr empty). path = [ "flake" ] ++ intoAttr.
+ specDescriptor =
+ spec:
+ let
+ hasOutput = (spec.intoAttr or [ ]) != [ ];
+ in
+ if !hasOutput then
+ [ ]
+ else
+ [
+ {
+ path = [ "flake" ] ++ spec.intoAttr;
+ system = spec.system or null;
+ inherit spec;
+ }
+ ];
+
+ # All output descriptors for a flat instantiate-spec list.
+ specDescriptors = specs: lib.concatMap specDescriptor specs;
+
+ # @system disambiguation (the T-arm-local rule). Entries colliding on the same
+ # output path are resolved:
+ # - DIFFERENT systems → qualify each output name with @system so both are
+ # accessible (e.g. homeConfigurations."ben@x86_64-linux"). Without this,
+ # lib.recursiveUpdate would deep-merge two independent module-system
+ # evaluations and corrupt both.
+ # - SAME entity via multiple policy paths (e.g. fleet + direct) → dedup,
+ # keeping the last (lib.warn on collision). The kept modules are compatible.
+ #
+ # Returns the disambiguated descriptor list (path possibly @system-qualified).
+ # Inspects path + system metadata ONLY — never spec.instantiate (laziness-safe).
+ disambiguate =
+ descriptors:
+ let
+ pathStr = builtins.concatStringsSep ".";
+ grouped = builtins.foldl' (
+ acc: entry:
+ let
+ key = pathStr entry.path;
+ in
+ acc // { ${key} = (acc.${key} or [ ]) ++ [ entry ]; }
+ ) { } descriptors;
+ resolveGroup =
+ _: entries:
+ if builtins.length entries <= 1 then
+ entries
+ else
+ let
+ systems = map (e: e.system or null) entries;
+ uniqueSystems = lib.unique systems;
+ isMultiSystem = builtins.length uniqueSystems > 1;
+ in
+ if isMultiSystem then
+ map (
+ e:
+ let
+ basePath = lib.init e.path;
+ baseName = lib.last e.path;
+ in
+ e // { path = basePath ++ [ "${baseName}@${e.system}" ]; }
+ ) entries
+ else
+ let
+ entry = lib.last entries;
+ in
+ lib.warnIf (builtins.length entries > 1)
+ "den: multiple instantiate specs target ${builtins.concatStringsSep "." entry.path} on ${
+ if entry.system != null then entry.system else "unknown"
+ }; keeping last"
+ [ entry ];
+ in
+ lib.concatLists (lib.mapAttrsToList resolveGroup grouped);
+in
+{
+ inherit specDescriptors disambiguate;
+}
diff --git a/nix/lib/aspects/fx/edges/materialize-unified.nix b/nix/lib/aspects/fx/edges/materialize-unified.nix
new file mode 100644
index 000000000..b196c1ec3
--- /dev/null
+++ b/nix/lib/aspects/fx/edges/materialize-unified.nix
@@ -0,0 +1,221 @@
+# materialize-unified.nix — the ordered-dispatch delivery engine (Task 17).
+#
+# Today the fx delivery pipeline materializes via PHASE FOLDS: phase2 applies ALL
+# provides (edges/provides.nix applyProvidesEdges), THEN phase3 applies ALL routes
+# (edges/route.nix applyRoutes, which itself toposorts its route specs). The
+# accumulator `{ classImports; perScope }` threads through both.
+#
+# materializeUnified collapses that into ONE ordered-dispatch fold that INTERLEAVES
+# provides + routes in `topoSortEdges` order, reusing the EXISTING per-spec
+# materializers (provides.applyOneProvide | route.applySimpleRouteEdge |
+# route.applyComplexRouteEdge). It is order-only (Design B): because
+#
+# - provides currently always precede routes, and
+# - the unified edge list is built `provides-edges ++ route-edges` and run
+# through a STABLE topoSortEdges (toposort.nix lock-in: independents keep
+# input order),
+#
+# independent edges keep the provides-before-routes order → byte-identical to
+# phase2∘phase3; the only dependent edges (synthesize / complex forwards) land
+# AFTER their producers exactly as applyRoutes' own internal toposort already
+# orders them. The final-extraction merge stays the existing assembleSubtree step
+# (only when doFinalMerge), unchanged.
+#
+# This engine IS production delivery at every site as of Task 17 (fxResolveFull,
+# fxResolveImports, the per-host re-walk, and the spawn re-entry all fold it). It
+# was proven byte-equivalent to the old phase2∘phase3 (+ optional assembleSubtree)
+# by the fx-materialize-unified suite (the materializeEquiv oracle in resolve.nix
+# keeps that comparison standing). Its `exposeEdges` mode (Task 18) returns the
+# folded provides+routes edge records so the production `edgeTrace` is captured,
+# not re-derived.
+{ lib, den }:
+let
+ routeEdgesMod = import ./route.nix { inherit lib den; };
+ providesMod = import ./provides.nix { inherit lib den; };
+ inherit (import ./materialize.nix { inherit lib den; }) assembleSubtree;
+ inherit (import ./toposort.nix { inherit lib; }) topoSortEdges;
+
+ inherit (routeEdgesMod)
+ applyComplexRouteEdge
+ applySimpleRouteEdge
+ orderedKeptRoutes
+ routeEdges
+ ;
+ inherit (providesMod)
+ applyOneProvide
+ dedupProvides
+ providesEdges
+ ;
+
+ # materializeUnified: ONE ordered-dispatch fold over the unified provides+routes
+ # edge set, byte-equivalent to phase2∘phase3 (+ optional assembleSubtree).
+ #
+ # pi — the Π(root) record (rootScopeId + the static slice). Read for
+ # the optional final merge (assembleSubtree) and rootScopeId.
+ # seed — phase-1 output { classImports; perScope } (the fold start).
+ # ctx — the pipeline base ctx (provides wrap context).
+ # scopedProvides — sid → [ provide specs ] (phase-2 input).
+ # scopedRoutes — sid → [ route specs ] (phase-3 input).
+ # spawnNode — the threaded node-spawn primitive (complex-forward source).
+ # buildForwardAspect — the synthesize constructor (handlers/forward.nix).
+ # doFinalMerge — true ⇒ return assembleSubtree { root; pi // acc }; false ⇒
+ # return the raw accumulator (caller reads classImports).
+ # exposeAcc — with doFinalMerge = true, return BOTH the merged output AND
+ # the post-provides+routes accumulator from the SAME fold:
+ # `{ merged = ; acc = <{classImports;perScope}>; }`.
+ # Lets the per-host / spawn edge collectors source the class-
+ # content presence map (acc.perScope) WITHOUT a second
+ # phase2∘phase3 fold (it replaced the old phase3.perScope).
+ materializeUnified =
+ {
+ pi,
+ seed,
+ ctx,
+ scopedProvides,
+ scopedRoutes,
+ spawnNode,
+ buildForwardAspect,
+ }:
+ {
+ doFinalMerge ? false,
+ # When set, return the ORDERED dispatch sequence [ { kind; spec } ] instead of
+ # the materialized accumulator — the Task-17 equivalence proof compares this to
+ # the production phase2∘phase3 dispatch order (the load-bearing Design-B claim).
+ exposeDispatch ? false,
+ # With doFinalMerge, also expose the post-fold accumulator (the edge
+ # collectors' content source). See the option doc above.
+ exposeAcc ? false,
+ # When set, ALSO carry the folded edge records — `map (p: p.edge)
+ # orderedPairs`, i.e. the SAME trace edges this fold dispatched, in fold
+ # (post-toposort) order. The literal-object trace capture primitive
+ # (Task 18). Composes with the doFinalMerge / exposeAcc return path (does
+ # NOT route through the early exposeDispatch return), so spawn / per-host
+ # sites can take `merged` + `acc` + `edges` together.
+ #
+ # Return-shape table (doFinalMerge, exposeAcc, exposeEdges) → shape:
+ # exposeDispatch = true (any flags) → [ { kind; spec } ] (early; unaffected)
+ # (false, _, false) → acc
+ # (false, _, true ) → acc // { edges; }
+ # (true, false, false) → merged (bare attrset)
+ # (true, false, true ) → { merged; edges; }
+ # (true, true, false) → { merged; acc; }
+ # (true, true, true ) → { merged; acc; edges; }
+ # The exposeEdges = false rows are byte-identical to the pre-Task-18
+ # returns: every existing caller (none pass exposeEdges) is unchanged.
+ exposeEdges ? false,
+ }:
+ let
+ inherit (pi)
+ rootScopeId
+ scopeContexts
+ scopeParent
+ scopeIsolated
+ ;
+ # Stable scope name for the trace-edge construction (ordering only).
+ name = den.lib.aspects.fx.edges.edge.scopeName {
+ scopeEntityKind = pi.scopeEntityKind or { };
+ inherit scopeContexts;
+ };
+
+ # ===== 1. The unified producer-edge SPEC list, phase order ============
+ # Provides FIRST (deduped, in dedupProvides order — phase2), routes SECOND
+ # (kept + toposorted, in orderedKeptRoutes order — phase3). Each spec is
+ # paired with its TRACE edge for the unified toposort (the trace edges carry
+ # the cell-model identity topoSortEdges reads; the SPEC drives materialization).
+
+ dedupedProvides = dedupProvides (lib.concatLists (lib.attrValues scopedProvides));
+ providesTraceEdges = providesEdges {
+ inherit name;
+ inherit scopedProvides;
+ };
+ # providesEdges dedups internally with the SAME dedupProvides, so the trace
+ # edge list aligns 1:1 with dedupedProvides (same order, same count).
+ providesPairs = lib.zipListsWith (spec: edge: {
+ kind = "provide";
+ inherit spec edge;
+ }) dedupedProvides providesTraceEdges;
+
+ orderedRoutes = orderedKeptRoutes rootScopeId (lib.concatLists (lib.attrValues scopedRoutes));
+ # Build the matching trace edges over the SAME kept+ordered route list, so
+ # the spec↔edge pairing is 1:1 and in the same order applyRoutes folds.
+ routeTraceEdges = routeEdges {
+ inherit name scopeParent rootScopeId;
+ rawRoutes = orderedRoutes;
+ };
+ routePairs = lib.zipListsWith (spec: edge: {
+ kind = "route";
+ inherit spec edge;
+ }) orderedRoutes routeTraceEdges;
+
+ pairs = providesPairs ++ routePairs;
+
+ # ===== 2. STABLE toposort over the paired trace edges =================
+ # Independents keep input order (provides-before-routes); synthesize edges
+ # land after the producers of their fromClass (the cell model). topoSortEdges
+ # returns reordered edge RECORDS, so we tag each edge with its source pair
+ # index (inert — the cell model reads target/source/mode/annotations only) and
+ # index the pairs back out after the sort.
+ taggedEdges = lib.imap0 (i: p: p.edge // { __pairIdx = i; }) pairs;
+ sortedTagged = topoSortEdges taggedEdges;
+ orderedPairs = map (e: builtins.elemAt pairs e.__pairIdx) sortedTagged;
+
+ # ===== 3. The interleaved ordered-dispatch fold ======================
+ # acc = { classImports; perScope }. Simple routes read the FROZEN phase-1
+ # perScope (seed.perScope), NOT the evolving acc — matching applyRoutes,
+ # whose route fold's `wrappedPerScope` is captured ONCE at fold start. Complex
+ # forwards read the EVOLVING acc (getCollectedSource reads acc.perScope).
+ wrappedPerScope = seed.perScope;
+ acc = builtins.foldl' (
+ prev: pair:
+ if pair.kind == "provide" then
+ applyOneProvide ctx prev pair.spec
+ else if pair.spec.__complexForward or false then
+ applyComplexRouteEdge prev {
+ route = pair.spec;
+ inherit
+ rootScopeId
+ scopeContexts
+ scopeParent
+ spawnNode
+ buildForwardAspect
+ ;
+ }
+ else
+ applySimpleRouteEdge prev {
+ route = pair.spec;
+ inherit wrappedPerScope scopeParent scopeIsolated;
+ }
+ ) seed orderedPairs;
+
+ # The folded edge records, in fold (post-toposort) order — the exact trace
+ # edges the dispatch above consumed. Captured for the literal-object trace.
+ foldedEdges = map (p: p.edge) orderedPairs;
+ in
+ if exposeDispatch then
+ map (p: {
+ inherit (p) kind spec;
+ }) orderedPairs
+ else if doFinalMerge then
+ let
+ merged = assembleSubtree {
+ root = rootScopeId;
+ pi = pi // acc;
+ };
+ in
+ if exposeAcc then
+ { inherit merged acc; } // lib.optionalAttrs exposeEdges { edges = foldedEdges; }
+ else if exposeEdges then
+ {
+ inherit merged;
+ edges = foldedEdges;
+ }
+ else
+ merged
+ else if exposeEdges then
+ acc // { edges = foldedEdges; }
+ else
+ acc;
+in
+{
+ inherit materializeUnified;
+}
diff --git a/nix/lib/aspects/fx/edges/materialize.nix b/nix/lib/aspects/fx/edges/materialize.nix
new file mode 100644
index 000000000..2f9def5e3
--- /dev/null
+++ b/nix/lib/aspects/fx/edges/materialize.nix
@@ -0,0 +1,354 @@
+# materialize.nix — the edge materializer (spec §3c "materialization"). Given a
+# context projection Π and the edges targeting a root, produce the root's
+# per-class content. This is the single mechanism that replaces the phase-fold
+# re-entries' final extraction; phase ordering becomes edge toposort (corollary
+# 5) as later mechanisms are ported.
+#
+# The mode arms are split across files: `merge` (default-fold port) is live here;
+# `nest`/`nest-verbatim` (route delivery via materializeRouteEdge) and the synthesize
+# (complex-forward) + provides edge materialization live in edges/route.nix +
+# edges/provides.nix. The else-throw in the `materialize` switch below is NOT a
+# stub — it is permanently correct: `assembleSubtree` carries merge edges only;
+# route nest/nest-verbatim + synthesize delivery + provides are folded upstream by
+# the unified ordered-dispatch engine (edges/materialize-unified.nix), which reuses
+# those per-spec materializers and calls assembleSubtree for the final merge.
+#
+# The spawn port is `assembleSpawnSubtree` (below): the spawn's provides + routes
+# fold + its isolation-BLIND, dedup-FREE final extraction, expressed over this
+# machinery via materializeUnified with the `isolationMode = "blind"` +
+# `dedupMode = "raw"` Π dials and an explicit `allScopeIds` subtree-universe
+# override.
+#
+# DESIGN INVARIANTS (spec §2 corollaries; enforced by the entity-isolation suite
+# and the delivery-edges fixtures):
+# - `materialize` (the mode switch) contains the ONLY mode switch
+# (merge | nest | nest-verbatim).
+# - The mode switch carries NO mechanism vocabulary (no route/provides/spawn/
+# instantiate names): mechanisms are dissolved into edges before they reach
+# it. Mechanism-specific Π-*builders* (e.g. `assembleSpawnSubtree`) MAY name
+# their mechanism — they construct mechanism-shaped Π for the generic
+# consumer; only the switch itself stays vocabulary-free.
+# - It performs NO isolation-flag reads: isolation is consumed at edge
+# CONSTRUCTION (corollary 2 — isolation is edge-absence). `assembleSubtree`
+# resolves the subtree boundary (via scope-walk.subtreeScopes, governed by
+# Π's EXPLICIT isolationMode) BEFORE handing merge edges to the switch, so
+# the switch only walks an already-bounded scope list.
+{ lib, den, ... }:
+let
+ inherit (import ../scope-walk.nix { inherit lib; }) subtreeScopes dedupByKey;
+ inherit (import ./default.nix { inherit lib; }) defaultFoldEdges;
+ inherit (import ./pi.nix { inherit lib; }) mkStaticPi;
+ inherit (import ./edge.nix { inherit lib; }) scopeName;
+in
+rec {
+ # The Π(root) record shape (§A). Per-field comments note the invariant that
+ # constrains each. The default-fold merge consumes only
+ # perScope/scopeParent/scopeIsolated/rootScopeId/dedupMode/allScopeIds; the
+ # remaining fields are the route/provides/spawn projection inputs (the variants
+ # are visible data instead of implicit state-threading).
+ #
+ # Π(root) = {
+ # scopeContexts; # §9 subtree-only context slice. NOT read by the
+ # # default-fold merge (which reads perScope buckets
+ # # directly); routes/provides/synthesize materialize
+ # # against it.
+ # contextsAreAugmented; # §8 DELIBERATE (cycle-forced) — B′ gets raw contexts.
+ # # Carried so a unified assembleSubtree knows which it
+ # # got.
+ # classImports; # (caller-merged: accumulator) §2 the collected class
+ # # buckets, per-scope (perScope). The default-fold merge
+ # # SOURCE. TARGET semantics = drained (the B′ baseDrain
+ # # divergence is fixed via the augmented-context build,
+ # # §A #8/#2/#7 option b).
+ # provides; # (caller-merged: route/provides fold input, not static-Π)
+ # # §9 subtree+ancestors; §3 spawn's own suffices.
+ # routes; # (caller-merged: route/provides fold input, not static-Π)
+ # # §9 subtree+ancestors; §4 parent-subtree routes merge
+ # # into a spawn.
+ # rootScopeId; # §5 DELIBERATE — the subtree root (pipeline root |
+ # # hostScopeId | spawnRoot). The merge target's root.
+ # scopeParent; # the parent DAG slice (subtree/ancestor walks).
+ # scopeIsolated; # §6/§10 — the isolation marks. Consulted at EXTRACTION
+ # # via subtreeScopes, governed by isolationMode; never
+ # # read inside the mode switch.
+ # isolationMode; # §6 `aware` (default) | `blind` (spawn final extraction
+ # # invariant). EXPLICIT — never defaulted.
+ # dedupMode ? "dedup"; # the merge-mode collection dial. "dedup" (default) =
+ # # first-occurrence-wins cross-scope key dedup
+ # # (extractSubtreeModules / wrapPerScope semantics).
+ # # "raw" = dedup-FREE concat (the spawn final-extraction
+ # # invariant): the spawn's phase1 wrapPerScope
+ # # already key-deduped INTO the perScope buckets, and the
+ # # final per-scope concat must NOT re-dedup across scopes
+ # # (a duplicate cross-scope module is a deliberate keyless
+ # # re-emission the target's own evalModules reconciles).
+ # allScopeIds ? null; # optional subtree-universe override. null ⇒ derive from
+ # # perScope attrnames (the entity-root/per-host re-entries).
+ # # The spawn re-entry passes mergedScopeParent ∪
+ # # scopedRoutes keys EXPLICITLY: a route-only scope can sit
+ # # on the subtree parent-chain without a perScope bucket, so
+ # # the membership universe is WIDER than perScope alone.
+ # classInject ? null; # §1 the resolved entity class to inject into context
+ # # args; no observable witness — defensive projection,
+ # # default off.
+ # }
+
+ # Resolve Π's isolation marks into the `isolated` set the subtree walk takes,
+ # governed by the EXPLICIT isolationMode (§A: "pass it EXPLICITLY, never by
+ # defaulting"). blind ⇒ {} (the spawn final-extraction invariant); aware ⇒ the
+ # scope isolation marks. This is the ONLY place an isolation mark is consulted,
+ # and it happens at CONSTRUCTION (assembleSubtree), not inside the switch.
+ isolatedSetOf =
+ pi:
+ if pi.isolationMode == "blind" then
+ { }
+ else if pi.isolationMode == "aware" then
+ pi.scopeIsolated
+ else
+ throw "den materialize: isolationMode must be \"aware\" | \"blind\", got ${builtins.toJSON pi.isolationMode}";
+
+ # Collect the merge source for a (root, class) target: the class bucket of the
+ # already-bounded subtree. With dedupMode = "dedup" (default) this is the
+ # wrapPerScope cross-scope dedup + extractSubtreeModules semantics (first-
+ # occurrence-wins by key); with dedupMode = "raw" it is a dedup-FREE concat
+ # (the spawn final-extraction invariant — phase1 already key-deduped into the
+ # buckets, so a remaining cross-scope duplicate is a deliberate keyless
+ # re-emission, not a dedup target).
+ # perScope — sid → { class → [ modules ] } (the wrapped buckets).
+ # subtreeScopeIds — the resolved, isolation-bounded scope list.
+ # dedupMode — "dedup" | "raw".
+ collectMerge =
+ perScope: subtreeScopeIds: dedupMode: cls:
+ if dedupMode == "raw" then
+ # Dedup-free: iterate the perScope buckets in perScope-attrname order
+ # (the spawn final extraction's exact iteration), restricted to subtree
+ # membership. Order is load-bearing without a key-dedup, so we walk
+ # perScope keys directly rather than the allScopeIds-ordered subtree list.
+ let
+ member = lib.genAttrs subtreeScopeIds (_: true);
+ in
+ lib.concatMap (sid: perScope.${sid}.${cls} or [ ]) (
+ builtins.filter (sid: member ? ${sid}) (builtins.attrNames perScope)
+ )
+ else
+ dedupByKey (m: m.key or null) (lib.concatMap (sid: perScope.${sid}.${cls} or [ ]) subtreeScopeIds);
+
+ # materialize: Π + an edge list → { class → [ modules ] }. The ONLY mode
+ # switch for default-fold extraction. The merge arm here is the per-root final
+ # extraction; routes + provides are interleaved upstream by the unified ordered-
+ # dispatch fold (edges/materialize-unified.nix materializeUnified, the single
+ # delivery engine for every site), which calls assembleSubtree for the final
+ # merge — so `assembleSubtree` carries merge edges only. `perScope` and the
+ # resolved subtree are passed via the closure `ctx`.
+ materialize =
+ pi: ctx: edges:
+ let
+ step =
+ acc: edge:
+ let
+ cls = edge.target.class;
+ in
+ if edge.mode == "merge" then
+ # merge: module-list union of the bounded subtree's bucket, deduped or
+ # raw per ctx.dedupMode (the spawn final extraction is dedup-free).
+ acc
+ // {
+ ${cls} = (acc.${cls} or [ ]) ++ collectMerge ctx.perScope ctx.subtreeScopeIds ctx.dedupMode cls;
+ }
+ else
+ throw "den materialize: assembleSubtree edge mode ${builtins.toJSON edge.mode} (nest/nest-verbatim route delivery folds through edges/route.nix:materializeRouteEdge, not assembleSubtree)";
+ in
+ builtins.foldl' step { } edges;
+
+ # assembleSubtree: materialize all edges targeting `root`. For the default-fold
+ # port this is the per-root final extraction that replaces extractSubtreeModules
+ # — the merge-mode materialization of the root's own default-fold edges.
+ #
+ # The subtree boundary (isolation) is resolved HERE (construction time,
+ # corollary 2), producing the bounded scope list the merge switch walks. The
+ # mode switch never sees an isolation flag.
+ #
+ # root — the root scope id whose content is being assembled.
+ # pi — the Π(root) projection (shape above).
+ # Returns { class → [ modules ] }; a class with no content is absent (callers
+ # treat absence as null, matching extractSubtreeModules' `== [] then null`).
+ assembleSubtree =
+ { root, pi }:
+ let
+ # The subtree-membership universe. Default = perScope attrnames (the entity-
+ # root / per-host re-entries). The spawn re-entry passes pi.allScopeIds
+ # explicitly (mergedScopeParent ∪ scopedRoutes keys) because a route-only
+ # scope can sit on the subtree parent-chain without a perScope bucket.
+ allScopeIds = pi.allScopeIds or (builtins.attrNames pi.perScope);
+ # The merge collection dial (§A spawn invariant): default dedup; "raw" =
+ # dedup-free concat (spawn final extraction).
+ dedupMode = pi.dedupMode or "dedup";
+ # The isolation set the subtree boundary uses, governed by pi.isolationMode.
+ # Computed ONCE here so the merge-collection walk and the edge constructor's
+ # own internal subtree walk agree on the boundary.
+ isolated = isolatedSetOf pi;
+ subtreeScopeIds = subtreeScopes {
+ inherit (pi) scopeParent;
+ inherit isolated root allScopeIds;
+ };
+ # Default-fold edges for THIS root, built by the SHARED constructor
+ # (edges/default.nix defaultFoldEdges) — the same function the read-only
+ # oracle (edge-trace.nix) consumes, so production and oracle agree on the
+ # CONSTRUCTOR, not merely the primitives (spec §3a convergence).
+ # Adaptation to assembleSubtree's per-root, isolationMode-governed call:
+ # - entityRootScopes = [ root ] (this single root; isolated descendants
+ # are their own roots, materialized by their own assembleSubtree).
+ # - scopeIsolated = `isolated` (already resolved through isolationMode, so
+ # the constructor's internal subtree walk matches subtreeScopeIds).
+ # - classContentAt = pi.perScope (only `? class` membership is read).
+ # - name = identity: the merge switch reads ONLY edge.target.class, so the
+ # T.root naming used by the oracle is irrelevant here; identity keeps the
+ # emitted target.root = the raw sid, unchanged from the prior inline form.
+ edges = defaultFoldEdges {
+ name = sid: sid;
+ inherit (pi) scopeParent;
+ scopeIsolated = isolated;
+ classContentAt = pi.perScope;
+ inherit allScopeIds;
+ entityRootScopes = [ root ];
+ };
+ in
+ materialize pi {
+ inherit (pi) perScope;
+ inherit subtreeScopeIds dedupMode;
+ } edges;
+
+ # assembleSpawnSubtree: the spawn node's full phase-fold + final extraction,
+ # expressed over the edge machinery. The spawn's inline
+ # phase1(wrapPerScope) → phase2(applyProvides) → phase3(applyRoutes) →
+ # isolation-BLIND dedup-FREE subtree concat is reproduced here as ONE entry:
+ # the phase fold builds the perScope buckets, then `assembleSubtree` performs
+ # the final per-root extraction with the spawn's two distinguishing dials —
+ # `isolationMode = "blind"` (documented invariant: no isolated
+ # descendant can appear under a spawnRoot, since isolated kinds are created by
+ # resolve.to in the HOST pipeline, never via spawnNode) and `dedupMode = "raw"`
+ # (the phase1 wrapPerScope already key-deduped INTO the buckets; the final
+ # cross-scope concat must NOT re-dedup — a remaining duplicate is a deliberate
+ # keyless re-emission the target's own evalModules reconciles).
+ #
+ # The phase primitives are passed IN (wrapPerScope), so this helper introduces no
+ # resolve.nix import — the spawn keeps its existing injection seam; only the
+ # inline phase-1 CALL expression moves here (provides + routes now fold through
+ # materializeUnified, not injected applyProvides/applyRoutes primitives).
+ #
+ # class — the single class this spawn node materializes.
+ # spawnRoot — the spawn subtree root.
+ # ctx — the pipeline base ctx (phase fallback context).
+ # augmented — the spawn's assemblePipes-augmented contexts.
+ # mergedClassImports — phase1 source (parent + spawned, pipe-stripped).
+ # mergedScopeParent — the merged parent DAG (spawnRoot linked up to host).
+ # mergedScopeIsolated — merged isolation marks (inert under blind mode).
+ # ownProvides — the spawn's OWN provides (parent provides are
+ # deliberately NOT reapplied).
+ # mergedSpawnRoutes — parent-subtree routes (routeKey-deduped) ⊕ spawn own
+ # (parent routes MUST merge into the spawn).
+ # allScopeIds — the subtree-membership universe (mergedScopeParent ∪
+ # scopedRoutes keys — WIDER than perScope alone).
+ # selfRef — the spawn primitive (nested-forward resolver).
+ # wrapPerScope — the injected phase-1 wrap primitive.
+ assembleSpawnSubtree =
+ {
+ class,
+ spawnRoot,
+ ctx,
+ augmented,
+ scopeEntityKind,
+ mergedClassImports,
+ mergedScopeParent,
+ mergedScopeIsolated,
+ ownProvides,
+ mergedSpawnRoutes,
+ allScopeIds,
+ selfRef,
+ wrapPerScope,
+ }:
+ let
+ phase1 = wrapPerScope ctx augmented mergedClassImports;
+ # Production delivery (Task 17/18): the spawn final extraction folds
+ # materializeUnified (doFinalMerge = true → it runs assembleSubtree at
+ # spawnRoot), replacing the phase2 (provides) ∘ phase3 (routes) ∘
+ # assembleSubtree sequence. exposeAcc surfaces the post-fold accumulator so
+ # the `edges` collector reads its perScope from the SAME fold — there is no
+ # separate phase2∘phase3 fold to diverge from. The Π keeps the spawn's two
+ # distinguishing dials — isolationMode = "blind" + dedupMode = "raw" — and the
+ # EXPLICIT allScopeIds subtree-universe override. scopeContexts = augmented is
+ # the SAME contexts the complex-forward path reads for source resolution.
+ piUnified =
+ (mkStaticPi {
+ rootScopeId = spawnRoot;
+ scopeContexts = augmented;
+ scopeParent = mergedScopeParent;
+ scopeIsolated = mergedScopeIsolated;
+ isolationMode = "blind";
+ dedupMode = "raw";
+ inherit allScopeIds;
+ })
+ // {
+ inherit scopeEntityKind;
+ };
+ # materializeUnified is reached via the lazy `den` namespace (NOT a direct
+ # import) — materialize-unified.nix imports THIS file for assembleSubtree, so
+ # a direct import would cycle. Nix laziness makes the namespace access fine.
+ materialized =
+ den.lib.aspects.fx.edges.materializeUnified.materializeUnified
+ {
+ pi = piUnified;
+ inherit ctx;
+ seed = phase1;
+ scopedProvides = ownProvides;
+ scopedRoutes = mergedSpawnRoutes;
+ spawnNode = selfRef;
+ buildForwardAspect = den.lib.aspects.fx.handlers.buildForwardAspect;
+ }
+ {
+ doFinalMerge = true;
+ exposeAcc = true;
+ # Task 18.2: CAPTURE the provides+routes edges the fold dispatched
+ # (materialized.edges), instead of re-deriving them below via
+ # providesEdges/routeEdges. The default-fold edge stays constructor-
+ # built (deterministic structural edge, not a drift surface).
+ exposeEdges = true;
+ };
+ # Π used by the `edges` collector's isolation-set resolution (blind ⇒ {}).
+ pi = piUnified;
+ # The SURFACED edge set for THIS spawn node — the spawn's default-fold merge
+ # edge(s) (built here via the shared defaultFoldEdges constructor) ++ the
+ # spawn's provides + re-applied mergedSpawnRoutes route edges (CAPTURED from
+ # the materializeUnified fold below, Task 18.2). `.imports` consumers read only
+ # imports; `.edges` is now consumed by the production `edgeTrace` (resolve.nix)
+ # — it is the spawn's contribution to the captured production edge object.
+ #
+ # DELIBERATE name-space note: assembleSubtree's INTERNAL defaultFoldEdges
+ # uses `name = sid: sid` (identity) because the merge switch reads only
+ # `edge.target.class`. The SURFACED edges here use `scopeName`
+ # (`:`) to match the oracle/unified-set normalization. These
+ # two name spaces are intentionally DIFFERENT — do NOT unify them.
+ name = scopeName {
+ inherit scopeEntityKind;
+ scopeContexts = augmented;
+ };
+ edges =
+ (defaultFoldEdges {
+ inherit name;
+ scopeParent = mergedScopeParent;
+ scopeIsolated = isolatedSetOf pi; # blind ⇒ {} — matches the merge boundary
+ classContentAt = materialized.acc.perScope;
+ inherit allScopeIds;
+ entityRootScopes = [ spawnRoot ];
+ })
+ # Task 18.2: the provides+routes edges are the CAPTURE from the SAME
+ # materializeUnified fold above (materialized.edges), not a re-derivation
+ # via providesEdges/routeEdges — so the surfaced set can never drift from
+ # what the fold actually dispatched.
+ ++ materialized.edges;
+ in
+ {
+ imports = materialized.merged.${class} or [ ];
+ inherit edges;
+ };
+}
diff --git a/nix/lib/aspects/fx/edges/parity.nix b/nix/lib/aspects/fx/edges/parity.nix
new file mode 100644
index 000000000..6c1c28aad
--- /dev/null
+++ b/nix/lib/aspects/fx/edges/parity.nix
@@ -0,0 +1,25 @@
+{ lib }:
+let
+ inherit (import ./edge.nix { inherit lib; }) edgeSortKey;
+in
+{
+ # assertEdgeParity — the cross-pipeline parity diff. Diffs two delivery-edge
+ # traces by normalized identity key (T,P,S,M; annotations EXCLUDED — the parity
+ # contract is STRUCTURAL, spec §4). Returns matched + the asymmetric differences
+ # + a boolean. The §5.1 deviation classification (bug-in-hoag | bug-in-v1 |
+ # intentional-v2) is a HUMAN step over this diff (parity/edge-schema.md runbook),
+ # not automated here.
+ assertEdgeParity =
+ { expected, actual }:
+ let
+ keyOf = edgeSortKey;
+ expKeys = lib.genAttrs (map keyOf expected) (_: true);
+ actKeys = lib.genAttrs (map keyOf actual) (_: true);
+ in
+ rec {
+ matched = lib.filter (e: actKeys ? ${keyOf e}) expected;
+ missingFromActual = lib.filter (e: !(actKeys ? ${keyOf e})) expected;
+ extraInActual = lib.filter (e: !(expKeys ? ${keyOf e})) actual;
+ parity = missingFromActual == [ ] && extraInActual == [ ];
+ };
+}
diff --git a/nix/lib/aspects/fx/edges/pi.nix b/nix/lib/aspects/fx/edges/pi.nix
new file mode 100644
index 000000000..ef3e31c8a
--- /dev/null
+++ b/nix/lib/aspects/fx/edges/pi.nix
@@ -0,0 +1,46 @@
+# pi.nix — the static Π(root) projection builder. Π is the root-pure context
+# slice every delivery edge materializes against; this constructor assembles the
+# STATIC subset — the 9 fields no edge step ever mutates — from already-projected
+# pipeline end-state. NOT static Π and therefore NOT built here (the caller merges
+# them in after): the fold accumulator { classImports; perScope } (read+written by
+# edge steps) and the route/provides fold-input fields (provides; routes).
+# Per-root, never global — roots differ in isolationMode / dedupMode / allScopeIds,
+# so each edge materializes under its OWN root's dials.
+{ lib, ... }:
+{
+ # rootScopeId — the subtree root (pipeline root | hostScopeId | spawnRoot).
+ # scopeContexts — the context slice route/provides/synthesize materialize
+ # against (inert for the default-fold merge).
+ # scopeParent / scopeIsolated — the parent DAG + isolation marks.
+ # isolationMode — "aware" | "blind" (EXPLICIT, never defaulted).
+ # contextsAreAugmented — whether scopeContexts carries assemblePipes output.
+ # dedupMode — "dedup" (default) | "raw" (spawn final extraction).
+ # allScopeIds — optional subtree-universe override; OMITTED when null
+ # so assembleSubtree derives it from perScope attrnames.
+ # classInject — resolved entity class to inject (default null).
+ mkStaticPi =
+ {
+ rootScopeId,
+ scopeContexts,
+ scopeParent,
+ scopeIsolated,
+ isolationMode,
+ contextsAreAugmented ? true,
+ dedupMode ? "dedup",
+ allScopeIds ? null,
+ classInject ? null,
+ }:
+ {
+ inherit
+ rootScopeId
+ scopeContexts
+ scopeParent
+ scopeIsolated
+ isolationMode
+ contextsAreAugmented
+ dedupMode
+ classInject
+ ;
+ }
+ // lib.optionalAttrs (allScopeIds != null) { inherit allScopeIds; };
+}
diff --git a/nix/lib/aspects/fx/edges/provides.nix b/nix/lib/aspects/fx/edges/provides.nix
new file mode 100644
index 000000000..ea10a8507
--- /dev/null
+++ b/nix/lib/aspects/fx/edges/provides.nix
@@ -0,0 +1,140 @@
+# provides.nix — the provides edge constructor (spec §2 corollary 4, §B
+# Decision 1). A `policy.provide` spec materializes as a TWO-EDGE composition
+# `nest ∘ merge`:
+#
+# 1. a NEST edge `(S = the provided module, T = (sourceScope-bucket, class),
+# P = spec.path, M = nest)` — the `setAttrByPath path module` construction;
+# 2. the DEFAULT MERGE edge every entity-root scope already has (corollary 1) —
+# the nested module is appended into the SOURCE scope's own bucket
+# (perScope.${sid}.${class}), and the default subtree fold carries it to the
+# entity root exactly like any other collected content.
+#
+# This file emits ONLY the nest edge into the source scope's bucket; the merge
+# half rides the existing default-fold edge (do NOT emit a literal second edge in
+# materialization — the trace keeps the `mergeHalf = "default-fold"` annotation
+# per the §3a oracle convention). M stays the closed enum — provides adds no mode.
+#
+# Edge identity / dedup is the composite key `(policyName, intoClass, path)` — NOT
+# scope-keyed: two provides from one policy into one class+path collapse to one
+# edge regardless of which scope registered them (first-occurrence-wins). This is
+# deliberately coarser than route dedup (which keys on scope): provides identity
+# is policy-authored intent, not scope of registration (§B Decision 1).
+#
+# Two projections share ONE dedup (dedupProvides):
+# - the trace-facing edge RECORD (identity + annotations, no content) consumed
+# by the read-only oracle (edge-trace.nix);
+# - the MATERIALIZATION (the actual wrapped module appended to the source-scope
+# bucket) consumed by resolve.nix's phase-2 fold, replacing applyProvides.
+{ lib, den }:
+let
+ inherit (import ./edge.nix { inherit lib; }) mkEdge collected rootTarget;
+ inherit (import ../scope-walk.nix { inherit lib; }) dedupByKey;
+
+ # Dedup provides by composite key (policyName/class/path). Null-keyed specs
+ # (no __providePolicyName) are always kept. First-occurrence wins.
+ dedupProvides = dedupByKey (
+ s:
+ let
+ pn = s.__providePolicyName or null;
+ in
+ if pn != null then "${pn}/${s.class}/${lib.concatStringsSep "/" (s.path or [ ])}" else null
+ );
+
+ # ===== materialization (ported from resolve.nix:applyProvides) ==========
+ # Apply the deduped provides specs onto an accumulator { classImports; perScope; }.
+ # Each spec → the nest-at-P module (setAttrByPath), wrapped via wrapClassModule
+ # (module-identity wrapping, NOT a mode); unsatisfied wraps are DROPPED. The
+ # wrapped module is appended to BOTH the flat classImports aggregate AND the
+ # SOURCE scope's perScope bucket — the latter is the merge half (subtree-
+ # collectible, visible to a later route's getCollectedSource / subtree walk).
+ #
+ # ctx — the pipeline base ctx (the wrap context for every provide).
+ # scopedProvides — sid → [ provide specs ] (the registered provides).
+ # acc — { classImports; perScope; } (phase-1 output).
+ # Materialize ONE provides spec onto the accumulator. Factored out of the
+ # applyProvidesEdges fold (additive) so a single provides spec can be
+ # materialized in interleaved order by materializeUnified (Task 17) — the per-
+ # spec body is IDENTICAL, so applyProvidesEdges (= foldl' applyOneProvide) and
+ # the interleaved fold land byte-identical content.
+ applyOneProvide =
+ ctx: prev: spec:
+ let
+ targetClass = spec.class;
+ path = spec.path or [ ];
+ sid = spec.sourceScopeId;
+ # Nest-at-P construction (P=[] degenerates to a plain merge contribution).
+ rawModule = if path == [ ] then spec.module else lib.setAttrByPath path spec.module;
+ wrapped = den.lib.aspects.fx.aspect.wrapClassModule {
+ inherit ctx;
+ module = rawModule;
+ aspectPolicy = null;
+ globalPolicy = null;
+ };
+ wrappedMod =
+ if wrapped.unsatisfied or false then
+ [ ]
+ else
+ let
+ loc = "${targetClass}@/${lib.concatStringsSep "/" path}";
+ in
+ [ (lib.setDefaultModuleLocation loc wrapped.module) ];
+ in
+ {
+ classImports = prev.classImports // {
+ ${targetClass} = (prev.classImports.${targetClass} or [ ]) ++ wrappedMod;
+ };
+ perScope = prev.perScope // {
+ ${sid} = (prev.perScope.${sid} or { }) // {
+ ${targetClass} = ((prev.perScope.${sid} or { }).${targetClass} or [ ]) ++ wrappedMod;
+ };
+ };
+ };
+
+ applyProvidesEdges =
+ ctx: scopedProvides: acc:
+ let
+ allProvides = dedupProvides (lib.concatLists (lib.attrValues scopedProvides));
+ in
+ builtins.foldl' (applyOneProvide ctx) acc allProvides;
+
+ # ===== trace-facing provides edge constructor (§8 identity, no content) =
+ # Renders the deduped provides specs as edge RECORDS for the oracle. Each is the
+ # NEST edge into the source scope's bucket (the merge half is the default-fold
+ # edge, annotated). P=[] degenerates to a merge contribution (no nesting).
+ #
+ # name — sid → stable scope name (edge.nix scopeName).
+ # scopedProvides — sid → [ provide specs ].
+ providesEdges =
+ { name, scopedProvides }:
+ let
+ allProvides = builtins.concatLists (lib.attrValues scopedProvides);
+ dedupedProvides = dedupProvides allProvides;
+ in
+ map (
+ spec:
+ let
+ path = spec.path or [ ];
+ sid = spec.sourceScopeId;
+ in
+ mkEdge {
+ source = collected (name sid) spec.class;
+ target = rootTarget (name sid) spec.class;
+ inherit path;
+ mode = if path == [ ] then "merge" else "nest";
+ annotations = {
+ providesPolicyName = spec.__providePolicyName or null;
+ # The merge half (delivery to the entity root) is the default-fold edge;
+ # this nest edge only constructs the placed module (§B Decision 1).
+ mergeHalf = "default-fold";
+ };
+ }
+ ) dedupedProvides;
+in
+{
+ inherit
+ dedupProvides
+ applyOneProvide
+ applyProvidesEdges
+ providesEdges
+ ;
+}
diff --git a/nix/lib/aspects/fx/edges/route.nix b/nix/lib/aspects/fx/edges/route.nix
new file mode 100644
index 000000000..3abced184
--- /dev/null
+++ b/nix/lib/aspects/fx/edges/route.nix
@@ -0,0 +1,855 @@
+# route.nix — the simple-route edge constructor (spec §3c "edge collection",
+# §B Decision 4 matrix). A `scopedRoutes` simple-route spec becomes a delivery
+# edge per the §B 10-cell reachable matrix; the edge's MODE (merge | nest |
+# nest-verbatim) and edge PROPERTIES (adaptArgs, guard, #572-combine,
+# ensureTargetPath, adapterKey, instantiate, collectSubtree, to=parent) drive
+# the materializer's mode switch (edges/materialize.nix). No new modes — the §B
+# hybrids decompose into mode + properties.
+#
+# This file owns BOTH route halves: SIMPLE routes (delivery edges, §B Decision 4)
+# and COMPLEX (__complexForward) routes (synthesize edges, §B Decision 2). The
+# `applyRoutes` fold dispatches between them; resolve.nix and spawn-node thread
+# their state in and get the assembled buckets back (the phase-3 materialization).
+#
+# Two projections share ONE classification (classifyRoute):
+# - the trace-facing edge RECORD (identity + annotations, no content) consumed
+# by the read-only oracle (edge-trace.nix) — §8 records identity, not content;
+# - the MATERIALIZATION (the actual wrapped module list + target scope) consumed
+# by the `applyRoutes` fold (applySimpleRouteEdge / applyComplexRouteEdge).
+# Both derive from the same per-route cell decision, so oracle and production can
+# never disagree on which §B cell a route is.
+#
+# Ordering + dedup (§B Decision 5 + dedupRoutes suppressions) live HERE, at edge
+# construction: dedupRoutes' two suppressions (adapterKey@scope identity dedup;
+# redundant-root edge-set shadowing) and topoSortRoutes' producer→consumer order
+# (now a general edge toposort with loud cycle throw).
+{ lib, den }:
+let
+ inherit (import ./edge.nix { inherit lib; })
+ mkEdge
+ collected
+ synthesize
+ rootTarget
+ ;
+ inherit (import ../scope-walk.nix { inherit lib; }) subtreeScopes;
+
+ # ===== materialization mechanics (ported from route/wrap.nix) ==========
+ # These are MODE mechanics (how `nest`/`nest-verbatim` place a module at P),
+ # not mechanism vocabulary. They are invoked by the materializer's mode switch
+ # via the closure each edge carries.
+
+ # Freeform type for route nesting evalModules: merges like NixOS (attrsets
+ # deep-merge, lists concatenate) but errors on conflicting scalar/derivation
+ # values instead of silently clobbering.
+ mergeableType = lib.mkOptionType {
+ name = "mergeable";
+ description = "auto-merged value (attrsets merge, lists concatenate, scalars conflict)";
+ merge =
+ loc: defs:
+ let
+ values = map (d: d.value) defs;
+ first = builtins.head values;
+ allLists = builtins.all builtins.isList values;
+ # Derivations are attrsets but must not deep-merge — treat as opaque.
+ allMergeableAttrs = builtins.all (v: builtins.isAttrs v && !(lib.isDerivation v)) values;
+ in
+ if builtins.length defs == 1 then
+ first
+ else if allLists then
+ builtins.concatLists values
+ else if allMergeableAttrs then
+ (lib.types.lazyAttrsOf mergeableType).merge loc defs
+ else
+ throw "den: the option `${lib.showOption loc}' has conflicting definitions from multiple aspects";
+ };
+ nestingFreeformType = lib.types.lazyAttrsOf mergeableType;
+
+ # Adapt a module's args when path is empty (top-level adaptArgs).
+ adaptModule =
+ adaptArgs: path: mod:
+ if adaptArgs == null || path != [ ] then
+ mod
+ else if builtins.isFunction mod then
+ args: mod (adaptArgs args)
+ else
+ mod;
+
+ # Nest a module at a path using submodule evaluation with adapted specialArgs.
+ nestWithAdaptArgs =
+ path: adaptArgs: mod: args:
+ let
+ fullArgs = args // (args.config._module.args or { });
+ adapted = adaptArgs fullArgs;
+ sourceModules = if builtins.isAttrs mod && mod ? imports then mod.imports else [ mod ];
+ evaluated = lib.evalModules {
+ specialArgs = adapted;
+ modules = [
+ { config._module.freeformType = nestingFreeformType; }
+ ]
+ ++ sourceModules;
+ };
+ in
+ {
+ config = lib.setAttrByPath path (
+ builtins.removeAttrs evaluated.config [
+ "_module"
+ "warnings"
+ "assertions"
+ ]
+ );
+ };
+
+ # Nest a module at a path by evaluating imports with full outer args.
+ nestPlain =
+ path: mod: args:
+ let
+ fullArgs = args // (args.config._module.args or { });
+ resolveImport = imp: if builtins.isFunction imp then imp fullArgs else imp;
+ sourceModules = if builtins.isAttrs mod && mod ? imports then mod.imports else [ mod ];
+ resolved = map resolveImport sourceModules;
+ evaluated = lib.evalModules {
+ specialArgs = fullArgs;
+ modules = [
+ { config._module.freeformType = nestingFreeformType; }
+ ]
+ ++ resolved;
+ };
+ in
+ {
+ config = lib.setAttrByPath path (
+ builtins.removeAttrs evaluated.config [
+ "_module"
+ "warnings"
+ "assertions"
+ ]
+ );
+ };
+
+ # Nest a module at a path BY REFERENCE, keeping the collected wrapper INTACT
+ # (key/_file preserved) — required when the target re-instantiates the
+ # delivered content as its own NixOS system (nest-verbatim mode).
+ nestVerbatim = path: mod: {
+ config = lib.setAttrByPath path { imports = [ mod ]; };
+ };
+
+ # Wrap a module with a conditional guard. A bool guard gates content with
+ # optionalAttrs (not mkIf): a false guard contributes NOTHING. A structural
+ # module (imports/_file but no flat config) recurses, gating each leaf's config.
+ guardModule =
+ guard: mod:
+ if guard == null then
+ mod
+ else
+ let
+ guardOne =
+ node: args:
+ let
+ inner = if builtins.isFunction node then node args else node;
+ in
+ if inner ? imports && !(inner ? config) then
+ { imports = map guardOne inner.imports; } // builtins.removeAttrs inner [ "imports" ]
+ else
+ { config = lib.optionalAttrs (guard args) (inner.config or inner); };
+ in
+ guardOne mod;
+
+ # The §B nest/nest-verbatim placement for ONE collected module at P.
+ # reinstantiate ⇒ nest-verbatim; adaptArgs (path≠[]) ⇒ nestWithAdaptArgs;
+ # else nestPlain. P=[] returns the module unchanged (merge contribution).
+ placeOne =
+ {
+ path,
+ adaptArgs,
+ reinstantiate,
+ guard,
+ }:
+ mod:
+ let
+ placed =
+ if path == [ ] then
+ mod
+ else if reinstantiate then
+ nestVerbatim path mod
+ else if adaptArgs != null then
+ nestWithAdaptArgs path adaptArgs mod
+ else
+ nestPlain path mod;
+ in
+ guardModule guard placed;
+
+ # The §B materialization for a simple route's collected source modules → the
+ # wrapped module list landed in the target bucket. Implements the §B cells:
+ # - empty source: ensureTargetPath (cell 5) or no edge.
+ # - #572 combine (cell 3): adaptArgs≠null ∧ path≠[] ∧ ¬reinstantiate ⇒ ONE
+ # nestWithAdaptArgs over { imports = adapted; } (all modules in one
+ # evalModules so same-class aspects merge inside it).
+ # - otherwise (cells 1/2/4): per-module placement.
+ # adapterKey (cell 6) and instantiate (cell 7) are handled by the constructor
+ # BEFORE this (they replace the source-module list outright); this function
+ # sees only the wrapRouteModules path.
+ materializeNest =
+ {
+ modules,
+ path,
+ guard ? null,
+ adaptArgs ? null,
+ reinstantiate ? false,
+ ensureTargetPath ? false,
+ }:
+ let
+ adapted = map (adaptModule adaptArgs path) modules;
+ in
+ if adaptArgs != null && path != [ ] && !reinstantiate then
+ # cell 3 (#572): ONE combined evalModules.
+ [
+ (guardModule guard (nestWithAdaptArgs path adaptArgs { imports = adapted; }))
+ ]
+ else
+ map (placeOne {
+ inherit
+ path
+ adaptArgs
+ reinstantiate
+ guard
+ ;
+ }) adapted;
+
+ # ===== adapter functor (§B cell 6 — dynamic P) =========================
+ # The adapter arm's P is DYNAMIC: resolved at evalModules time via intoPathFn
+ # args, not at construction. The edge carries the functor recipe; the trace
+ # records P as the static route.path with dynamicPath=true.
+ mkAdapterFunctor =
+ route: sourceModules:
+ let
+ adapterMod = route.adapterModule or null;
+ sourceModule = {
+ imports = sourceModules;
+ };
+ guardFn = route.guard or (_: lib.id);
+ adaptArgsFn = route.adaptArgs or (_: { });
+ intoPathFn = route.intoPathFn or (_: route.path);
+ key = route.adapterKey;
+ guardArgs = route.guardArgs or { };
+ intoPathArgs = route.intoPathArgs or { };
+ adaptArgv = route.adaptArgv or { };
+ freeformMod =
+ route.freeformMod or {
+ config._module.freeformType = lib.types.lazyAttrsOf lib.types.unspecified;
+ };
+ adapterMods =
+ if adapterMod != null then
+ [
+ freeformMod
+ adapterMod
+ ]
+ else
+ [ freeformMod ];
+ in
+ {
+ __functionArgs = guardArgs // intoPathArgs // adaptArgv;
+ __functor = _: args: {
+ options.den.fwd.${key} = lib.mkOption {
+ defaultText = lib.literalExpression "{ }";
+ default = { };
+ type = lib.types.submoduleWith {
+ specialArgs = adaptArgsFn args;
+ modules = adapterMods ++ [ sourceModule ];
+ };
+ };
+ config = guardFn args (lib.setAttrByPath (intoPathFn args) args.config.den.fwd.${key});
+ };
+ };
+
+ # materializeRouteEdge: the §B nest/nest-verbatim/merge placement for ONE
+ # simple-route edge carrying its already-resolved source modules + materializer
+ # properties → the wrapped module list to land in the target bucket. This is the
+ # ONLY mode switch for route delivery; applySimpleRouteEdge routes EVERY simple
+ # route through here so nest/nest-verbatim/merge placement is decided in one
+ # place (spec §3c materialization).
+ #
+ # `adapterKey`/`instantiate` arms replace the module list outright (cells 6/7);
+ # the remaining cells (1–5) go through materializeNest (nest | nest-verbatim |
+ # merge contribution at P=[], + the #572 combine + ensureTargetPath).
+ materializeRouteEdge =
+ m:
+ if m.kind == "instantiate" then
+ # cell 7: eager instantiate evaluated at materialization, placed at P.
+ if m.sourceModules == [ ] then
+ [ ]
+ else
+ [ { config = lib.setAttrByPath m.path m.instantiateEvaluated; } ]
+ else if m.kind == "ensure-empty" then
+ # cell 5 with empty source and ensureTargetPath: land an empty attrset at P.
+ lib.optional m.ensureTargetPath { config = lib.setAttrByPath m.path { }; }
+ else if m.kind == "adapter" then
+ # cell 6: the adapter functor module (dynamic P resolved at evalModules).
+ [ (mkAdapterFunctor m.adapterRoute m.sourceModules) ]
+ else
+ # cells 1–4: nest | nest-verbatim | merge contribution (P=[]), + #572.
+ materializeNest {
+ inherit (m)
+ modules
+ path
+ guard
+ adaptArgs
+ reinstantiate
+ ensureTargetPath
+ ;
+ };
+
+ # ===== source collection (§B cell 9 — collectSubtree / isFlakeRoute) ===
+ # Collect class modules from a scope and all descendants, skipping isolated
+ # descendants (isolation-AWARE; collection root always included). The plain
+ # case collects from the route's own scope only.
+ collectFromSubtree =
+ wrappedPerScope: scopeParent: scopeIsolated: rootScopeId: fromClass:
+ let
+ scopes = subtreeScopes {
+ inherit scopeParent;
+ isolated = scopeIsolated;
+ root = rootScopeId;
+ allScopeIds = builtins.attrNames wrappedPerScope;
+ };
+ in
+ lib.concatMap (sid: wrappedPerScope.${sid}.${fromClass} or [ ]) scopes;
+
+ sourceModulesOf =
+ {
+ route,
+ wrappedPerScope,
+ scopeParent,
+ scopeIsolated,
+ }:
+ let
+ isFlakeRoute = route.intoClass == "flake";
+ in
+ if isFlakeRoute || (route.collectSubtree or false) then
+ collectFromSubtree wrappedPerScope scopeParent scopeIsolated route.sourceScopeId route.fromClass
+ else if wrappedPerScope ? ${route.sourceScopeId} then
+ wrappedPerScope.${route.sourceScopeId}.${route.fromClass} or [ ]
+ else
+ [ ];
+
+ # ===== per-route classification (§B Decision 4 matrix) =================
+ # The single cell decision both projections derive from. `mode` is the §B
+ # M; `kind` selects the materialization arm (wrapRouteModules | adapter |
+ # instantiate); `props` are the trace annotations / materializer properties.
+ classifyRoute =
+ route:
+ let
+ path = route.path or [ ];
+ isFlakeRoute = route.intoClass == "flake";
+ hasInstantiate = (route.instantiate or null) != null;
+ isAdapterRoute = (route.adapterKey or null) != null;
+ reinstantiate = route.reinstantiate or false;
+ adaptArgs = route.adaptArgs or null;
+ in
+ {
+ inherit
+ path
+ isFlakeRoute
+ hasInstantiate
+ isAdapterRoute
+ reinstantiate
+ adaptArgs
+ ;
+ # §B mode: reinstantiate ⇒ nest-verbatim; P=[] ⇒ merge; else nest.
+ mode =
+ if reinstantiate then
+ "nest-verbatim"
+ else if path == [ ] then
+ "merge"
+ else
+ "nest";
+ appendToParent = route.appendToParent or false;
+ collectSubtree = route.collectSubtree or false;
+ adapterKey = route.adapterKey or null;
+ guard = route.guard or null;
+ };
+
+ # The target scope for a route's edge: appendToParent ⇒ the PARENT scope of
+ # sourceScopeId (the §B "to=parent" property, consumed at construction).
+ appendScopeIdOf =
+ scopeParent: route:
+ if route.appendToParent or false then
+ scopeParent.${route.sourceScopeId} or route.sourceScopeId
+ else
+ route.sourceScopeId;
+
+ # ===== dedup + toposort (§B Decision 5 + dedupRoutes suppressions) =====
+
+ # adapterKeys that exist at child (non-root) scopes — the redundant-root shadow
+ # input.
+ findChildScopeKeys =
+ rootScopeId: rawRoutes:
+ builtins.foldl' (
+ acc: r:
+ let
+ ak = r.adapterKey or null;
+ in
+ if ak != null && rootScopeId != null && r.sourceScopeId != rootScopeId then
+ acc // { ${ak} = true; }
+ else
+ acc
+ ) { } rawRoutes;
+
+ # Per-position suppression verdict for each raw route, matching dedupRoutes'
+ # two rules (redundant-root shadow + adapterKey@scope first-wins). Returns a
+ # list aligned with rawRoutes: { suppressed; byChild; adapterKey; }.
+ suppressionVerdicts =
+ rootScopeId: rawRoutes:
+ let
+ childScopeKeys = findChildScopeKeys rootScopeId rawRoutes;
+ go =
+ seen: routes:
+ if routes == [ ] then
+ [ ]
+ else
+ let
+ r = builtins.head routes;
+ rest = builtins.tail routes;
+ ak = r.adapterKey or null;
+ # §B rule 2: redundant-root shadow — an adapter route AT the root
+ # whose adapterKey also exists at a child scope is dropped.
+ isRedundantRoot =
+ ak != null && rootScopeId != null && r.sourceScopeId == rootScopeId && childScopeKeys ? ${ak};
+ # §B rule 1: same adapterKey@scope first-occurrence wins.
+ key = if ak != null then "${ak}@${r.sourceScopeId}" else null;
+ isDupKey = key != null && seen ? ${key};
+ suppressed = isRedundantRoot || isDupKey;
+ verdict = {
+ inherit suppressed;
+ byChild = isRedundantRoot;
+ adapterKey = ak;
+ };
+ nextSeen = if !suppressed && key != null then seen // { ${key} = true; } else seen;
+ in
+ [ verdict ] ++ go nextSeen rest;
+ in
+ go { } rawRoutes;
+
+ # The kept (non-suppressed) routes in original order — the §B Decision 5
+ # toposort input.
+ keptRoutes =
+ rootScopeId: rawRoutes:
+ let
+ verdicts = suppressionVerdicts rootScopeId rawRoutes;
+ in
+ builtins.concatLists (
+ lib.imap0 (i: r: lib.optional (!(builtins.elemAt verdicts i).suppressed) r) rawRoutes
+ );
+
+ # General edge toposort (§B Decision 5): a producer (intoClass@scope) must fire
+ # before any consumer (fromClass@scope) that reads it. Today this is the
+ # single-level partition topoSortRoutes did (a complex forward depending on a
+ # producer of its fromClass), generalized to a real toposort with a LOUD cycle
+ # throw printing the edge chain. Simple routes read the original per-scope data
+ # (not the fold state), so they never participate as dependents.
+ topoSort =
+ routes:
+ let
+ n = builtins.length routes;
+ routeAt = i: builtins.elemAt routes i;
+ producerMap = builtins.foldl' (
+ acc: i:
+ let
+ r = routeAt i;
+ key = "${r.intoClass}@${r.sourceScopeId}";
+ in
+ acc // { ${key} = (acc.${key} or [ ]) ++ [ i ]; }
+ ) { } (lib.range 0 (n - 1));
+ # Dependency indices of route i: producers of its fromClass@scope OTHER than
+ # itself, but only when i is a complex forward (simple routes read the
+ # original per-scope data, never the fold state, so never depend).
+ depsOf =
+ i:
+ let
+ r = routeAt i;
+ in
+ if (r.__complexForward or false) then
+ builtins.filter (j: j != i) (producerMap."${r.fromClass}@${r.sourceScopeId}" or [ ])
+ else
+ [ ];
+ labelOf =
+ i:
+ let
+ r = routeAt i;
+ in
+ "${r.fromClass or "?"}>${r.intoClass or "?"}@${r.sourceScopeId or "?"}";
+ # Kahn-style toposort over the INDEX DAG (indices are comparable; route
+ # records may carry functions and are not). On a remaining cycle, throw with
+ # the participating class@scope edge chain (§B Decision 5: a detected cycle
+ # is a loud config error). Today no cycle is reachable — single-level
+ # producer→consumer — so this is a forward guard, byte-stable with the old
+ # noDeps-before-withDeps partition.
+ emittedSet = is: lib.genAttrs (map toString is) (_: true);
+ go =
+ emitted: remaining:
+ if remaining == [ ] then
+ [ ]
+ else
+ let
+ es = emittedSet emitted;
+ ready = builtins.filter (i: builtins.all (j: es ? ${toString j}) (depsOf i)) remaining;
+ in
+ if ready == [ ] then
+ throw "den materialize: delivery-edge cycle among [ ${lib.concatStringsSep " -> " (map labelOf remaining)} ] — a route's source depends on its own output transitively."
+ else
+ let
+ readySet = lib.genAttrs (map toString ready) (_: true);
+ in
+ ready ++ go (emitted ++ ready) (builtins.filter (i: !(readySet ? ${toString i})) remaining);
+ in
+ map routeAt (go [ ] (lib.range 0 (n - 1)));
+
+ # The ordered, deduped route list both projections fold over. Suppressed routes
+ # are DROPPED for materialization but RECORDED (with suppressed=true) for the
+ # trace — so the two consumers pass the full raw list + verdicts and select.
+ orderedKeptRoutes = rootScopeId: rawRoutes: topoSort (keptRoutes rootScopeId rawRoutes);
+
+ # ===== synthesize source + materialization (§B Decision 2 — complex forward) =
+ # A complex (__complexForward) route is a SINGLE synthesize edge:
+ # S = synthesize(forwardSpec, sourceModule)
+ # where sourceModule is built by ONE source rule with a fallback (NOT two edge
+ # kinds): `sourceModules = collected if non-empty else rewalk`. The fallback is
+ # internal to S-construction; the edge identity is (forwardSpec, intoClass)
+ # regardless of which branch produced the source. The synthesize constructor is
+ # `buildForwardAspect` (handlers/forward.nix) — it builds a NEW aspect from the
+ # source module (spec §2: "neither a plain collect nor a re-resolution").
+
+ # Collect class modules from a forward aspect (recursing into includes). Moved
+ # from route/wrap.nix — consumed only by the synthesize materialization (a
+ # forward-aspect class collection, not a route-nesting concern).
+ collectClassMods =
+ cls: aspect:
+ let
+ own = lib.optional (aspect ? ${cls}) aspect.${cls};
+ nested = builtins.concatMap (collectClassMods cls) (aspect.includes or [ ]);
+ in
+ own ++ nested;
+
+ # A `den.default`-tagged module — root content shared across the entity chain.
+ isDenDefaultModule = mod: lib.hasSuffix "@default" (mod.key or mod._file or "");
+
+ # Root-scope `fromClass` content a child-scope COMPLEX forward may pull in.
+ # S-construction rule (§B Decision 2): when `fromClass` is owned by an entity in
+ # the chain, root content under it is that entity's OWN declaration (not
+ # aggregation fodder) — restrict to shared `den.default`. A forward-only custom
+ # class keeps its full root content.
+ filterRootModules =
+ scopeContexts: spec: rootModules:
+ let
+ childCtx = scopeContexts.${spec.sourceScopeId} or { };
+ ownedClasses =
+ (childCtx.user.classes or [ ])
+ ++ lib.optional (childCtx ? host) childCtx.host.class
+ ++ lib.optional (childCtx ? home) childCtx.home.class;
+ in
+ if builtins.elem spec.fromClass ownedClasses then
+ builtins.filter isDenDefaultModule rootModules
+ else
+ rootModules;
+
+ # The "collected" source branch: a child-scope forward collects its own-scope
+ # fromClass modules PLUS the (filtered) root-scope fromClass modules; a root-
+ # scope (or rootless) forward collects the flat classImports aggregate.
+ getCollectedSource =
+ acc: spec: rootScopeId: scopeContexts:
+ let
+ sid = spec.sourceScopeId;
+ in
+ if rootScopeId != null && sid != rootScopeId then
+ let
+ ownModules = (acc.perScope.${sid} or { }).${spec.fromClass} or [ ];
+ rootModules = (acc.perScope.${rootScopeId} or { }).${spec.fromClass} or [ ];
+ in
+ filterRootModules scopeContexts spec rootModules ++ ownModules
+ else
+ acc.classImports.${spec.fromClass} or [ ];
+
+ # The "rewalk" source branch (fallback when collected == []): re-resolve the
+ # source aspect via spawnNode with FULL fleet visibility. `from = scopeParent`
+ # of sourceScopeId (the HOST scope) so the spawn's policyBoundAncestor sees
+ # fleet peers — using sourceScopeId directly gives a self-parent edge → zero
+ # peers (§B Decision 2 fleet-visibility deciding evidence).
+ #
+ # unifiedEdges (Task 16) does NOT collect this spawn's surfaced `.edges`: the
+ # complex forward this fallback resolves is ALREADY represented in the unified
+ # set by its `synthesize` edge (from routeEdges). Collecting the fallback spawn's
+ # edges here would double-count the same delivery. Only the drain-fold spawn
+ # (resolve.nix mkDrained) surfaces edges into unifiedEdges.
+ resolveSourceFallback =
+ spec: spawnNode: scopeParent:
+ if !(spec ? sourceAspect) || spawnNode == null || !(spec ? sourceScopeId) then
+ [ ]
+ else
+ (spawnNode {
+ from = scopeParent.${spec.sourceScopeId} or spec.sourceScopeId;
+ class = spec.fromClass;
+ aspect = den.lib.aspects.normalizeRoot spec.sourceAspect;
+ bindings = { };
+ }).imports;
+
+ # Append synthesized modules to a class bucket at a scope (flat + perScope).
+ appendToClass = acc: cls: sid: newMods: {
+ classImports = acc.classImports // {
+ ${cls} = (acc.classImports.${cls} or [ ]) ++ newMods;
+ };
+ perScope = acc.perScope // {
+ ${sid} = (acc.perScope.${sid} or { }) // {
+ ${cls} = ((acc.perScope.${sid} or { }).${cls} or [ ]) ++ newMods;
+ };
+ };
+ };
+
+ # Materialize ONE synthesize edge: build the source module (collected-else-
+ # rewalk), run it through the forward constructor (buildForwardAspect), collect
+ # its intoClass modules, and append to the intoClass bucket at sourceScopeId.
+ # The synthesize edge records identity (forwardSpec, intoClass); the CONTENT is
+ # constructed here at materialization time (spec §8: synthesize records identity,
+ # not content).
+ applyComplexRouteEdge =
+ acc:
+ {
+ route,
+ rootScopeId,
+ scopeContexts,
+ scopeParent,
+ spawnNode,
+ buildForwardAspect,
+ }:
+ let
+ spec = route;
+ collectedSource = getCollectedSource acc spec rootScopeId scopeContexts;
+ sourceModules =
+ if collectedSource != [ ] then
+ collectedSource
+ else
+ resolveSourceFallback spec spawnNode scopeParent;
+ sourceModule = spec.mapModule { imports = sourceModules; };
+ newMods = collectClassMods spec.intoClass (buildForwardAspect spec sourceModule);
+ in
+ appendToClass acc spec.intoClass spec.sourceScopeId newMods;
+
+ # Materialize ONE simple-route edge: classify (§B cell), collect source, run the
+ # nest/nest-verbatim/merge mode switch (materializeRouteEdge), append to the
+ # target bucket. The cell decision (classifyRoute), source collection
+ # (sourceModulesOf), and target scope (appendScopeIdOf) come from THIS file; the
+ # placement (mode switch) from materializeRouteEdge below.
+ applySimpleRouteEdge =
+ acc:
+ {
+ route,
+ wrappedPerScope,
+ scopeParent,
+ scopeIsolated,
+ }:
+ let
+ c = classifyRoute route;
+ sourceModules = sourceModulesOf {
+ inherit
+ route
+ wrappedPerScope
+ scopeParent
+ scopeIsolated
+ ;
+ };
+ adapterMod = route.adapterModule or null;
+ modulesWithAdapter = if adapterMod == null then sourceModules else sourceModules ++ [ adapterMod ];
+ # The §B materialize payload selecting the cell arm.
+ kind =
+ if c.hasInstantiate then
+ "instantiate"
+ else if modulesWithAdapter == [ ] then
+ "ensure-empty"
+ else if c.isAdapterRoute then
+ "adapter"
+ else
+ "nest";
+ # cell 5 ensureTargetPath predicate (apply-time, content-aware): empty
+ # module set + adaptArgs + non-flake + path≠[].
+ ensureTargetPath =
+ !c.isFlakeRoute && c.adaptArgs != null && c.path != [ ] && modulesWithAdapter == [ ];
+ # cell 7 instantiate: eager evaluation at materialization.
+ instantiateEvaluated =
+ let
+ adaptArgsFn = route.adaptArgs or (_: { });
+ extraArgs = adaptArgsFn { };
+ in
+ if c.hasInstantiate then route.instantiate ({ modules = sourceModules; } // extraArgs) else null;
+ wrappedModules = materializeRouteEdge {
+ inherit kind ensureTargetPath instantiateEvaluated;
+ inherit (c)
+ path
+ adaptArgs
+ guard
+ reinstantiate
+ ;
+ modules = modulesWithAdapter;
+ sourceModules = sourceModules;
+ adapterPresent = adapterMod != null;
+ adapterRoute = route;
+ };
+ in
+ appendToClass acc route.intoClass (appendScopeIdOf scopeParent route) wrappedModules;
+
+ # The route fold: dedup + toposort routes, fold applying each (complex synthesize
+ # vs simple delivery edge). The ONLY consumer-facing route entry — resolve.nix
+ # and spawn-node thread their state in, get the assembled { classImports; perScope }
+ # back. Simple + complex routes are both delivery edges now; the phase-3 fold is
+ # the materialization of the route edge set in topo order.
+ applyRoutes =
+ {
+ scopedRoutes,
+ wrappedPerScope,
+ classImports,
+ scopeParent ? { },
+ scopeIsolated ? { },
+ scopeContexts ? { },
+ spawnNode ? null,
+ rootScopeId ? null,
+ buildForwardAspect ? null,
+ }:
+ let
+ allRoutes = orderedKeptRoutes rootScopeId (lib.concatLists (lib.attrValues scopedRoutes));
+ in
+ builtins.foldl'
+ (
+ acc: route:
+ if route.__complexForward or false then
+ applyComplexRouteEdge acc {
+ inherit
+ route
+ rootScopeId
+ scopeContexts
+ scopeParent
+ spawnNode
+ buildForwardAspect
+ ;
+ }
+ else
+ applySimpleRouteEdge acc {
+ inherit
+ route
+ wrappedPerScope
+ scopeParent
+ scopeIsolated
+ ;
+ }
+ )
+ {
+ inherit classImports;
+ perScope = wrappedPerScope;
+ }
+ allRoutes;
+in
+{
+ # The resolver (applyRoutes) and the read-only oracle (materializeRouteEdge,
+ # routeEdges below) consume route.nix; the per-spec materializers + ordering
+ # helpers are ALSO surfaced (additive) so materializeUnified (Task 17) can
+ # interleave provides + routes in one ordered-dispatch fold while reusing the
+ # EXACT per-spec materialization applyRoutes uses.
+ inherit
+ materializeRouteEdge
+ applyRoutes
+ applyComplexRouteEdge
+ applySimpleRouteEdge
+ classifyRoute
+ keptRoutes
+ suppressionVerdicts
+ orderedKeptRoutes
+ ;
+
+ # ===== trace-facing route edge constructor (§8 identity, no content) ===
+ # Renders the simple+complex route specs as edge RECORDS for the oracle
+ # (edge-trace.nix). Identity + annotations only; suppression verdicts are
+ # EXACT (the constructor's own dedup rules), not a path-dependent approximation.
+ #
+ # sourceVia for complex forwards is PERMANENTLY "unresolved" — this is NOT a
+ # deferred annotation. The trace renders construction-time data (the edge
+ # identity triple), but the collected-else-rewalk source choice (§B Decision 2)
+ # is MATERIALIZATION-time path-dependent: it depends on whether `getCollectedSource`
+ # found content in the assembled `acc.perScope` AT the synthesize edge's fold
+ # position (which itself depends on provides + earlier simple routes feeding the
+ # source class). The synthesize edge records identity, not which branch fired
+ # (spec §8: synthesize records identity, not content), so "unresolved" is the
+ # correct, final disposition — recording a concrete branch here would require
+ # re-running the materialization the trace is meant to be independent of.
+ #
+ # name — sid → stable scope name (edge.nix scopeName).
+ # scopeParent — parent DAG (for appendToParent target resolution).
+ # rootScopeId — the pipeline root (suppression rootScopeId).
+ # rawRoutes — the flattened scopedRoutes spec list.
+ routeEdges =
+ {
+ name,
+ scopeParent,
+ rootScopeId,
+ rawRoutes,
+ }:
+ let
+ verdicts = suppressionVerdicts rootScopeId rawRoutes;
+ forwardId =
+ spec:
+ spec.adapterKey or "${spec.fromClass}>${spec.intoClass}@${spec.sourceScopeId}/${
+ lib.concatStringsSep "/" (spec.staticIntoPath or spec.path or [ ])
+ }";
+ routeEdge =
+ verdict: spec:
+ let
+ sid = spec.sourceScopeId;
+ isComplex = spec.__complexForward or false;
+ path = spec.path or spec.staticIntoPath or [ ];
+ appendToParent = spec.appendToParent or false;
+ appendSid = if appendToParent then scopeParent.${sid} or sid else sid;
+ adapterKey = spec.adapterKey or null;
+ reinstantiate = spec.reinstantiate or false;
+ baseAnnotations =
+ lib.optionalAttrs (spec.adaptArgs or null != null) { adaptArgs = true; }
+ // lib.optionalAttrs (spec.guard or null != null) { guard = true; }
+ // lib.optionalAttrs (spec.collectSubtree or false) { collectSubtree = true; }
+ // lib.optionalAttrs ((spec.intoClass or null) == "flake") { isFlakeRoute = true; }
+ // lib.optionalAttrs ((spec.instantiate or null) != null) { instantiate = true; }
+ // lib.optionalAttrs appendToParent { appendToParent = true; }
+ // lib.optionalAttrs (
+ # §B cell 5: ensureEntry placeholder (empty target path materialized).
+ # Content-blind approx (also requires empty module set) — converges
+ # with the materializer's actual ensureTargetPath at runtime.
+ !isComplex && (spec.intoClass or null) != "flake" && (spec.adaptArgs or null) != null && path != [ ]
+ ) { ensureTargetPath = true; }
+ // lib.optionalAttrs verdict.suppressed { suppressed = true; }
+ // lib.optionalAttrs verdict.byChild { suppressedByChildKey = adapterKey; };
+ in
+ if isComplex then
+ mkEdge {
+ source = synthesize (forwardId spec) spec.fromClass spec.intoClass;
+ target = rootTarget (name appendSid) spec.intoClass;
+ inherit path;
+ mode = "nest";
+ annotations = baseAnnotations // {
+ complexForward = true;
+ sourceVia = "unresolved";
+ };
+ }
+ else
+ mkEdge {
+ source = collected (name sid) spec.fromClass;
+ target = rootTarget (name appendSid) spec.intoClass;
+ inherit path;
+ mode =
+ if reinstantiate then
+ "nest-verbatim"
+ else if path == [ ] then
+ "merge"
+ else
+ "nest";
+ annotations =
+ baseAnnotations
+ // lib.optionalAttrs (adapterKey != null) {
+ inherit adapterKey;
+ dynamicPath = true;
+ };
+ };
+ in
+ lib.imap0 (i: spec: routeEdge (builtins.elemAt verdicts i) spec) rawRoutes;
+}
diff --git a/nix/lib/aspects/fx/edges/toposort.nix b/nix/lib/aspects/fx/edges/toposort.nix
new file mode 100644
index 000000000..de3dc90ea
--- /dev/null
+++ b/nix/lib/aspects/fx/edges/toposort.nix
@@ -0,0 +1,179 @@
+# toposort.nix — the GENERAL record-level delivery-edge toposort (Task 16).
+# Generalizes route.nix's per-route `topoSort` (which keyed deps only on
+# sourceScopeId, the known blind spot) to a cell-model toposort over the UNIFIED
+# edge set: cross-kind (provides/route/synthesize/default-fold/instantiate),
+# cross-field, cross-root. A producer that WRITES a (scope,class) cell must fire
+# before any edge that READS that cell.
+#
+# CELL MODEL (B15 proof). Producers WRITE; only the final-extraction merge,
+# synthesize, and instantiate edges READ produced cells. Provides + simple routes
+# read FROZEN phase-1 inputs, so they depend on NOTHING.
+#
+# writeCell(edge):
+# target {root;class} → cell (target.root, target.class). Covers EVERY
+# producer: provides nest, route nest/merge,
+# appendToParent (constructor already set
+# target.root=parent), synthesize. No special-case.
+# target {output} → writes nothing readable (terminal instantiate).
+#
+# readCells(edge):
+# merge + annotations.collectedScopes → { (sid, target.class) : sid ∈
+# collectedScopes } — the per-root final extraction
+# reads every subtree-scope bucket at its class.
+# synthesize source → (target.root, fromClass) ∪ (rootName, fromClass) ∪
+# the FLAT aggregate of fromClass: i.e. EVERY producer
+# of fromClass at ANY scope (route.nix:568 reads the
+# flat acc.classImports.${fromClass}). Modeled as
+# "depends on all writers of fromClass".
+# collected source + target {output} (instantiate) → (source.collected.scope,
+# source.collected.class) — the host subtree bucket;
+# depends on that host's final-extraction merge.
+# anything else (provides / simple route — collected source, no
+# collectedScopes, root target) → {} (frozen phase-1
+# inputs, B15). The presence of collectedScopes is what
+# distinguishes a READING merge from a producing one.
+#
+# Ambiguity rule: any edge shape not matched above defaults to readCells = {} —
+# a pure producer. Conservative: never invents a false cycle.
+#
+# The DAG is built over INDICES (edge records carry functions and are not
+# comparable / hashable); the cell match is a pure record inspection (no content
+# eval). Kahn toposort with a loud cycle throw mirroring route.nix's message.
+{ lib }:
+let
+ cellKey = scope: class: scope + "/" + class;
+
+ # The (scope,class) cell this edge WRITES, or null for a terminal instantiate.
+ writeCellOf =
+ e:
+ let
+ t = e.target;
+ in
+ if t ? output then null else cellKey t.root t.class;
+
+ # The cell keys this edge READS (see the CELL MODEL header). The rootName for a
+ # synthesize edge's extra root-slice read is the root scope NAME; the unified set
+ # uses normalized scope names throughout, but the synthesize FLAT read already
+ # subsumes "all producers of fromClass at ANY scope", so the root-slice and
+ # own-scope cells are members of that flat set. We therefore model the synthesize
+ # read as the flat fromClass set (computed against all write cells by class).
+ #
+ # For non-synthesize edges the read is the bounded cell set the model specifies.
+ readCellsOf =
+ writeClassScopes: e:
+ let
+ s = e.source;
+ t = e.target;
+ ann = e.annotations or { };
+ in
+ # Final-extraction merge: reads every collected subtree scope at its class.
+ if e.mode == "merge" && ann ? collectedScopes then
+ map (sid: cellKey sid t.class) ann.collectedScopes
+ # Synthesize (complex forward): reads the FLAT aggregate of fromClass — every
+ # producer of fromClass at ANY scope.
+ else if s ? synthesize then
+ map (scope: cellKey scope s.synthesize.fromClass) (
+ writeClassScopes.${s.synthesize.fromClass} or [ ]
+ )
+ # Instantiate: collected source feeding a flake-output target reads the host's
+ # subtree bucket cell.
+ else if s ? collected && (t ? output) then
+ [ (cellKey s.collected.scope s.collected.class) ]
+ # Provides / simple routes / anything else: frozen phase-1 inputs (B15) — and
+ # the conservative default for unmatched shapes.
+ else
+ [ ];
+
+ # A short edge label for the cycle-throw chain: target cell + source kind.
+ labelOf =
+ e:
+ let
+ t = e.target;
+ s = e.source;
+ srcKind =
+ if s ? synthesize then
+ "synthesize:${s.synthesize.fromClass}>${s.synthesize.intoClass}"
+ else if s ? collected then
+ "collected:${s.collected.scope}/${s.collected.class}"
+ else if s ? rewalk then
+ "rewalk:${s.rewalk.aspect}"
+ else
+ "?";
+ in
+ if t ? output then
+ "out:${lib.concatStringsSep "." t.output}<-${srcKind}"
+ else
+ "${t.root}/${t.class}[${e.mode}]<-${srcKind}";
+
+ topoSortEdges =
+ edges:
+ let
+ n = builtins.length edges;
+ edgeAt = i: builtins.elemAt edges i;
+ idxs = lib.range 0 (n - 1);
+
+ # cell → [ indices of edges that WRITE it ]. The producer map.
+ writers = builtins.foldl' (
+ acc: i:
+ let
+ c = writeCellOf (edgeAt i);
+ in
+ if c == null then acc else acc // { ${c} = (acc.${c} or [ ]) ++ [ i ]; }
+ ) { } idxs;
+
+ # class → [ distinct scope names with a write cell at that class ]. The flat
+ # read universe for synthesize edges (every producer of fromClass anywhere).
+ writeClassScopes = builtins.foldl' (
+ acc: i:
+ let
+ t = (edgeAt i).target;
+ in
+ if t ? output then
+ acc
+ else
+ acc // { ${t.class} = lib.unique ((acc.${t.class} or [ ]) ++ [ t.root ]); }
+ ) { } idxs;
+
+ # Dependency indices of edge i: every writer of any cell i reads, minus self.
+ depsOf =
+ i:
+ let
+ cells = readCellsOf writeClassScopes (edgeAt i);
+ writerIdxs = lib.unique (builtins.concatLists (map (c: writers.${c} or [ ]) cells));
+ in
+ builtins.filter (j: j != i) writerIdxs;
+
+ # Kahn toposort over the index DAG. On a remaining cycle, throw with the
+ # participating edge chain (mirrors route.nix:496 — a detected cycle is a
+ # loud config error).
+ #
+ # STABLE: `ready` is emitted in ascending index order (it filters
+ # `remaining`, which starts as `idxs` ascending and is only ever shrunk by a
+ # membership filter that preserves order), so independent edges preserve
+ # input/construction order — load-bearing for Task 17 strict-byte
+ # (materializeUnified relies on the unified provides-then-routes edge list
+ # keeping provides BEFORE routes among independents, matching phase2∘phase3).
+ go =
+ emitted: remaining:
+ if remaining == [ ] then
+ [ ]
+ else
+ let
+ es = lib.genAttrs (map toString emitted) (_: true);
+ ready = builtins.filter (i: builtins.all (j: es ? ${toString j}) (depsOf i)) remaining;
+ in
+ if ready == [ ] then
+ throw "den materialize: delivery-edge cycle among [ ${
+ lib.concatStringsSep " -> " (map (i: labelOf (edgeAt i)) remaining)
+ } ] — a delivery edge's source depends on its own output transitively."
+ else
+ let
+ readySet = lib.genAttrs (map toString ready) (_: true);
+ in
+ ready ++ go (emitted ++ ready) (builtins.filter (i: !(readySet ? ${toString i})) remaining);
+ in
+ map edgeAt (go [ ] idxs);
+in
+{
+ inherit topoSortEdges;
+}
diff --git a/nix/lib/aspects/fx/handlers/bind.nix b/nix/lib/aspects/fx/handlers/bind.nix
index 7e8ba2788..650e8782e 100644
--- a/nix/lib/aspects/fx/handlers/bind.nix
+++ b/nix/lib/aspects/fx/handlers/bind.nix
@@ -7,6 +7,9 @@
}:
let
inherit (den.lib) fx;
+ inherit (den.lib.aspects.fx) argClass;
+ schema = den.schema or { };
+ isEntityKind = argClass.isEntityKind schema;
in
{
bindHandler = {
@@ -14,6 +17,10 @@ in
{ param, state }:
let
inherit (param) aspect compileFn;
+ # Entity intermediates already fanned earlier in this chain (e.g. `pet`
+ # when fanning `toy` under `{ pet, toy }`). Threaded through the recursive
+ # fan so a transitive descendant can enumerate off its immediate parent.
+ boundEntities = param.boundEntities or { };
childArgs = aspect.__args or { };
childScopeHandlers = aspect.__scopeHandlers or { };
requiredKeys = builtins.filter (k: !childArgs.${k}) (builtins.attrNames childArgs);
@@ -35,6 +42,11 @@ in
else
{ };
keysAfterStateFallback = builtins.filter (k: !(scopeCtx ? ${k})) keysToProbe;
+ # Entity records reachable for descendant child-enumeration: the scope's
+ # own ctx (scope + ancestors) plus intermediates fanned earlier in this
+ # chain. A transitive descendant enumerates off its IMMEDIATE parent
+ # record, which may be a fanned intermediate rather than the scope root.
+ availRecords = scopeCtx // boundEntities;
# Detect pipe arg references: if any required keys are pipe names,
# unconditionally defer — pipe data is assembled post-pipeline.
pipeRegistry = den.quirks or { };
@@ -65,31 +77,161 @@ in
}
else
aspect;
- probeArgs =
+ # Kind of the entity that owns the current scope (K_S), from state.
+ scopeKind =
+ if currentScope == null then
+ null
+ else
+ ((state.scopeEntityKind or (_: { })) null).${currentScope} or null;
+ inherit (den.lib.aspects.fx) identity;
+ # Per-scope include ancestry of the aspect currently binding. Element 0
+ # is the entity-kind root (== scopeKind); element 1 is the schema-include
+ # node that delivered this aspect into the current scope (its identity
+ # key). Deeper elements are nested sub-includes — the PROVENANCE ROOT we
+ # care about stays at index 1 regardless of nesting depth.
+ scopeChain = ((state.scopedIncludesChain or (_: { })) null).${currentScope} or [ ];
+ # identity.key of the schema-include node this aspect descends from, or
+ # null if the aspect isn't a schema-include descendant (e.g. the entity
+ # self-aspect with an empty/length-1 chain).
+ aspectIncludeRoot = if builtins.length scopeChain >= 2 then builtins.elemAt scopeChain 1 else null;
+ # True when the SAME schema-include node that delivered this aspect into
+ # the current (ancestor) scope is ALSO registered in `argKind`'s own
+ # schema includes — i.e. one source injected at both the ancestor AND the
+ # descendant entity kind (e.g. `den.default`, registered into
+ # schema.{host,user,home}.includes). Such an aspect reaches the
+ # descendant directly via its own scope's resolution, so fanning it out
+ # at the ancestor here would double-cover; it is inert at the ancestor.
+ #
+ # Structural-identity comparison: matches on identity.key of the include
+ # NODE (the provenance root captured in scopedIncludesChain), NOT a
+ # head-of-display-name string. This kills the false positive on
+ # coincidental name collisions and the false negative on nested include
+ # chains that the old `splitString aspect.name` heuristic carried —
+ # `chain[1]` is the schema-include root at any nesting depth, and
+ # identity.key is the same stable key both lists register the node under
+ # (den.default appears as the same value → same key in each).
+ sharedWithDescendant =
+ argKind:
+ let
+ descIncludes = (schema.${argKind} or { }).includes or [ ];
+ in
+ aspectIncludeRoot != null && builtins.any (inc: identity.key inc == aspectIncludeRoot) descIncludes;
+ # Per-key probe: which required keys have NO handler anywhere.
+ probeMissing =
keys:
- if keys == [ ] then
- fx.pure true
+ builtins.foldl' (
+ acc: key:
+ fx.bind acc (
+ missing:
+ fx.bind (fx.effects.hasHandler key) (
+ isAvailable: fx.pure (missing ++ lib.optionals (!isAvailable) [ key ])
+ )
+ )
+ ) (fx.pure [ ]) keys;
+ # Fan out over the scope's K_a-children for descendant entity-arg
+ # argKind, refiring `bind` bound to each child. Recursion on "bind"
+ # discovers further descendant args per child → cartesian for free.
+ # All instances EMIT AT THE CURRENT SCOPE.
+ fanOut =
+ argKind:
+ let
+ # Enumerate children off argKind's PARENT-kind record (host.users,
+ # pet.toys), not the scope root — so a transitive descendant chains
+ # through its immediate parent. The parent record is whichever of the
+ # scope ctx / a fanned intermediate carries that kind.
+ parentKind = argClass.parentKindOf schema scopeKind argKind;
+ parentRecord = if parentKind == null then null else availRecords.${parentKind} or null;
+ children = if parentRecord == null then [ ] else argClass.childrenOf parentRecord argKind;
+ bindChild =
+ idx: child:
+ fx.send "bind" {
+ aspect = augmentedAspect // {
+ __scopeHandlers =
+ (augmentedAspect.__scopeHandlers or { })
+ // den.lib.aspects.fx.handlers.constantHandler { ${argKind} = child; };
+ __ctxId = "${
+ augmentedAspect.__ctxId or augmentedAspect.name or "fanout"
+ }@${argKind}=${child.name or (toString idx)}";
+ # All fan-out instances emit at the SAME (emitting) scope, so
+ # per-scope dedup can't keep them apart. Force context
+ # dependence so emit-class keys each by its ctx-qualified
+ # identity (preserving the {ctxId} suffix) rather than
+ # collapsing siblings to a shared base identity.
+ meta = (augmentedAspect.meta or { }) // {
+ contextDependent = true;
+ };
+ };
+ inherit compileFn;
+ # Record this intermediate so a deeper descendant's fan reads its
+ # children off THIS record (transitive DAG nesting).
+ boundEntities = boundEntities // {
+ ${argKind} = child;
+ };
+ };
+ # Fold child binds, collecting r.value singletons ++ r.fanOut lists.
+ collect = builtins.foldl' (
+ acc: i:
+ fx.bind acc (
+ vals:
+ fx.bind (bindChild i (builtins.elemAt children i)) (
+ r: fx.pure (vals ++ (r.fanOut or (lib.optional (r ? value) r.value)))
+ )
+ )
+ ) (fx.pure [ ]) (lib.genList lib.id (builtins.length children));
+ in
+ if children == [ ] then
+ fx.pure { inert = true; }
else
- let
- key = builtins.head keys;
- rest = builtins.tail keys;
- in
- fx.bind (fx.effects.hasHandler key) (
- isAvailable: if isAvailable then probeArgs rest else fx.pure false
- );
+ fx.bind collect (vals: fx.pure { fanOut = vals; });
in
{
- resume = fx.bind (probeArgs keysAfterStateFallback) (
- allAvailable:
- if allAvailable then
+ resume = fx.bind (probeMissing keysAfterStateFallback) (
+ missingKeys:
+ if missingKeys == [ ] then
fx.bind (compileFn augmentedAspect) (result: fx.pure { value = result; })
else
- fx.bind (fx.send "defer" {
- child = aspect;
- inherit requiredKeys;
- requiredArgs = keysAfterStateFallback;
- inherit hasPipeArgs;
- }) (_: fx.pure { deferred = true; })
+ let
+ # Entity classification (the formal rule). An entity-kind missing
+ # arg at scope S (kind K_S) is: in-ctx (handled upstream, not
+ # missing) → descendant of K_S → fan out at S → otherwise MISPLACED
+ # → whole aspect inert, silently.
+ #
+ # Root scope (scopeKind == null) has NO entity kind: isDescendantOf
+ # is false for a null scopeKind, so every entity arg here is
+ # misplaced → inert. (The old cross-scope defer carrier is gone; a
+ # root defer would dangle forever — inert is the rule's verdict.)
+ entityMissing = builtins.filter isEntityKind missingKeys;
+ descendants = builtins.filter (argClass.isDescendantOf schema scopeKind) entityMissing;
+ misplaced = builtins.filter (k: !(builtins.elem k descendants)) entityMissing;
+ in
+ # An entity arg that is neither in-ctx nor a descendant → inert.
+ if misplaced != [ ] then
+ fx.pure { inert = true; }
+ # First descendant arg fans out — unless the same source is also
+ # injected at the descendant kind (e.g. den.default), in which case
+ # it reaches the descendant directly and fanning out here would
+ # double-cover; such an aspect is inert at this scope.
+ else if descendants != [ ] then
+ (
+ let
+ # Fan a descendant whose parent kind is reachable NOW (the scope
+ # itself or an already-fanned intermediate); the recursion fans
+ # deeper descendants once their parent is bound. Shallowest-first,
+ # NOT alphabetical — a transitive chain must bind the intermediate
+ # before its child (`{ pet, toy }`: fan `pet`, then `toy` off it).
+ fanable = argClass.fanableDescendants schema scopeKind availRecords descendants;
+ pick = if fanable != [ ] then builtins.head fanable else builtins.head descendants;
+ in
+ if sharedWithDescendant pick then fx.pure { inert = true; } else fanOut pick
+ )
+ # Only non-entity (pipe/conditional/enrichment) args remain → defer.
+ else
+ fx.bind (fx.send "defer" {
+ child = aspect;
+ inherit requiredKeys;
+ requiredArgs = missingKeys;
+ inherit hasPipeArgs;
+ }) (_: fx.pure { deferred = true; })
);
inherit state;
};
diff --git a/nix/lib/aspects/fx/handlers/compile-conditional.nix b/nix/lib/aspects/fx/handlers/compile-conditional.nix
index dc2ac0da7..e4af2578c 100644
--- a/nix/lib/aspects/fx/handlers/compile-conditional.nix
+++ b/nix/lib/aspects/fx/handlers/compile-conditional.nix
@@ -29,31 +29,39 @@ let
) (fx.pure [ ]) aspects;
# Collect constraint registry entries from the current scope and all
- # ancestor scopes via scopeParent. Normalizes ownerChain to [] so
- # isExcludedInScope treats all collected entries as in-scope.
+ # ancestor scopes via scopeParent (the shared cycle-guarded walk from
+ # constraint.nix), then NORMALIZES ownerChain to [] so isExcludedInScope treats
+ # all collected entries as in-scope (scope ancestry already establishes
+ # relevance — the guard view does not need the within-scope includesChain filter
+ # that check-constraint keeps).
collectScopeConstraints =
- scopedRegistry: scopeParentMap:
- let
- go =
- scope: acc:
- let
- scopeEntries = scopedRegistry.${scope} or { };
- # Normalize entries: clear ownerChain since scope ancestry
- # already establishes relevance.
- normalized = lib.mapAttrs (_: entries: map (e: e // { ownerChain = [ ]; }) entries) scopeEntries;
- merged = lib.zipAttrsWith (_: builtins.concatLists) [
- acc
- normalized
- ];
- parent = scopeParentMap.${scope} or null;
- in
- if parent == null then merged else go parent merged;
- in
- go;
+ scopedRegistry: scopeParentMap: scope:
+ lib.mapAttrs (_: entries: map (e: e // { ownerChain = [ ]; }) entries) (
+ collectScopedConstraints scopedRegistry scopeParentMap scope
+ );
- # Reuse constraint lookup from constraint.nix to avoid duplicating
- # the prefix-matching logic (identity path splitting + prefix search).
- inherit (import ./constraint.nix { inherit lib den; }) lookupEntries;
+ # Reuse the shared scope+ancestor walk + the constraint lookup from
+ # constraint.nix (avoids duplicating the cycle-guarded walk and the prefix-
+ # matching identity logic).
+ inherit (import ./constraint.nix { inherit lib den; })
+ lookupEntries
+ isAncestorChain
+ collectScopedConstraints
+ foldScopeAncestors
+ ;
+
+ # Union the per-scope path sets over `scope` + its ancestors — an entity's
+ # own + inherited membership, EXCLUDING sibling / other-entity subtrees (the
+ # same cycle-guarded walk as collectScopedConstraints, merging with `//` since
+ # the pathSet is membership booleans). Guards consult THIS instead of the
+ # fleet-wide flat pathSet, which accumulated every walked scope's aspects and
+ # leaked sibling membership in an eval-order-dependent way (#613: a host's
+ # `hasAspect` guard saw an aspect another host included). pathSetByScope mirrors
+ # the flat set's key space (identity.nix), so the guard's `pathSet ? identity.key
+ # ref` check works unchanged against this union.
+ scopedPathSet =
+ pathSetByScope: scopeParentMap: scope:
+ foldScopeAncestors (a: b: a // b) scopeParentMap (s: pathSetByScope.${s} or { }) scope;
# Check if an aspect identity is excluded in a constraint registry.
isExcludedInScope =
@@ -61,18 +69,20 @@ let
nodeIdentity:
let
allEntries = lookupEntries constraintRegistry nodeIdentity;
- isAncestor = ownerChain: lib.take (builtins.length ownerChain) includesChain == ownerChain;
inScope =
entry:
entry.type == "exclude"
- && ((entry.scope or "global") == "global" || isAncestor (entry.ownerChain or [ ]));
+ && (
+ (entry.scope or "global") == "global" || isAncestorChain includesChain (entry.ownerChain or [ ])
+ );
in
builtins.any inScope allEntries;
- # In-flight pathSet is not class-partitioned, so forClass approximates
- # as forAnyClass (may produce false positives across classes, never false
- # negatives). Accurate enough for guards — the pathSet reflects all
- # classes walked so far in the current resolution.
+ # The pathSet handed in is the scope-restricted union (scopedPathSet over
+ # currentScope + ancestors, #613) — an entity's own + inherited membership.
+ # It is not class-partitioned, so forClass approximates as forAnyClass (may
+ # produce false positives across classes within that scope, never false
+ # negatives).
mkPipelineHasAspect = pathSet: excludeCheck: {
__functor =
_: ref:
@@ -168,24 +178,27 @@ in
# Evaluate guard with exclude awareness. The constraint registry
# has already been populated by emitPolicyEffectsThen (which
# registers excludes before processing includes).
- resume = fx.bind (fx.send "get-path-set" null) (
- pathSet:
- fx.bind fx.effects.state.get (
- currentState:
- let
- scopedRegistry = (currentState.scopedConstraintRegistry or (_: { })) null;
- scopeParentMap = (currentState.scopeParent or (_: { })) null;
- constraintRegistry =
- collectScopeConstraints scopedRegistry scopeParentMap currentState.currentScope
- { };
- guardCtx = mkGuardCtx {
- inherit pathSet constraintRegistry;
- scopeHandlers = condNode.__scopeHandlers or { };
- };
- pass = condNode.meta.guard guardCtx;
- in
- if pass then emitGuardedAspects condNode else deferConditional condNode
- )
+ # Scope the guard's membership view to this entity's own subtree
+ # (currentScope + ancestors), NOT the fleet-wide flat pathSet — the
+ # same scope+ancestor restriction already applied to the constraint
+ # registry below. Otherwise a sibling host that included the aspect
+ # earlier in the walk leaks into this guard (#613).
+ resume = fx.bind fx.effects.state.get (
+ currentState:
+ let
+ scope = currentState.currentScope;
+ pathSetByScope = (currentState.pathSetByScope or (_: { })) null;
+ scopeParentMap = (currentState.scopeParent or (_: { })) null;
+ scopedRegistry = (currentState.scopedConstraintRegistry or (_: { })) null;
+ constraintRegistry = collectScopeConstraints scopedRegistry scopeParentMap scope;
+ guardCtx = mkGuardCtx {
+ pathSet = scopedPathSet pathSetByScope scopeParentMap scope;
+ inherit constraintRegistry;
+ scopeHandlers = condNode.__scopeHandlers or { };
+ };
+ pass = condNode.meta.guard guardCtx;
+ in
+ if pass then emitGuardedAspects condNode else deferConditional condNode
);
inherit state;
};
@@ -225,57 +238,59 @@ in
let
drainPass =
pending: prevResults:
- fx.bind (fx.send "get-path-set" null) (
- pathSet:
- fx.bind fx.effects.state.get (
- currentState:
- let
- # Build scope-specific constraint registry from current
- # scope and ancestors — tux's excludes don't leak to pingu.
- scopedRegistry = (currentState.scopedConstraintRegistry or (_: { })) null;
- scopeParentMap = (currentState.scopeParent or (_: { })) null;
- constraintRegistry = collectScopeConstraints scopedRegistry scopeParentMap scope { };
- len = builtins.length pending;
- go =
- idx: acc:
- if idx >= len then
- acc
- else
- let
- condNode = builtins.elemAt pending idx;
- guardCtx = mkGuardCtx {
- inherit pathSet constraintRegistry;
- scopeHandlers = condNode.__scopeHandlers or { };
- };
- pass = condNode.meta.guard guardCtx;
- in
- go (idx + 1) (
- fx.bind acc (
- prev:
- if pass then
- fx.bind (emitGuardedAspects condNode) (
- results:
- fx.pure {
- emitted = prev.emitted ++ results;
- failed = prev.failed;
- progressed = true;
- }
- )
- else
+ fx.bind fx.effects.state.get (
+ currentState:
+ let
+ # Build scope-specific constraint registry AND membership
+ # set from this deferred conditional's scope + ancestors —
+ # tux's excludes don't leak to pingu, and a sibling host's
+ # aspects don't leak into this guard's hasAspect (#613).
+ scopedRegistry = (currentState.scopedConstraintRegistry or (_: { })) null;
+ scopeParentMap = (currentState.scopeParent or (_: { })) null;
+ pathSetByScope = (currentState.pathSetByScope or (_: { })) null;
+ constraintRegistry = collectScopeConstraints scopedRegistry scopeParentMap scope;
+ guardPathSet = scopedPathSet pathSetByScope scopeParentMap scope;
+ len = builtins.length pending;
+ go =
+ idx: acc:
+ if idx >= len then
+ acc
+ else
+ let
+ condNode = builtins.elemAt pending idx;
+ guardCtx = mkGuardCtx {
+ pathSet = guardPathSet;
+ inherit constraintRegistry;
+ scopeHandlers = condNode.__scopeHandlers or { };
+ };
+ pass = condNode.meta.guard guardCtx;
+ in
+ go (idx + 1) (
+ fx.bind acc (
+ prev:
+ if pass then
+ fx.bind (emitGuardedAspects condNode) (
+ results:
fx.pure {
- inherit (prev) emitted progressed;
- failed = prev.failed ++ [ condNode ];
+ emitted = prev.emitted ++ results;
+ failed = prev.failed;
+ progressed = true;
}
- )
- );
- in
- go 0 (
- fx.pure {
- emitted = prevResults;
- failed = [ ];
- progressed = false;
- }
- )
+ )
+ else
+ fx.pure {
+ inherit (prev) emitted progressed;
+ failed = prev.failed ++ [ condNode ];
+ }
+ )
+ );
+ in
+ go 0 (
+ fx.pure {
+ emitted = prevResults;
+ failed = [ ];
+ progressed = false;
+ }
)
);
diff --git a/nix/lib/aspects/fx/handlers/compile-parametric.nix b/nix/lib/aspects/fx/handlers/compile-parametric.nix
index c313ac707..6c21b2801 100644
--- a/nix/lib/aspects/fx/handlers/compile-parametric.nix
+++ b/nix/lib/aspects/fx/handlers/compile-parametric.nix
@@ -62,6 +62,21 @@ in
inherit (param) identity ctx;
gated = true;
}
+ # Relationship fan-out: one compiled aspect per descendant
+ # child, each re-resolved at the current (emitting) scope.
+ else if bindResult ? fanOut then
+ builtins.foldl' (
+ acc: compiled:
+ fx.bind acc (
+ prev:
+ fx.bind (fx.send "resolve" {
+ aspect = compiled;
+ inherit (param) identity ctx;
+ gated = true;
+ }) (resolved: fx.pure (prev ++ resolved))
+ )
+ ) (fx.pure [ ]) bindResult.fanOut
+ # deferred or inert → contributes nothing here.
else
fx.pure [ ]
)
diff --git a/nix/lib/aspects/fx/handlers/constraint.nix b/nix/lib/aspects/fx/handlers/constraint.nix
index e5f70dd4f..d6ce25210 100644
--- a/nix/lib/aspects/fx/handlers/constraint.nix
+++ b/nix/lib/aspects/fx/handlers/constraint.nix
@@ -7,8 +7,6 @@
...
}:
let
- inherit (import ./state-util.nix) scopedAppend;
-
lookupEntries =
registry: nodeIdentity:
let
@@ -25,14 +23,76 @@ let
else
exact;
+ # Chain-prefix ancestry: is `ownerChain` a prefix of the includes `chain`. The
+ # shared scope-relevance atom (also consumed by compile-conditional.nix).
+ isAncestorChain = chain: ownerChain: lib.take (builtins.length ownerChain) chain == ownerChain;
+
filterByScope =
currentChain: entries:
let
- isAncestor = ownerChain: lib.take (builtins.length ownerChain) currentChain == ownerChain;
- inScope = entry: (entry.scope or "global") == "global" || isAncestor (entry.ownerChain or [ ]);
+ inScope =
+ entry:
+ (entry.scope or "global") == "global" || isAncestorChain currentChain (entry.ownerChain or [ ]);
in
builtins.filter inScope entries;
+ # Cycle-guarded fold up the scopeParent chain from `scope`, merging each scope's
+ # value (`at s`) into the accumulator via `merge`. The shared scope+ancestor walk
+ # skeleton (constraint registry AND the guard pathSet, compile-conditional.nix).
+ # Stops on null or any revisit — scopeParent can cycle in spawn/forward merged
+ # sub-pipelines. Own/closer scopes are merged before ancestors.
+ foldScopeAncestors =
+ merge: scopeParentMap: at: scope:
+ let
+ go =
+ seen: s: acc:
+ if s == null || seen ? ${s} then
+ acc
+ else
+ go (seen // { ${s} = true; }) (scopeParentMap.${s} or null) (merge acc (at s));
+ in
+ go { } scope { };
+
+ # The constraint registry relevant to a scope, as one identity→entries map —
+ # the merge of the scope's own + ANCESTOR scopes' entries (cycle-guarded walk
+ # up scopeParent). Replaces the fleet-wide flat registry: it is the SINGLE
+ # lookup all readers share (check-constraint + the policy-name exclusion
+ # filters), so the flat registry is gone. A SIBLING entity's excludes live under
+ # the sibling's scope key — NOT an ancestor — and are therefore ABSENT, fixing
+ # the eval-order sibling-leak (#613 analog) for BOTH aspect-content excludes
+ # (`den.aspects.X.excludes`) and policy-name excludes. Schema-tier excludes
+ # (`den.schema.KIND.excludes`) register at the resolved KIND scope and reach
+ # descendants via the ancestor walk (the late-policy dispatch scopes to the
+ # SIBLING it emits for — see scopedConstraintsForScope — so a kind's own
+ # excludes are in scope). Cycle-guarded: scopeParent can cycle in spawn/forward
+ # merged sub-pipelines, and check-constraint runs for EVERY node.
+ collectScopedConstraints =
+ scopedRegistry: scopeParentMap: scope:
+ foldScopeAncestors (
+ a: b:
+ lib.zipAttrsWith (_: builtins.concatLists) [
+ a
+ b
+ ]
+ ) scopeParentMap (s: scopedRegistry.${s} or { }) scope;
+
+ # The shared entry point: build the scope-relevant constraint registry from
+ # pipeline state, FOR a given target scope. Every reader goes through this, so
+ # there is ONE registry (no fleet-wide flat duplicate). The scope is explicit
+ # because the LATE-policy dispatch (policy/schema emitLateForSibling) runs at the
+ # PARENT scope but emits for a CHILD sibling — it must scope to the sibling
+ # (where that sibling's + its kind's excludes live), not the parent. `scope ==
+ # null` (bare-handler unit tests / empty state) ⇒ empty registry.
+ scopedConstraintsForScope =
+ state: scope:
+ collectScopedConstraints ((state.scopedConstraintRegistry or (_: { })) null) (
+ (state.scopeParent or (_: { }))
+ null
+ ) scope;
+
+ # The common case: scope to the state's currentScope.
+ scopedConstraintsFor = state: scopedConstraintsForScope state (state.currentScope or null);
+
entryToResume =
entry:
if entry.type == "exclude" then
@@ -67,7 +127,7 @@ let
in
{
resume = null;
- state = (scopedAppend state "scopedConstraintFilters" currentScope filterEntry) // {
+ state = state // {
flatConstraintFilters = (state.flatConstraintFilters or [ ]) ++ [ filterEntry ];
};
}
@@ -79,11 +139,13 @@ let
owner = param.owner or "";
inherit scope ownerChain;
};
- flatReg = state.flatConstraintRegistry or { };
- existing = flatReg.${param.identity} or [ ];
in
{
resume = null;
+ # Only the scope-keyed registry is written; all readers go through
+ # scopedConstraintsFor (entity-scoped: scope + ancestors), so the former
+ # fleet-wide flatConstraintRegistry — which leaked excludes across
+ # siblings — is gone.
state =
let
all = (state.scopedConstraintRegistry or (_: { })) null;
@@ -95,12 +157,7 @@ let
};
};
in
- (state // { scopedConstraintRegistry = _: updatedRegistry; })
- // {
- flatConstraintRegistry = flatReg // {
- ${param.identity} = existing ++ [ entry ];
- };
- };
+ state // { scopedConstraintRegistry = _: updatedRegistry; };
};
"check-constraint" =
@@ -109,7 +166,11 @@ let
nodeIdentity = if builtins.isAttrs param then param.identity else param;
aspect = if builtins.isAttrs param then param.aspect or null else null;
currentChain = ((state.scopedIncludesChain or (_: { })) null).${state.currentScope} or [ ];
- allEntries = lookupEntries (state.flatConstraintRegistry or { }) nodeIdentity;
+ # #613 analog: entity-scoped registry (scopedConstraintsFor: scope +
+ # ancestors), NOT the fleet-wide flat registry — a sibling entity's exclude
+ # must not suppress this node. filterByScope still applies for within-scope
+ # include nesting.
+ allEntries = lookupEntries (scopedConstraintsFor state) nodeIdentity;
scopedEntries = filterByScope currentChain allEntries;
firstEntry = if scopedEntries == [ ] then null else builtins.head scopedEntries;
in
@@ -142,5 +203,13 @@ let
};
in
{
- inherit constraintRegistryHandler lookupEntries;
+ inherit
+ constraintRegistryHandler
+ lookupEntries
+ isAncestorChain
+ foldScopeAncestors
+ collectScopedConstraints
+ scopedConstraintsFor
+ scopedConstraintsForScope
+ ;
}
diff --git a/nix/lib/aspects/fx/handlers/defer.nix b/nix/lib/aspects/fx/handlers/defer.nix
index 446ffc268..23d442bbe 100644
--- a/nix/lib/aspects/fx/handlers/defer.nix
+++ b/nix/lib/aspects/fx/handlers/defer.nix
@@ -6,7 +6,10 @@
}:
let
inherit (den.lib) fx;
+ inherit (den.lib.aspects.fx) argClass;
inherit (import ./state-util.nix) scopedAppend;
+ schema = den.schema or { };
+ isEntityKind = argClass.isEntityKind schema;
in
{
deferHandler = {
@@ -14,6 +17,15 @@ in
{ param, state }:
let
inherit (param) child requiredKeys requiredArgs;
+ # Entity-kind args never reach defer: bind classifies every entity arg
+ # (ctx/fan-out/inert) synchronously. A leaked entity arg here is a
+ # resolver bug — fail loud rather than silently dangle.
+ entityArgs = builtins.filter isEntityKind requiredArgs;
+ guard =
+ if entityArgs == [ ] then
+ null
+ else
+ throw "den: entity-kind arg '${builtins.head entityArgs}' reached defer for aspect '${child.name or ""}' — bind should have classified it (fan-out/inert); this is a resolver bug";
stub = {
name = child.name or "";
meta = (child.meta or { }) // {
@@ -22,7 +34,7 @@ in
includes = [ ];
};
in
- {
+ builtins.seq guard {
resume = fx.bind (fx.send "resolve-complete" stub) (_: fx.pure [ ]);
state = scopedAppend state "scopedDeferredIncludes" state.currentScope {
inherit child requiredKeys requiredArgs;
diff --git a/nix/lib/aspects/fx/handlers/dispatch-policies.nix b/nix/lib/aspects/fx/handlers/dispatch-policies.nix
index fc15be10c..00f97ce80 100644
--- a/nix/lib/aspects/fx/handlers/dispatch-policies.nix
+++ b/nix/lib/aspects/fx/handlers/dispatch-policies.nix
@@ -10,6 +10,7 @@
}:
let
inherit (den.lib) fx;
+ inherit (import ./constraint.nix { inherit lib den; }) scopedConstraintsFor;
# Check if a policy name is excluded by any constraint in the registry.
isExcluded =
@@ -24,7 +25,9 @@ in
"dispatch-policies" =
{ param, state }:
let
- registry = state.flatConstraintRegistry or { };
+ # Entity-scoped (scope + ancestors, NOT fleet-wide) — a sibling entity's
+ # policy-exclude must not filter this scope's policies (#613 analog).
+ registry = scopedConstraintsFor state;
filteredPolicies = lib.filterAttrs (name: _: !isExcluded registry name) param.aspectPolicies;
in
{
diff --git a/nix/lib/aspects/fx/handlers/policy.nix b/nix/lib/aspects/fx/handlers/policy.nix
index 3d65dddbe..26b007c81 100644
--- a/nix/lib/aspects/fx/handlers/policy.nix
+++ b/nix/lib/aspects/fx/handlers/policy.nix
@@ -14,15 +14,9 @@ let
in
{
resume = null;
- state =
- (scopedMerge state "scopedAspectPolicies" state.currentScope {
- ${param.name} = entry;
- })
- // {
- flatAspectPolicies = (state.flatAspectPolicies or { }) // {
- ${param.name} = entry;
- };
- };
+ state = scopedMerge state "scopedAspectPolicies" state.currentScope {
+ ${param.name} = entry;
+ };
};
};
in
diff --git a/nix/lib/aspects/fx/handlers/push-scope.nix b/nix/lib/aspects/fx/handlers/push-scope.nix
index a70c41154..d0572a0da 100644
--- a/nix/lib/aspects/fx/handlers/push-scope.nix
+++ b/nix/lib/aspects/fx/handlers/push-scope.nix
@@ -1,6 +1,9 @@
# Effect handler: push-scope
-# Atomically sets currentScope, scopeContexts, scopeParent,
-# inherits scopedAspectPolicies, and fans out scopedDeferredIncludes.
+# Atomically sets currentScope, scopeContexts, scopeParent, and inherits
+# scopedAspectPolicies. Deferred includes are NOT inherited to child scopes:
+# entity-kind args are classified synchronously in bind (fan-out/inert/ctx),
+# never carried cross-scope. Non-entity (pipe/enrichment) deferred includes are
+# drained same-scope (drain.nix / resolve.nix baseDrain / scope-widen).
{
lib,
den,
@@ -22,8 +25,6 @@ let
scopeHandlers = constantHandler (
scopedCtx // lib.optionalAttrs (entityClass != null) { class = entityClass; }
);
- allDeferred = (state.scopedDeferredIncludes or (_: { })) null;
- parentItems = allDeferred.${parentScope} or [ ];
in
let
prevContexts = state.scopeContexts null;
@@ -32,9 +33,23 @@ let
prevEntityClass = (state.scopeEntityClass or (_: { })) null;
prevEntityKind = (state.scopeEntityKind or (_: { })) null;
prevSourcePolicy = (state.scopeSourcePolicy or (_: { })) null;
+ prevIsolated = (state.scopeIsolated or (_: { })) null;
+ prevScopeByEntity = (state.scopeByEntity or (_: { })) null;
updatedContexts = prevContexts // {
${newScopeId} = scopedCtx;
};
+ # Spec→scope link: record the entity scope this push created, keyed by
+ # (parentScope, id_hash). The instantiate spec — registered at the same
+ # parent scope and carrying the same entity record — resolves its scope
+ # via this link directly (no post-hoc name-infix reconstruction).
+ # Only entity scopes (entityKind set, record carries id_hash) are linked.
+ entityRecord = if entityKind == null then null else scopedCtx.${entityKind} or null;
+ entityIdHash = if entityRecord == null then null else entityRecord.id_hash or null;
+ updatedScopeByEntity =
+ prevScopeByEntity
+ // lib.optionalAttrs (entityIdHash != null) {
+ "${parentScope}\n${entityIdHash}" = newScopeId;
+ };
updatedParent = prevParent // lib.optionalAttrs (!isSameScope) { ${newScopeId} = parentScope; };
updatedPolicies = prevPolicies // {
${newScopeId} = prevPolicies.${newScopeId} or { };
@@ -46,33 +61,27 @@ let
updatedSourcePolicy =
prevSourcePolicy
// lib.optionalAttrs (sourcePolicyName != null) { ${newScopeId} = sourcePolicyName; };
+ isolatedKind = entityKind != null && (den.schema.${entityKind}.isolated or false);
+ updatedIsolated = prevIsolated // lib.optionalAttrs isolatedKind { ${newScopeId} = true; };
in
{
resume = {
inherit scopeHandlers;
scopeId = newScopeId;
};
- state =
- state
- // {
- currentScope = newScopeId;
- inLateDispatch = false;
- inLateDispatchStack = (state.inLateDispatchStack or [ ]) ++ [ (state.inLateDispatch or false) ];
- scopeContexts = _: updatedContexts;
- scopeParent = _: updatedParent;
- scopedAspectPolicies = _: updatedPolicies;
- scopeEntityClass = _: updatedEntityClass;
- scopeEntityKind = _: updatedEntityKind;
- scopeSourcePolicy = _: updatedSourcePolicy;
- }
- // lib.optionalAttrs (parentItems != [ ]) {
- scopedDeferredIncludes =
- _:
- allDeferred
- // {
- ${newScopeId} = (allDeferred.${newScopeId} or [ ]) ++ parentItems;
- };
- };
+ state = state // {
+ currentScope = newScopeId;
+ inLateDispatch = false;
+ inLateDispatchStack = (state.inLateDispatchStack or [ ]) ++ [ (state.inLateDispatch or false) ];
+ scopeContexts = _: updatedContexts;
+ scopeParent = _: updatedParent;
+ scopedAspectPolicies = _: updatedPolicies;
+ scopeEntityClass = _: updatedEntityClass;
+ scopeEntityKind = _: updatedEntityKind;
+ scopeSourcePolicy = _: updatedSourcePolicy;
+ scopeIsolated = _: updatedIsolated;
+ scopeByEntity = _: updatedScopeByEntity;
+ };
};
};
in
diff --git a/nix/lib/aspects/fx/handlers/resolve-schema-entity.nix b/nix/lib/aspects/fx/handlers/resolve-schema-entity.nix
index 8aa3cbf40..deb6e63b8 100644
--- a/nix/lib/aspects/fx/handlers/resolve-schema-entity.nix
+++ b/nix/lib/aspects/fx/handlers/resolve-schema-entity.nix
@@ -1,6 +1,8 @@
# Handles: resolve-schema-entity
-# Entity resolution: push scope, resolve entity, walk tree,
-# drain deferred, propagate forwards, pop scope.
+# Entity resolution: push scope, resolve entity, propagate forwards, pop scope.
+# Deferred includes are NOT refired here — entity-kind args are classified
+# synchronously in bind (fan-out/inert/ctx). Non-entity deferred includes are
+# drained post-pipeline (resolve.nix baseDrain) or on context-widen.
{
lib,
den,
@@ -31,28 +33,7 @@ let
++ map stripCtxId resolveIncludes;
};
- # Walk deferred aspects after entity resolution, collecting results.
- walkDeferred =
- scopeHandlersForCtx: scopedCtx: prevResults: childResult: satisfiable:
- builtins.foldl' (
- acc': deferred:
- fx.bind acc' (
- prev:
- let
- child = deferred.child // {
- __scopeHandlers = scopeHandlersForCtx;
- };
- in
- fx.bind (fx.send "resolve" {
- aspect = child;
- identity = identity.key child;
- ctx = scopedCtx;
- gated = true;
- }) (resolved: fx.pure (prev ++ [ resolved ]))
- )
- ) (fx.pure (prevResults ++ [ childResult ])) satisfiable;
-
- # Resolve entity tree within scope: resolve-entity → walk → drain → propagate.
+ # Resolve entity tree within scope: resolve-entity → resolve → propagate → pop.
resolveEntityInScope =
scopeHandlersForCtx: scopedCtx: param: prevResults: scopeId: parentScope:
fx.effects.scope.provide scopeHandlersForCtx (
@@ -74,15 +55,10 @@ let
resolvedList:
let
childResult = builtins.head resolvedList;
+ allResults = prevResults ++ [ childResult ];
in
- fx.bind (fx.send "drain" scopedCtx) (
- satisfiable:
- fx.bind (walkDeferred scopeHandlersForCtx scopedCtx prevResults childResult satisfiable) (
- allResults:
- fx.bind (fx.send "propagate-routes" { inherit scopeId; }) (
- _: fx.bind (fx.send "restore-scope" { inherit parentScope; }) (_: fx.pure allResults)
- )
- )
+ fx.bind (fx.send "propagate-routes" { inherit scopeId; }) (
+ _: fx.bind (fx.send "restore-scope" { inherit parentScope; }) (_: fx.pure allResults)
)
)
)
diff --git a/nix/lib/aspects/fx/identity.nix b/nix/lib/aspects/fx/identity.nix
index 0d433d38f..621de3843 100644
--- a/nix/lib/aspects/fx/identity.nix
+++ b/nix/lib/aspects/fx/identity.nix
@@ -13,6 +13,10 @@ let
# Composed: aspectPath → pathKey in one call.
key = a: pathKey (aspectPath a);
+ # Base identity without the {ctxId} instance suffix: provider chain + name.
+ # The pretty, stable fully-qualified name (e.g. "roles/workstation").
+ baseKey = a: pathKey ((a.meta.provider or [ ]) ++ [ (a.name or "") ]);
+
# True when an identity string refers to an anonymous/unresolved node.
isAnonIdentity =
id:
@@ -21,15 +25,6 @@ let
# Strip the {ctxId} suffix from an identity, yielding the base identity.
stripCtxSuffix = id: lib.head (lib.splitString "/{" id);
- toPathSet =
- paths:
- builtins.listToAttrs (
- builtins.map (p: {
- name = pathKey p;
- value = true;
- }) paths
- );
-
tombstone = resolved: extra: {
name = "~${resolved.name or ""}";
meta =
@@ -42,32 +37,40 @@ let
includes = [ ];
};
+ # The flat, scope-agnostic membership set: union of every per-scope bucket.
+ # `pathSetByScope` is the single source of truth; consumers that don't care
+ # about scope (structural hasAspect, capture) derive the flat view from it
+ # instead of a separately-maintained flat field.
+ flattenPathSetByScope = pbs: lib.foldl' (a: b: a // b) { } (builtins.attrValues pbs);
+
collectPathsHandler = {
"resolve-complete" =
{ param, state }:
let
isExcluded = param.meta.excluded or false;
+ # The entity root (host/user/home) carries __entityKind. It is indexed
+ # in pathSetByScope but excluded from resolvedNodes — an entity is not
+ # one of its own aspects.
+ isEntityRoot = param ? __entityKind;
path = aspectPath param;
- key = pathKey path;
+ nodeKey = pathKey path;
# Also store base path (without ctxId) so hasAspect can match
# without needing to know the specific context instance.
- basePath = (param.meta.provider or [ ]) ++ [ (param.name or "") ];
- baseKey = pathKey basePath;
+ nodeBaseKey = baseKey param;
in
{
resume = param;
state =
state
// lib.optionalAttrs (!isExcluded) {
- pathSet =
- _:
- (state.pathSet or (_: { })) null
- // {
- ${key} = true;
- }
- // lib.optionalAttrs (baseKey != key) {
- ${baseKey} = true;
- };
+ # The per-scope path set is the SINGLE membership record. Each node is
+ # indexed under its currentScope by BOTH the ctx-qualified nodeKey and
+ # the base key (without {ctxId}). The flat, scope-agnostic set some
+ # consumers need is just the union of these buckets
+ # (flattenPathSetByScope) — no separate flat field. Conditional guards
+ # read a scope-restricted union (currentScope + ancestors, #613); the
+ # entity-surface projected hasAspect reads one bucket by id_hash and
+ # only the base key (the extra nodeKey entry is inert there).
pathSetByScope =
_:
let
@@ -77,34 +80,42 @@ let
in
prev
// {
- ${scope} = scopeSet // {
- ${baseKey} = true;
- };
+ ${scope} =
+ scopeSet
+ // {
+ ${nodeKey} = true;
+ }
+ // lib.optionalAttrs (nodeBaseKey != nodeKey) {
+ ${nodeBaseKey} = true;
+ };
+ };
+ }
+ // lib.optionalAttrs (!isExcluded && !isEntityRoot) {
+ # Full resolved nodes keyed by unique (ctx-qualified) identity, for
+ # host.aspects. Stored behind the state thunk so a deepSeq of state
+ # never forces the node's class-content bodies (the lambda is WHNF);
+ # only reading host.aspects materializes name/meta/identity.
+ resolvedNodes =
+ _:
+ (state.resolvedNodes or (_: { })) null
+ // {
+ ${nodeKey} = param;
};
};
};
};
- pathSetHandler = {
- "get-path-set" =
- { param, state }:
- {
- resume = (state.pathSet or (_: { })) null;
- inherit state;
- };
- };
-
in
{
inherit
aspectPath
pathKey
key
+ baseKey
isAnonIdentity
stripCtxSuffix
- toPathSet
tombstone
+ flattenPathSetByScope
collectPathsHandler
- pathSetHandler
;
}
diff --git a/nix/lib/aspects/fx/key-classification.nix b/nix/lib/aspects/fx/key-classification.nix
index 458812def..bb51ec886 100644
--- a/nix/lib/aspects/fx/key-classification.nix
+++ b/nix/lib/aspects/fx/key-classification.nix
@@ -6,7 +6,7 @@
let
# Structural keys are always handled by the pipeline itself — not
# dispatched as class or nested aspect keys.
- structuralKeysSet = lib.genAttrs [
+ builtinStructuralKeys = [
"name"
"description"
"meta"
@@ -29,7 +29,10 @@ let
"__providesForwarded"
"_module"
"_"
- ] (_: true);
+ ];
+
+ # User-extensible reserved keys via den.reservedKeys option.
+ structuralKeysSet = lib.genAttrs (builtinStructuralKeys ++ (den.reservedKeys or [ ])) (_: true);
# Schema registry for key classification.
# Top-level den.classes lives outside den.schema, breaking
diff --git a/nix/lib/aspects/fx/pipeline.nix b/nix/lib/aspects/fx/pipeline.nix
index 84a1d234a..6c99d598e 100644
--- a/nix/lib/aspects/fx/pipeline.nix
+++ b/nix/lib/aspects/fx/pipeline.nix
@@ -46,7 +46,6 @@ let
// handlers.includeHandler
// handlers.checkDedupHandler
// handlers.ctxSeenHandler
- // identity.pathSetHandler
// identity.collectPathsHandler
// handlers.registerAspectPolicyHandler
// handlers.registerRouteHandler
@@ -134,24 +133,25 @@ let
defaultState = {
# --- Flat state (global by design, not scoped) ---
seen = _: { };
- pathSet = _: { };
- # Per-scope path set: scopeId → { basePathKey → true }. Byproduct of the
- # structural walk, bucketed by the scope that owns each node. Powers the
- # projected (in-context) hasAspect. Thunked to survive per-step deepSeq.
+ # Per-scope path set: scopeId → { pathKey → true } (both the ctx-qualified
+ # nodeKey and the base key). Byproduct of the structural walk, bucketed by
+ # the scope that owns each node — the SINGLE membership record. Powers the
+ # projected (in-context) hasAspect and the scope-restricted guard membership
+ # check (#613); the flat scope-agnostic view is its union
+ # (identity.flattenPathSetByScope). Thunked to survive per-step deepSeq.
pathSetByScope = _: { };
+ # Full resolved nodes keyed by unique identity, for entity.aspects.
+ resolvedNodes = _: { };
# --- Scope-partitioned output state (handlers write here) ---
scopedClassImports = _: { };
scopedAspectPolicies = _: { };
- # Pre-merged flat view (avoid O(S) rebuild per installPolicies call).
- flatAspectPolicies = { };
scopedDeferredIncludes = _: { };
scopedDeferredConditionals = _: { };
scopedIncludesChain = _: { };
scopedConstraintRegistry = _: { };
- scopedConstraintFilters = _: { };
- # Pre-merged flat views (avoid O(S) rebuild per check-constraint call).
- flatConstraintRegistry = { };
+ # Flat filter list only (excludes/substitutes are entity-scoped via
+ # scopedConstraintRegistry; filters have no scoped registry).
flatConstraintFilters = [ ];
scopedRoutes = _: { };
scopedInstantiates = _: { };
@@ -170,6 +170,15 @@ let
currentScope = "__unscoped";
scopeContexts = _: { };
scopeParent = _: { };
+ # Spec→scope link (the entity scope is recorded, never name-infix matched):
+ # when resolve.to creates an entity scope (push-scope with entityKind set), record
+ # the scope it created keyed by (parentScope, entity id_hash). An instantiate
+ # spec — registered at the SAME parent scope, carrying the same entity record
+ # (hence id_hash) — looks its entity scope up directly. Key combines parent +
+ # id_hash because id_hash is context-free (kind+name, NOT ancestry), so two
+ # same-name entities on different systems share an id_hash but have distinct
+ # parent (system=…) scopes. See resolve.nix entityScopeFor.
+ scopeByEntity = _: { };
# --- Policy dispatch tracking ---
firedPolicyNames = _: { };
diff --git a/nix/lib/aspects/fx/policy/dispatch.nix b/nix/lib/aspects/fx/policy/dispatch.nix
index 3ca0c04fa..79d7fc194 100644
--- a/nix/lib/aspects/fx/policy/dispatch.nix
+++ b/nix/lib/aspects/fx/policy/dispatch.nix
@@ -39,15 +39,25 @@ let
dispatchAspect =
aspectPolicies: firedPolicies: resolveCtx:
let
+ # `self` is always bound to the dispatching scope's context, so a policy
+ # can guard with `{ self, ... }:` to mean "fire at my registration scope"
+ # — distinct from `{ , ... }:` which fans across entities of that
+ # kind. The binding must be present (not just allowed) because Nix's
+ # attrset pattern requires every named arg, used or not. See the
+ # late-dispatch filter in policy/schema.nix, which keeps `self`-guarded
+ # policies from re-firing at descendant scopes.
+ ctx = resolveCtx // {
+ self = resolveCtx.self or resolveCtx;
+ };
entries = lib.attrsToList aspectPolicies;
matching = builtins.filter (
- e: resolveArgsSatisfied e.value.fn resolveCtx && !(firedPolicies ? ${e.name})
+ e: resolveArgsSatisfied e.value.fn ctx && !(firedPolicies ? ${e.name})
) entries;
in
map (
entry:
let
- result = entry.value.fn resolveCtx;
+ result = entry.value.fn ctx;
rawEffects = if builtins.isList result then result else [ result ];
in
{
diff --git a/nix/lib/aspects/fx/policy/schema.nix b/nix/lib/aspects/fx/policy/schema.nix
index d1dcefaa8..4a805d910 100644
--- a/nix/lib/aspects/fx/policy/schema.nix
+++ b/nix/lib/aspects/fx/policy/schema.nix
@@ -16,6 +16,8 @@
mkSupplementalResolution,
}:
let
+ inherit (import ../handlers/constraint.nix { inherit lib den; }) scopedConstraintsForScope;
+
# Determine target entity kind from a schema effect.
resolveTargetKind =
entityKind: schemaEffect:
@@ -56,26 +58,39 @@ let
# In-context `.hasAspect` answers PROJECTED membership — what's delivered
# into this scope (incl. `provides`), not the structural registry tree. The
- # owning host's production run already bucketed each scope's path set under
- # `__pathSetByScope`. The lookup is pure and forced lazily (at a class-module
- # `mkIf`), so it never re-enters the resolve that produced it — except from
- # an `includes` position, which forces the host's own in-flight
- # `__resolveResult` and recurses. So: don't decide includes from it.
+ # owning host's production run bucketed each scope's path set under
+ # `__pathSetByScope`, RE-KEYED by entity identity (`id_hash`) at the entity
+ # surface (entities/_types.nix:pathSetByScopeOption). The lookup is pure and
+ # forced lazily (at a class-module `mkIf`), so it never re-enters the resolve
+ # that produced it — except from an `includes` position, which forces the
+ # host's own in-flight `__resolveResult` and recurses. So: don't decide
+ # includes from it.
#
- # Key by entity-kind bindings only: enrichment may add non-entity keys (e.g.
- # `system`) to the ctx, but buckets are keyed by entity scope — restricting
- # here keeps the lookup key matched. Only `.hasAspect` is swapped, and
- # `mkScopeId` keys off `.name`, so the scope ids are otherwise unperturbed.
+ # Per the projected-hasAspect spec, EVERY in-context entity-kind binding
+ # answers membership at the ACTIVE (consuming) scope — "is X delivered into
+ # THIS scope" — keyed by ONE shared scope id, not each entity's own. After
+ # the id_hash re-key the active scope's bucket is the consuming (target)
+ # entity's id_hash (the deepest scope in ctx). Keying per-entity made
+ # `host.hasAspect` read the host's OWN bucket, blinding it to aspects the
+ # host delivers DOWN to its users via `provides.to-users` — those resolve
+ # under the user scope, so only the active (consuming) bucket holds them.
overrideKinds = builtins.filter (
k: schemaEntityKindsSet ? ${k} && builtins.isAttrs (rawScopedCtx.${k} or null)
) (builtins.attrNames rawScopedCtx);
- scopeId = mkScopeId (lib.getAttrs overrideKinds rawScopedCtx);
- # Host run buckets every user scope; a host-less entity uses its own.
- ownerPathSet =
- rawScopedCtx.host.__pathSetByScope or rawScopedCtx.${targetKind}.__pathSetByScope or { };
+ # Owner = topmost ancestor along targetKind's parent chain whose ctx binding
+ # carries a production bucket; falls back to the target itself. (Generic form
+ # of "the host run buckets every descendant scope".)
+ ownerChain = den.lib.aspects.fx.argClass.ancestorChain den.schema targetKind;
+ ownerKind = lib.findFirst (
+ k: builtins.isAttrs (rawScopedCtx.${k} or null) && rawScopedCtx.${k} ? __pathSetByScope
+ ) targetKind (lib.reverseList ownerChain);
+ ownerPathSet = rawScopedCtx.${ownerKind}.__pathSetByScope or { };
+ # The active scope = the consuming (target) entity's bucket — the deepest
+ # scope in ctx. Shared by every binding so `host`/`user`/etc. all answer
+ # "delivered into THIS scope", per the spec.
projected = den.lib.aspects.mkProjectedHasAspect {
pathSetByScope = ownerPathSet;
- inherit scopeId;
+ key = rawScopedCtx.${targetKind}.id_hash or null;
};
scopedCtx =
rawScopedCtx // lib.genAttrs overrideKinds (k: rawScopedCtx.${k} // { hasAspect = projected; });
@@ -168,23 +183,32 @@ let
entityKinds = den.lib.schemaUtil.schemaEntityKinds;
latePolicies = lib.filterAttrs (
name: policy:
+ let
+ policyArgs = builtins.functionArgs (policy.fn or policy);
+ requiredEntityArgs = builtins.filter (
+ k: builtins.elem k entityKinds && !(policyArgs.${k} or false)
+ ) (builtins.attrNames policyArgs);
+ in
!(alreadyFired ? ${name})
&& !(parentFiredPolicies ? ${name})
- && (
- let
- policyArgs = builtins.functionArgs (policy.fn or policy);
- requiredEntityArgs = builtins.filter (
- k: builtins.elem k entityKinds && !(policyArgs.${k} or false)
- ) (builtins.attrNames policyArgs);
- in
- requiredEntityArgs == [ ] || builtins.elem sib.targetKind requiredEntityArgs
- )
+ # `self`-guarded policies fire only at their own registration scope
+ # (during the initial dispatch); they must never re-fire at descendant
+ # scopes via the fan-out. This is what makes `{ self, ... }:` mean
+ # "fire once here", as opposed to `{ , ... }:` which fans.
+ && !(policyArgs ? self)
+ && (requiredEntityArgs == [ ] || builtins.elem sib.targetKind requiredEntityArgs)
) allAspectPolicies;
in
fx.bind fx.effects.state.get (
state:
let
- constraintRegistry = state.flatConstraintRegistry or { };
+ # Entity-scoped (NOT fleet-wide) — a sibling entity's policy-exclude must
+ # not filter this sibling's late policies (#613 analog).
+ # Scope to the SIBLING being dispatched (`sib.scopeId`), not the current
+ # (parent) scope: the late dispatch runs at the parent but emits FOR the
+ # child sibling, and the relevant excludes (e.g. den.schema.flake-system.
+ # excludes) register at the sibling/descendant scope, not an ancestor.
+ constraintRegistry = scopedConstraintsForScope state sib.scopeId;
isExcluded = name: builtins.any (e: e.type == "exclude") (constraintRegistry.${name} or [ ]);
filteredPolicies = lib.filterAttrs (name: _: !isExcluded name) latePolicies;
resolveCtx = sib.scopedCtx // {
diff --git a/nix/lib/aspects/fx/resolve.nix b/nix/lib/aspects/fx/resolve.nix
index b0066d6b7..4798b1a0e 100644
--- a/nix/lib/aspects/fx/resolve.nix
+++ b/nix/lib/aspects/fx/resolve.nix
@@ -9,7 +9,24 @@ let
inherit (import ./wrap-classes.nix { inherit lib den; }) wrapCollectedClasses;
inherit (import ./assemble-pipes.nix { inherit lib den; }) assemblePipes;
inherit (import ./spawn-node.nix { inherit lib den; }) mkSpawnNode;
- route = import ./route { inherit lib den; };
+ routeEdges = import ./edges/route.nix { inherit lib den; };
+ inherit (import ./edge-trace.nix { inherit lib den; })
+ extractEdgeTrace
+ extractTopLevelEdges
+ sortEdges
+ ;
+ inherit (import ./scope-walk.nix { inherit lib; }) subtreeScopes dedupByKey;
+ inherit (import ./edges/materialize.nix { inherit lib den; }) assembleSubtree;
+ inherit (import ./edges/pi.nix { inherit lib; }) mkStaticPi;
+ inherit (import ./edges/instantiate-edges.nix { inherit lib den; }) mkInstantiateEdges;
+ inherit (import ./edges/edge.nix { inherit lib; }) scopeName;
+ inherit (import ./edges/provides.nix { inherit lib den; })
+ applyProvidesEdges
+ dedupProvides
+ providesEdges
+ ;
+ inherit (import ./edges/materialize-unified.nix { inherit lib den; }) materializeUnified;
+ instantiateEdges = import ./edges/instantiate.nix { inherit lib; };
handlers = den.lib.aspects.fx.handlers;
# Check if `ancestor` is an ancestor of `descendant` in the scopeParent tree.
@@ -23,28 +40,6 @@ let
else
parent == ancestor || isAncestorOf scopeParent ancestor parent;
- # Dedup provides by composite key (policyName/class/path).
- dedupProvides =
- raw:
- let
- go =
- seen: specs:
- if specs == [ ] then
- [ ]
- else
- let
- s = builtins.head specs;
- rest = builtins.tail specs;
- pn = s.__providePolicyName or null;
- key = if pn != null then "${pn}/${s.class}/${lib.concatStringsSep "/" (s.path or [ ])}" else null;
- in
- if key != null && seen ? ${key} then
- go seen rest
- else
- [ s ] ++ go (if key != null then seen // { ${key} = true; } else seen) rest;
- in
- go { } raw;
-
# Phase 1: Wrap collected class imports per-scope.
# Deduplicates modules with identical keys across scopes: when a shared
# aspect is included by both host and user, it emits class modules in
@@ -56,109 +51,36 @@ let
wrappedPerScope = lib.mapAttrs (
scopeId: scopeClasses: wrapCollectedClasses (scopeContexts.${scopeId} or ctx) scopeClasses
) scopedClassImportsRaw;
- # Fold scopes, deduplicating keyed modules (first occurrence wins).
- merged =
- let
- go =
- acc: scopeData:
- let
- allClasses = lib.unique (builtins.attrNames acc.classes ++ builtins.attrNames scopeData);
- in
- builtins.foldl' (
- a: cls:
- let
- existing = a.classes.${cls} or [ ];
- seenKeys = a.keys.${cls} or { };
- newMods = scopeData.${cls} or [ ];
- filtered = builtins.filter (
- m:
- let
- k = m.key or null;
- in
- k == null || !(seenKeys ? ${k})
- ) newMods;
- addedKeys = builtins.foldl' (
- ks: m:
- let
- k = m.key or null;
- in
- if k == null then ks else ks // { ${k} = true; }
- ) seenKeys filtered;
- in
- {
- classes = a.classes // {
- ${cls} = existing ++ filtered;
- };
- keys = a.keys // {
- ${cls} = addedKeys;
- };
- }
- ) acc allClasses;
- final = builtins.foldl' go {
- classes = { };
- keys = { };
- } (builtins.attrValues wrappedPerScope);
- in
- final.classes;
+ # Per class, concatenate every scope's modules (scope attr-name order)
+ # and dedup by key first-occurrence-wins. Equivalent to the old per-class
+ # cross-scope seenKeys fold; null-keyed (anon) modules are never deduped.
+ scopeData = builtins.attrValues wrappedPerScope;
+ allClasses = lib.unique (builtins.concatMap builtins.attrNames scopeData);
+ merged = lib.genAttrs allClasses (
+ cls: dedupByKey (m: m.key or null) (builtins.concatMap (sd: sd.${cls} or [ ]) scopeData)
+ );
in
{
classImports = merged;
perScope = wrappedPerScope;
};
- # Phase 2: Apply policy.provide — inject modules into target classes.
- applyProvides =
- ctx: scopeContexts: scopedProvides: acc:
- let
- allProvides = dedupProvides (lib.concatLists (lib.attrValues scopedProvides));
- in
- builtins.foldl' (
- prev: spec:
- let
- targetClass = spec.class;
- path = spec.path or [ ];
- sid = spec.sourceScopeId;
- scopeCtx = scopeContexts.${sid} or ctx;
- rawModule = if path == [ ] then spec.module else lib.setAttrByPath path spec.module;
- wrapped = den.lib.aspects.fx.aspect.wrapClassModule {
- inherit ctx;
- module = rawModule;
- aspectPolicy = null;
- globalPolicy = null;
- };
- wrappedMod =
- if wrapped.unsatisfied or false then
- [ ]
- else
- let
- loc = "${targetClass}@/${lib.concatStringsSep "/" path}";
- in
- [ (lib.setDefaultModuleLocation loc wrapped.module) ];
- in
- {
- classImports = prev.classImports // {
- ${targetClass} = (prev.classImports.${targetClass} or [ ]) ++ wrappedMod;
- };
- perScope = prev.perScope // {
- ${sid} = (prev.perScope.${sid} or { }) // {
- ${targetClass} = ((prev.perScope.${sid} or { }).${targetClass} or [ ]) ++ wrappedMod;
- };
- };
- }
- ) acc allProvides;
+ # Phase 2 (policy.provide → target classes) is now an edge constructor:
+ # edges/provides.nix applyProvidesEdges. The nest-into-source-bucket
+ # materialization + the (policyName/class/path) dedup live there (§B Decision 1).
# Phase 3: Apply routes. The first positional is the node spawn primitive
# (threaded with this pipeline's parent scope-tree state) used to resolve a
# complex-route forward SOURCE with full fleet visibility (replaces the old
# isolated fxResolve fallback).
applyRoutes =
- spawnNode: ctx: scopeContexts: rootScopeId: scopeParent: scopedRoutes: acc:
- route.applyRoutes {
+ spawnNode: ctx: scopeContexts: rootScopeId: scopeParent: scopeIsolated: scopedRoutes: acc:
+ routeEdges.applyRoutes {
inherit
scopedRoutes
scopeContexts
scopeParent
- ctx
+ scopeIsolated
rootScopeId
spawnNode
;
@@ -168,156 +90,186 @@ let
};
# Phase 4: Apply entity instantiation.
- # Find the host scope ID for an instantiate spec.
+ # Resolve the entity scope an instantiate spec targets.
+ #
# register-instantiate records sourceScopeId = currentScope (the parent, e.g.
- # flake-system), but the entity's scope was created by resolve.to as a child.
- # Search child scopes of sourceScopeId matching the entity name.
- findHostScopeId =
- scopeParent: allScopeIds: spec:
+ # flake-system); the entity's OWN scope is a CHILD created by resolve.to during
+ # the same policy fire (push-scope, see modules/policies/flake.nix). push-scope
+ # records that child scope keyed by (parentScope, id_hash) in scopeByEntity.
+ # Since the resolve.to and the instantiate effect share the same parent scope
+ # and carry the same entity record (hence id_hash), the spec looks its scope up
+ # DIRECTLY — no name-infix reconstruction. The (parent, id_hash) key handles
+ # multi-system same-name entities: id_hash is context-free (kind+name), so two
+ # `ben` homes on different systems share an id_hash but have distinct parent
+ # (system=…) scopes, keeping their links distinct.
+ #
+ # T rule (single-child fallback, spec §3d): a spec WITHOUT a recorded entity
+ # scope (no id_hash, or no link — e.g. a non-entity collect-perSystem spec)
+ # targets its source scope's root. The caller falls through to sourceScopeId.
+ entityScopeFor =
+ scopeByEntity: spec:
let
sid = spec.sourceScopeId or null;
- entityName = spec.name or null;
- # Find child scopes of sourceScopeId (where resolve.to created the entity scope).
- children =
- if sid != null then
- builtins.filter (scopeId: scopeId != sid && (scopeParent.${scopeId} or null) == sid) allScopeIds
- else
- [ ];
- matchByName =
- if entityName != null then
- builtins.filter (scopeId: lib.hasInfix "=${entityName}" scopeId) children
- else
- [ ];
- # Among matches, prefer the shortest scope ID — the entity's own scope,
- # not a descendant (e.g., "host=lb-prod" over "host=lb-prod,user=deploy").
- bestMatch =
- if builtins.length matchByName <= 1 then
- matchByName
- else
- let
- sorted = builtins.sort (a: b: builtins.stringLength a < builtins.stringLength b) matchByName;
- in
- [ (builtins.head sorted) ];
+ idHash = spec.id_hash or null;
in
- if bestMatch != [ ] then
- builtins.head bestMatch
- # Single-child fallback only for entity specs (which carry mainModule).
- # Non-entity instantiate specs (e.g., collect-perSystem) should fall
- # through to sourceScopeId so they collect from the full subtree.
- else if spec ? mainModule && builtins.length children == 1 then
- builtins.head children
- else
- null;
+ if sid != null && idHash != null then scopeByEntity."${sid}\n${idHash}" or null else null;
- # Extract merged modules for a scope subtree (the scope + all descendants).
- # This produces the complete module set for a host: host-scope modules,
- # user-scope modules, and route-delivered modules — all in one list.
- extractSubtreeModules =
- perScope: scopeParent: rootScopeId: targetClass:
+ # The per-host subtree extraction that produced the complete module set for a
+ # host (host-scope + user-scope + route-delivered modules, key-deduped) now
+ # routes through the edge materializer's merge mode (edges/materialize.nix
+ # assembleSubtree) — the default-fold port. See mkInstantiateArgs.
+
+ # The per-host PROJECTION: from the instantiate-arg bundle + a spec, derive the
+ # host subtree's scope universe, isolation-aware contexts, the per-host phase
+ # fold (phase3 carries perScope + classImports), and the subtree provides/routes.
+ # Factored out so BOTH mkInstantiateArgs (module assembly, unchanged behavior)
+ # AND the unifiedEdges edge collector (mkInstantiateEdges projection inputs)
+ # consume the SAME projection — they can never diverge on the host subtree.
+ # Returns null when the spec has no resolvable host scope (T-rule single-child
+ # fallback / non-entity spec).
+ perHostProjection =
+ {
+ augmentedScopeContexts,
+ scopedClassImportsRaw,
+ scopedProvides,
+ scopedRoutes,
+ scopeParent,
+ scopeByEntity ? { },
+ scopeEntityClass ? (_: { }),
+ scopeIsolated ? { },
+ spawnNodeFn,
+ ctx,
+ }:
+ spec:
let
- allScopeIds = builtins.attrNames perScope;
- # Collect all descendant scope IDs by walking scopeParent.
- isInSubtree =
- sid:
- sid == rootScopeId
- || (
+ allScopeIds = builtins.attrNames augmentedScopeContexts;
+ hostClass = spec.class or "nixos";
+ rawHostScopeId = entityScopeFor scopeByEntity spec;
+ hostScopeId = if rawHostScopeId != null then rawHostScopeId else spec.sourceScopeId;
+ in
+ if hostScopeId == null then
+ null
+ else
+ let
+ # Isolation-BLIND collect: the per-host re-walk collects
+ # sub-phases over the blind set, then extractSubtreeModules extracts
+ # over the isolation-AWARE set below. Pass `isolated = {}` explicitly
+ # — defaulting it would collapse this deliberate blind/aware split.
+ subtreeScopeIds = subtreeScopes {
+ inherit scopeParent allScopeIds;
+ isolated = { };
+ root = hostScopeId;
+ };
+ subtreeSet = lib.genAttrs subtreeScopeIds (_: true);
+ isInSubtree = sid: subtreeSet ? ${sid};
+ isAncestor =
+ sid:
let
- parent = scopeParent.${sid} or null;
+ parent = scopeParent.${hostScopeId} or null;
in
- parent != null && parent != sid && isInSubtree parent
+ sid == parent || (parent != null && parent != hostScopeId && isAncestorOf scopeParent sid parent);
+ isRelevant = sid: isInSubtree sid || isAncestor sid;
+ relevantScopeIds = builtins.filter isRelevant allScopeIds;
+ scopeEntityClassMap = scopeEntityClass null;
+ subtreeContexts = lib.genAttrs subtreeScopeIds (
+ sid:
+ let
+ base = augmentedScopeContexts.${sid};
+ entityCls = scopeEntityClassMap.${sid} or null;
+ in
+ if !(base ? class) && entityCls != null then
+ base // { class = entityCls; }
+ else if !(base ? class) then
+ base // { class = hostClass; }
+ else
+ base
);
- subtreeScopes = builtins.filter isInSubtree allScopeIds;
- # Collect modules from all subtree scopes, deduplicating by key.
- # Same aspect included at multiple scope levels (host default + user default)
- # produces identical static modules; first occurrence wins.
- # Named modules carry `key`; anon modules carry `_file` from setDefaultModuleLocation.
- raw = lib.concatMap (sid: perScope.${sid}.${targetClass} or [ ]) subtreeScopes;
- deduped =
- let
- go =
- seen: mods:
- if mods == [ ] then
- [ ]
- else
- let
- m = builtins.head mods;
- rest = builtins.tail mods;
- k = m.key or null;
- in
- if k != null && seen ? ${k} then
- go seen rest
- else
- [ m ] ++ go (if k != null then seen // { ${k} = true; } else seen) rest;
- in
- go { } raw;
- in
- if deduped == [ ] then null else deduped;
+ subtreeClassImports = lib.genAttrs subtreeScopeIds (sid: scopedClassImportsRaw.${sid} or { });
+ subtreeProvides = lib.filterAttrs (sid: _: isRelevant sid) scopedProvides;
+ subtreeRoutes = lib.filterAttrs (sid: _: isRelevant sid) scopedRoutes;
+ relevantContexts = lib.genAttrs relevantScopeIds (sid: augmentedScopeContexts.${sid});
+ subtreePhase1 = wrapPerScope ctx subtreeContexts subtreeClassImports;
+ in
+ {
+ inherit
+ hostScopeId
+ hostClass
+ subtreeScopeIds
+ subtreeContexts
+ subtreeProvides
+ subtreeRoutes
+ relevantContexts
+ ;
+ # The materializeUnified SEED (phase-1 wrap). Both consumers
+ # (mkInstantiateArgs module assembly + perHostEdgesFor edge collection)
+ # fold it through materializeUnified — module delivery AND the edge
+ # collector's content source now flow through the SAME engine, so there is
+ # no separate phase2∘phase3 fold to diverge from.
+ seed = subtreePhase1;
+ };
# Build instantiateArgs for a spec without calling spec.instantiate.
# Factored out so both applyInstantiates and hostConfigs can reuse it.
mkInstantiateArgs =
- {
+ argBundle@{
augmentedScopeContexts,
scopedClassImportsRaw,
scopedProvides,
scopedRoutes,
scopeParent,
+ scopeByEntity ? { },
scopeEntityClass ? (_: { }),
+ scopeEntityKind ? { },
+ scopeIsolated ? { },
spawnNodeFn,
ctx,
}:
spec:
let
- allScopeIds = builtins.attrNames augmentedScopeContexts;
- hostClass = spec.class or "nixos";
- rawHostScopeId = findHostScopeId scopeParent allScopeIds spec;
- hostScopeId = if rawHostScopeId != null then rawHostScopeId else spec.sourceScopeId;
+ # perHostProjection takes only the projection inputs; scopeEntityKind is a
+ # mkInstantiateArgs-LOCAL concern (Π naming for materializeUnified), so it is
+ # stripped from the bundle passed through.
+ projBundle = builtins.removeAttrs argBundle [ "scopeEntityKind" ];
+ proj = perHostProjection projBundle spec;
preWalkedModules =
- if hostScopeId != null then
+ if proj != null then
let
- isInSubtree =
- sid:
- sid == hostScopeId
- || (
- let
- parent = scopeParent.${sid} or null;
- in
- parent != null && parent != sid && isInSubtree parent
- );
- isAncestor =
- sid:
- let
- parent = scopeParent.${hostScopeId} or null;
- in
- sid == parent || (parent != null && parent != hostScopeId && isAncestorOf scopeParent sid parent);
- isRelevant = sid: isInSubtree sid || isAncestor sid;
- subtreeScopeIds = builtins.filter isInSubtree allScopeIds;
- relevantScopeIds = builtins.filter isRelevant allScopeIds;
- scopeEntityClassMap = scopeEntityClass null;
- subtreeContexts = lib.genAttrs subtreeScopeIds (
- sid:
- let
- base = augmentedScopeContexts.${sid};
- entityCls = scopeEntityClassMap.${sid} or null;
- in
- if !(base ? class) && entityCls != null then
- base // { class = entityCls; }
- else if !(base ? class) then
- base // { class = hostClass; }
- else
- base
- );
- subtreeClassImports = lib.genAttrs subtreeScopeIds (sid: scopedClassImportsRaw.${sid} or { });
- subtreeProvides = lib.filterAttrs (sid: _: isRelevant sid) scopedProvides;
- subtreeRoutes = lib.filterAttrs (sid: _: isRelevant sid) scopedRoutes;
- relevantContexts = lib.genAttrs relevantScopeIds (sid: augmentedScopeContexts.${sid});
- subtreePhase1 = wrapPerScope ctx subtreeContexts subtreeClassImports;
- subtreePhase2 = applyProvides ctx relevantContexts subtreeProvides subtreePhase1;
- subtreePhase3 =
- applyRoutes spawnNodeFn ctx relevantContexts hostScopeId scopeParent subtreeRoutes
- subtreePhase2;
+ inherit (proj)
+ hostScopeId
+ hostClass
+ subtreeProvides
+ subtreeRoutes
+ relevantContexts
+ seed
+ ;
+ # Production delivery (Task 17/18): the per-host final extraction folds
+ # materializeUnified (doFinalMerge = true → it runs assembleSubtree at
+ # hostScopeId), replacing the phase2 (provides) ∘ phase3 (routes) ∘
+ # assembleSubtree sequence. The Π's scopeContexts MUST be relevantContexts
+ # (NOT subtreeContexts): materializeUnified's complex-forward path reads
+ # pi.scopeContexts for source resolution, whereas the old assembleSubtree
+ # merge ignored it.
+ pi =
+ (mkStaticPi {
+ rootScopeId = hostScopeId;
+ scopeContexts = relevantContexts;
+ inherit scopeParent scopeIsolated;
+ isolationMode = "aware";
+ })
+ // {
+ inherit scopeEntityKind;
+ };
+ materialized = materializeUnified {
+ inherit pi ctx;
+ seed = seed;
+ scopedProvides = subtreeProvides;
+ scopedRoutes = subtreeRoutes;
+ spawnNode = spawnNodeFn;
+ inherit (handlers) buildForwardAspect;
+ } { doFinalMerge = true; };
+ hostModules = materialized.${hostClass} or [ ];
in
- extractSubtreeModules subtreePhase3.perScope scopeParent hostScopeId hostClass
+ if hostModules == [ ] then null else hostModules
else
null;
modules =
@@ -359,7 +311,10 @@ let
scopedProvides,
scopedRoutes,
scopeParent,
+ scopeByEntity ? { },
scopeEntityClass ? (_: { }),
+ scopeEntityKind ? { },
+ scopeIsolated ? { },
spawnNodeFn,
ctx,
}:
@@ -372,7 +327,10 @@ let
scopedProvides
scopedRoutes
scopeParent
+ scopeByEntity
scopeEntityClass
+ scopeEntityKind
+ scopeIsolated
spawnNodeFn
ctx
;
@@ -380,78 +338,12 @@ let
allInstantiates = lib.concatLists (lib.attrValues scopedInstantiates);
- # Build spec descriptors: { path, system, spec } without calling instantiate.
- # concatMap is strict in the list but the instantiate thunk is deferred.
- specDescriptors = lib.concatMap (
- spec:
- let
- hasOutput = (spec.intoAttr or [ ]) != [ ];
- in
- if !hasOutput then
- [ ]
- else
- [
- {
- path = [ "flake" ] ++ spec.intoAttr;
- system = spec.system or null;
- inherit spec;
- }
- ]
- ) allInstantiates;
-
- # Disambiguate instantiate entries targeting the same output path from
- # different entities. When the same user name appears on multiple systems
- # (e.g. den.homes.x86_64-linux.ben + den.homes.aarch64-darwin.ben both
- # producing homeConfigurations.ben), lib.recursiveUpdate would deeply
- # merge the two independent module-system evaluations, corrupting both.
- # Fix: qualify each colliding entry's output name with its system so both
- # are accessible (e.g. homeConfigurations."ben@x86_64-linux").
- # Same-entity duplicates (e.g. fleet + direct policy) are left as-is
- # since they produce compatible modules.
- #
- # Only inspects path and system metadata — never touches spec.instantiate.
- disambiguated =
- let
- pathStr = builtins.concatStringsSep ".";
- grouped = builtins.foldl' (
- acc: entry:
- let
- key = pathStr entry.path;
- in
- acc // { ${key} = (acc.${key} or [ ]) ++ [ entry ]; }
- ) { } specDescriptors;
- resolve =
- _: entries:
- if builtins.length entries <= 1 then
- entries
- else
- let
- systems = map (e: e.system or null) entries;
- uniqueSystems = lib.unique systems;
- isMultiSystem = builtins.length uniqueSystems > 1;
- in
- if isMultiSystem then
- # Different systems: qualify each output name with @system.
- map (
- e:
- let
- basePath = lib.init e.path;
- baseName = lib.last e.path;
- in
- e // { path = basePath ++ [ "${baseName}@${e.system}" ]; }
- ) entries
- else
- # Same entity via multiple policy paths: deduplicate.
- let
- entry = lib.last entries;
- in
- lib.warnIf (builtins.length entries > 1)
- "den: multiple instantiate specs target ${builtins.concatStringsSep "." entry.path} on ${
- if entry.system != null then entry.system else "unknown"
- }; keeping last"
- [ entry ];
- in
- lib.concatLists (lib.mapAttrsToList resolve grouped);
+ # Flake-output T-arm edge construction (spec §2: T = a flake-output path).
+ # The descriptors + @system disambiguation are the T-arm-LOCAL rules, shared
+ # with the read-only oracle (edge-trace.nix) via edges/instantiate.nix so
+ # production and oracle agree on the @system rule (spec §3a). Both touch
+ # path + system metadata only — never spec.instantiate (laziness-safe).
+ disambiguated = instantiateEdges.disambiguate (instantiateEdges.specDescriptors allInstantiates);
# Build lazy output tree. Each leaf calls spec.instantiate on first access.
instantiateConfigs = map (
@@ -485,6 +377,14 @@ let
scopeParent = result.state.scopeParent null;
scopedProvides = result.state.scopedProvides null;
scopedRoutes = result.state.scopedRoutes null;
+ # Kind-level isolation marks {scopeId→true}; route collection and subtree
+ # extraction skip isolated descendants (the collection root is exempt).
+ scopeIsolated = (result.state.scopeIsolated or (_: { })) null;
+ # Spec→scope link recorded at scope creation (push-scope), keyed by
+ # (parentScope, id_hash). The instantiate spec's scope is resolved through
+ # it directly (no name-infix reconstruction); both instantiate call sites
+ # (phase4 + the B′ hostConfigs build) use the link.
+ scopeByEntity = (result.state.scopeByEntity or (_: { })) null;
# Scan raw pipe values for config-dependent thunks (functions taking
# { config, ... }). If none exist, hostConfigs stays null and
@@ -518,13 +418,12 @@ let
else
let
allInstantiates = lib.concatLists (lib.attrValues (result.state.scopedInstantiates null));
- allScopeIds = builtins.attrNames scopeContexts;
specsByHost = builtins.listToAttrs (
lib.concatMap (
spec:
let
hasOutput = (spec.intoAttr or [ ]) != [ ];
- hostScopeId = if hasOutput then findHostScopeId scopeParent allScopeIds spec else null;
+ hostScopeId = if hasOutput then entityScopeFor scopeByEntity spec else null;
in
if hostScopeId == null then
[ ]
@@ -538,14 +437,30 @@ let
) allInstantiates
);
mkArgs = mkInstantiateArgs {
- augmentedScopeContexts = scopeContexts;
+ # §A #8/#2/#7 fix (option b): B′ builds peer configs over the
+ # hostConfigs-NULL ASSEMBLED contexts (pipe values resolved), not
+ # raw scopeContexts. B′'s raw-context use was cycle-forced
+ # (assemblePipes-with-hostConfigs needs hostConfigs);
+ # but the hostConfigs-NULL pass is cycle-free and resolves every
+ # pipeline-parametric pipe value. A pipe-CONSUMING peer aspect (one
+ # that reads a quirk value via context, e.g. `{ feat, ... }`) thus
+ # gets its pipe value injected — pre-fix the raw context left `feat`
+ # unbound and the peer config threw `feat missing` instead of
+ # matching its real instantiate output (variant B). Witnessed by
+ # deadbugs/bprime-basedrain-crosshost.
+ augmentedScopeContexts = augmentedScopeContextsNoCfg;
+ # …and over the matching DRAINED import map (deferred includes whose
+ # pipeline-parametric pipe-args are now resolved). Pre-fix this was
+ # raw scopedClassImportsRaw — the §A #2/#7 baseDrain carry-over.
+ scopedClassImportsRaw = drainedForHostConfigs;
inherit
- scopedClassImportsRaw
scopedProvides
scopedRoutes
scopeParent
+ scopeByEntity
;
scopeEntityClass = result.state.scopeEntityClass or (_: { });
+ inherit scopeIsolated scopeEntityKind;
spawnNodeFn = spawnNode;
inherit ctx;
};
@@ -563,6 +478,42 @@ let
inherit scopeParent;
};
+ # §A #8/#2/#7 B′ raw-context ACCIDENT fix (option b: augmented-context build).
+ #
+ # hostConfigs (re-entry B′) builds each peer host's full config for cross-
+ # host config-dependent pipe-thunk resolution. Pre-fix it built those from
+ # RAW (undrained) imports, so a peer whose config depends on a DEFERRED
+ # include (one that deferred on a pipe-name / enrichment arg) diverged from
+ # the peer's real instantiate output (variant B) — throwing `feat missing`
+ # instead of resolving. Witnessed by deadbugs/bprime-basedrain-crosshost.
+ #
+ # The fix: B′ consumes a DRAINED import map. The cycle that forced raw —
+ # baseDrain → augmentedScopeContexts → hostConfigs → (B′ would read the
+ # drained map) — is broken by draining over a hostConfigs-NULL augmented
+ # contexts here. assemblePipes with hostConfigs=null resolves every
+ # PIPELINE-PARAMETRIC pipe value (host/user-derived, no config dependency)
+ # and leaves config-dependent pipe thunks deferred (__configThunk), so:
+ # - pipe-arg-deferred includes whose pipe is pipeline-parametric (the
+ # common case, incl. the witness `feat`) DRAIN correctly for B′;
+ # - the rarer deferred-include-on-a-CONFIG-dependent-pipe sub-case stays
+ # deferred under B′ (its pipe value genuinely needs a peer's config,
+ # which is the cross-host thunk B′ is mid-resolving — a real recursion
+ # no pass can break; it remains a documented limitation).
+ # No cycle: augmentedScopeContextsNoCfg / drainedForHostConfigs / spawnNode /
+ # parentState all read RAW scopeContexts + scopedClassImports only, never
+ # hostConfigs or augmentedScopeContexts.
+ augmentedScopeContextsNoCfg = assemblePipes {
+ inherit scopeContexts scopeEntityKind;
+ hostConfigs = null;
+ scopedClassImports = scopedClassImportsRaw;
+ scopedPipeEffects = result.state.scopedPipeEffects null;
+ inherit scopeParent;
+ };
+ # B′ peer-config drain: only its class-imports map is consumed (the cross-
+ # host config build); its spawn edges are NOT collected — B′'s delivery is
+ # covered by the per-host mkInstantiateEdges in the unifiedEdges union.
+ drainedForHostConfigs = (mkDrained augmentedScopeContextsNoCfg).classImports;
+
# Parent-state bundle for node spawns. Uses the RAW scopeContexts and
# scopedClassImports (not the augmented/drained maps): the spawned node
# re-derives pipes via its OWN assemblePipes over the merged state, so
@@ -574,6 +525,7 @@ let
inherit
scopeContexts
scopeParent
+ scopeIsolated
ctx
scopeEntityKind
;
@@ -589,21 +541,28 @@ let
# runtime when a resolved aspect carries a complex non-collected forward,
# and a finite forward nesting terminates.
spawnNode = mkSpawnNode {
- inherit wrapPerScope applyProvides applyRoutes;
+ inherit wrapPerScope;
inherit (den.lib.aspects) normalizeRoot;
inherit (den.lib.aspects.fx.aspect) ctxFromHandlers;
selfRef = spawnNode;
} mkPipeline parentState;
- # Post-assembly drain: resolve deferred includes.
- # Two categories of deferred includes are drained here:
+ # Post-assembly drain: resolve deferred includes. Parameterized by the
+ # augmented contexts the deferred-include resolution reads, so the SAME
+ # drain logic produces two maps with different cycle constraints:
+ # - drainedClassImportsRaw — over the hostConfigs-augmented contexts
+ # (the host's OWN phase1–4 path; hostConfigs already resolved by then).
+ # - drainedForHostConfigs — over the hostConfigs-NULL augmented
+ # contexts (the cross-host B′ peer-config build, §A #2/#7 ACCIDENT fix).
+ # Two categories of deferred includes are drained:
# 1. Pipe-arg deferred: required args are pipe names, now available
# from assemblePipes.
# 2. Enrichment-deferred: required args (e.g., isNixos) were provided
# by a parent scope's policy enrichment but weren't available when
# the child scope was walked. The drain inherits parent scope context
# to resolve these.
- drainedClassImportsRaw =
+ mkDrained =
+ augmentedContexts:
let
allDeferred = (result.state.scopedDeferredIncludes or (_: { })) null;
inherit (den.lib.aspects.fx.keyClassification) classifyKeys;
@@ -615,7 +574,7 @@ let
enrichedScopeCtx =
scopeId:
let
- ownCtx = augmentedScopeContexts.${scopeId} or { };
+ ownCtx = augmentedContexts.${scopeId} or { };
inherit' =
sid:
let
@@ -625,7 +584,7 @@ let
{ }
else
let
- parentCtx = augmentedScopeContexts.${pid} or { };
+ parentCtx = augmentedContexts.${pid} or { };
grandparentCtx = inherit' pid;
in
grandparentCtx // parentCtx;
@@ -683,75 +642,426 @@ let
) accImports newEntries
) scopedClassImportsRaw (builtins.attrNames allDeferred);
- # Materialize deferred node spawn markers (policy.spawn) over
- # the parent scope-tree state. Each marker lives at a user scope; the
- # home class is re-walked from that user's host aspect with `user`
- # bound, threaded with host + sibling state so fleet-collected pipes
- # resolve to data and collectAll sees every peer. The result is folded
- # into the user scope's class buckets so BOTH phase1 and the phase4
- # per-host re-walk (over drainedClassImportsRaw) deliver it.
+ # Materialize deferred node spawn markers (policy.spawn) over the
+ # parent scope-tree state, kind-generically. Each marker lives at some
+ # spawned-FOR scope (the OWN entity, of kind `ownKind`); the spawned
+ # class is re-walked from the projected ASPECT carried on the PARENT
+ # scope's own entity record, with the own entity bound under its kind.
+ # The walk is threaded with parent + sibling state so fleet-collected
+ # pipes resolve to data and collectAll sees every peer. The result is
+ # folded into the own scope's class buckets so BOTH phase1 and the
+ # phase4 per-host re-walk (over drainedClassImportsRaw) deliver it.
+ #
+ # The aspect is read from the PARENT scope's own ctx (record under
+ # parentKind) — the same record the old code reached via the child
+ # scope's ancestor-bound `host`. Default classes fall back to the own
+ # record's `classes` (e.g. user type defaults `["homeManager"]`); in
+ # practice batteries pass `spec.classes` explicitly so this is unused.
allHomeNodes = (result.state.scopedSpawns or (_: { })) null;
in
- lib.foldl' (
- acc: scopeId:
- let
- sctx = scopeContexts.${scopeId} or { };
- host = sctx.host or null;
- user = sctx.user or null;
- from = scopeParent.${scopeId} or null;
- specs = allHomeNodes.${scopeId};
- defaultClasses = user.classes or [ "homeManager" ];
- classes = lib.unique (
- lib.concatMap (s: if s.classes != null then s.classes else defaultClasses) specs
- );
- in
- if host == null || from == null then
- acc
- else
- acc
- // {
- ${scopeId} =
- (acc.${scopeId} or { })
- // lib.genAttrs classes (
+ # Accumulate BOTH the class-imports map AND the spawn nodes' SURFACED edge
+ # sets. Each spawnNode {…} returns { imports; edges; }: `.imports` folds
+ # into the class buckets as before; `.edges` is the spawn's real delivered
+ # edge set (its default fold + provides + re-applied routes), collected so
+ # the host-own invocation can feed unifiedEdges (the oracle's rewalk arm
+ # undercounts these). The B′ invocation discards spawnEdges (its delivery
+ # is covered by the per-host mkInstantiateEdges, see call sites below).
+ lib.foldl'
+ (
+ acc: scopeId:
+ let
+ sctx = scopeContexts.${scopeId} or { };
+ ownKind = scopeEntityKind.${scopeId} or null;
+ ownRecord = if ownKind == null then null else sctx.${ownKind} or null;
+ from = scopeParent.${scopeId} or null;
+ parentKind = if from == null then null else scopeEntityKind.${from} or null;
+ parentRecord =
+ if parentKind == null then null else (scopeContexts.${from} or { }).${parentKind} or null;
+ specs = allHomeNodes.${scopeId};
+ defaultClasses = if ownRecord == null then [ ] else ownRecord.classes or [ ];
+ classes = lib.unique (
+ lib.concatMap (s: if s.classes != null then s.classes else defaultClasses) specs
+ );
+ in
+ if parentRecord == null || ownRecord == null then
+ acc
+ else
+ let
+ # Materialize each class once; capture the FULL spawn return so both
+ # `.imports` (class fold) and `.edges` (surfaced set) are available.
+ spawned = lib.genAttrs classes (
cls:
- ((acc.${scopeId} or { }).${cls} or [ ])
- ++ (spawnNode {
+ spawnNode {
inherit from;
class = cls;
- aspect = host.aspect;
+ aspect = parentRecord.aspect;
bindings = {
- inherit user;
+ ${ownKind} = ownRecord;
};
- }).imports
+ }
);
- }
- ) baseDrain (builtins.attrNames allHomeNodes);
+ in
+ {
+ classImports = acc.classImports // {
+ ${scopeId} =
+ (acc.classImports.${scopeId} or { })
+ // lib.genAttrs classes (
+ cls: ((acc.classImports.${scopeId} or { }).${cls} or [ ]) ++ spawned.${cls}.imports
+ );
+ };
+ spawnEdges = acc.spawnEdges ++ lib.concatMap (cls: spawned.${cls}.edges) classes;
+ }
+ )
+ {
+ classImports = baseDrain;
+ spawnEdges = [ ];
+ }
+ (builtins.attrNames allHomeNodes);
+
+ # The host's OWN phase1–4 drain, over the hostConfigs-augmented contexts.
+ # Surfaces drained.classImports for phases + drained.spawnEdges for unifiedEdges.
+ drained = mkDrained augmentedScopeContexts;
+ drainedClassImportsRaw = drained.classImports;
phase1 = wrapPerScope ctx augmentedScopeContexts drainedClassImportsRaw;
- phase2 = applyProvides ctx augmentedScopeContexts scopedProvides phase1;
- phase3 =
- applyRoutes spawnNode ctx augmentedScopeContexts result.state.rootScopeId scopeParent scopedRoutes
- phase2;
+ # Production delivery (Task 17): one ordered-dispatch fold over the unified
+ # provides+routes edge set, replacing the phase2 (provides) ∘ phase3 (routes)
+ # sequence. doFinalMerge = false → returns the raw { classImports; perScope }
+ # accumulator (the non-flake output reads the flat classImports, as before).
+ pi =
+ (mkStaticPi {
+ rootScopeId = result.state.rootScopeId;
+ scopeContexts = augmentedScopeContexts;
+ inherit scopeParent scopeIsolated;
+ isolationMode = "aware";
+ })
+ // {
+ inherit scopeEntityKind;
+ };
+ materialized =
+ materializeUnified
+ {
+ inherit
+ pi
+ ctx
+ scopedProvides
+ scopedRoutes
+ spawnNode
+ ;
+ seed = phase1;
+ inherit (handlers) buildForwardAspect;
+ }
+ {
+ doFinalMerge = false;
+ # Task 18.2: CAPTURE the top-level provides+routes edges the production
+ # fold dispatched (materialized.edges), so edgeTrace renders the captured
+ # set rather than re-deriving it via extractTopLevelEdges' provides/route
+ # arms. The return is acc // { edges; } — classImports reads stay byte-
+ # unchanged.
+ exposeEdges = true;
+ };
phase4 = applyInstantiates {
scopedInstantiates = result.state.scopedInstantiates null;
scopeEntityClass = result.state.scopeEntityClass or (_: { });
+ inherit scopeIsolated scopeEntityKind;
inherit
augmentedScopeContexts
scopedProvides
scopedRoutes
scopeParent
+ scopeByEntity
ctx
;
# Pass drained class imports so pipe-arg deferred aspects are
# included in per-host subtree assembly.
scopedClassImportsRaw = drainedClassImportsRaw;
spawnNodeFn = spawnNode;
- } phase3.classImports;
+ } materialized.classImports;
+
+ # ===== unifiedEdges component construction =========================
+ # The TOP-LEVEL mechanism edge components (default fold + provides + routes +
+ # instantiate), built by the SAME constructors the read-only oracle uses, over
+ # the SAME end-state — but WITHOUT the oracle's `spawnEdges` rewalk arm (which
+ # undercounts each spawn as one edge). The real spawn edges come from
+ # drained.spawnEdges (surfaced by the drain-fold), and the per-host / B′
+ # instantiate edges come from mkInstantiateEdges below.
+ topLevelEdgeParts = extractTopLevelEdges {
+ inherit
+ scopeContexts
+ scopeParent
+ scopeIsolated
+ scopeEntityKind
+ scopedProvides
+ scopedRoutes
+ ;
+ scopedClassImports = scopedClassImportsRaw;
+ scopedSpawns = (result.state.scopedSpawns or (_: { })) null;
+ scopedInstantiates = (result.state.scopedInstantiates or (_: { })) null;
+ rootScopeId = result.state.rootScopeId;
+ };
+
+ # The per-host / B′ instantiate edge projections, built from mkInstantiateEdges
+ # over the SAME perHostProjection the module assembly uses. `name` normalizes
+ # entity scopes to ":" (matching the oracle/unified set).
+ edgeName = scopeName { inherit scopeEntityKind scopeContexts; };
+ allInstantiateSpecs = lib.concatLists (lib.attrValues (result.state.scopedInstantiates null));
+
+ # Build the per-host edge set for a spec under the given projection-arg
+ # bundle. Returns [] when the spec has no resolvable host scope.
+ perHostEdgesFor =
+ argBundle: spec:
+ let
+ proj = perHostProjection argBundle spec;
+ in
+ if proj == null then
+ [ ]
+ else
+ let
+ # The edge collector's content source (the post-provides+routes per-scope
+ # presence map) comes from the SAME materializeUnified the module assembly
+ # folds — exposeAcc surfaces its accumulator so there is no separate
+ # phase2∘phase3 fold (which the old proj.phase3.perScope read).
+ pi =
+ (mkStaticPi {
+ rootScopeId = proj.hostScopeId;
+ scopeContexts = proj.relevantContexts;
+ inherit scopeParent scopeIsolated;
+ isolationMode = "aware";
+ })
+ // {
+ inherit scopeEntityKind;
+ };
+ materialized =
+ materializeUnified
+ {
+ inherit pi ctx;
+ seed = proj.seed;
+ scopedProvides = proj.subtreeProvides;
+ scopedRoutes = proj.subtreeRoutes;
+ spawnNode = argBundle.spawnNodeFn;
+ inherit (handlers) buildForwardAspect;
+ }
+ {
+ doFinalMerge = true;
+ exposeAcc = true;
+ # Task 18.2: CAPTURE the provides+routes edges the per-host fold
+ # dispatched, so mkInstantiateEdges renders the captured set
+ # rather than re-deriving it.
+ exposeEdges = true;
+ };
+ in
+ mkInstantiateEdges {
+ name = edgeName;
+ inherit scopeParent scopeIsolated;
+ inherit (proj)
+ hostScopeId
+ subtreeScopeIds
+ ;
+ perScope = materialized.acc.perScope;
+ capturedEdges = materialized.edges;
+ };
+
+ # Host-own per-host edges: the projection-arg bundle that phase4 uses (the
+ # hostConfigs-augmented contexts + drained class imports).
+ perHostArgBundle = {
+ inherit
+ augmentedScopeContexts
+ scopeParent
+ scopeByEntity
+ scopeIsolated
+ ctx
+ ;
+ scopedClassImportsRaw = drainedClassImportsRaw;
+ inherit scopedProvides scopedRoutes;
+ scopeEntityClass = result.state.scopeEntityClass or (_: { });
+ spawnNodeFn = spawnNode;
+ };
+ perHostEdges = lib.concatMap (perHostEdgesFor perHostArgBundle) allInstantiateSpecs;
+
+ # B′ per-host edges: the cross-host peer-config projection bundle (the
+ # hostConfigs-NULL augmented contexts + the matching drained map), mirroring
+ # the B′ mkInstantiateArgs bundle. Only meaningful when config-dependent pipe
+ # thunks forced the B′ pass; otherwise the projection is over the same scopes
+ # the host-own pass covers (the union dedups by sort key, so overlap is inert).
+ bprimeArgBundle = {
+ augmentedScopeContexts = augmentedScopeContextsNoCfg;
+ scopedClassImportsRaw = drainedForHostConfigs;
+ inherit
+ scopedProvides
+ scopedRoutes
+ scopeParent
+ scopeByEntity
+ scopeIsolated
+ ctx
+ ;
+ scopeEntityClass = result.state.scopeEntityClass or (_: { });
+ spawnNodeFn = spawnNode;
+ };
+ bprimeEdges = lib.optionals (hostConfigs != null) (
+ lib.concatMap (perHostEdgesFor bprimeArgBundle) allInstantiateSpecs
+ );
+
+ # The PRODUCTION delivery-edge object (Task 18.2). The fold-ordered
+ # provides+routes portion is the CAPTURE from the production materializeUnified
+ # folds (top-level `materialized.edges`, the surfaced spawn `.edges` in
+ # drained.spawnEdges, the per-host `.edges` in perHostEdges/bprimeEdges) — NOT
+ # a re-derivation, so it is drift-proof. The default-fold (merge) +
+ # instantiate (flake-output) edges stay constructor-built: they are the SAME
+ # deterministic structural edges production invokes via assembleSubtree /
+ # applyInstantiates (no drift surface). This corrects the legacy oracle's
+ # spawn rewalk UNDERCOUNT. A lazy thunk — forced only by inspection / the
+ # delivery-edges + fx-unified-edges suites, never by normal resolve consumers.
+ productionEdgeTrace = sortEdges (
+ materialized.edges
+ ++ topLevelEdgeParts.defaultFold
+ ++ topLevelEdgeParts.instantiateEdgeList
+ ++ drained.spawnEdges
+ ++ perHostEdges
+ ++ bprimeEdges
+ );
in
{
imports = phase4.${class} or [ ];
- # Surfaced from the SAME result.state — Task 1 thunked this onto state.
+ # Surfaced from the SAME result.state — this is thunked onto state.
pathSetByScope = result.state.pathSetByScope null;
+ # Per-scope ctx + entity-kind, so the entity surface can re-key the path
+ # set from scope-string to entity identity (id_hash) for projected
+ # hasAspect (see entities/_types.nix:pathSetByScopeOption).
+ inherit scopeContexts scopeEntityKind;
+ # The production edge object (see productionEdgeTrace above).
+ edgeTrace = productionEdgeTrace;
+ # One representation: unifiedEdges is an alias for the production edgeTrace.
+ unifiedEdges = productionEdgeTrace;
+ # The LEGACY end-state re-derivation (edge-trace.nix), WITH its `spawnEdges`
+ # rewalk arm (the spawn undercount). Kept as a distinct field so the
+ # differential suites can diff the production object against it. Nix attrs
+ # are lazy, so this is a thunk — forced only by the differential suites /
+ # debug inspection, never by normal resolve consumers.
+ legacyEdgeTrace = extractEdgeTrace {
+ inherit
+ scopeContexts
+ scopeParent
+ scopeIsolated
+ scopeEntityKind
+ scopedProvides
+ scopedRoutes
+ ;
+ scopedClassImports = scopedClassImportsRaw;
+ scopedSpawns = (result.state.scopedSpawns or (_: { })) null;
+ scopedInstantiates = (result.state.scopedInstantiates or (_: { })) null;
+ rootScopeId = result.state.rootScopeId;
+ };
+
+ # The Task-17 equivalence surface: BOTH the current phase2∘phase3 result AND
+ # the materializeUnified result over the SAME live seed (phase1) + the SAME
+ # provides/routes/spawn inputs the production phase folds consume. A lazy
+ # thunk (like edgeTrace / unifiedEdges) — forced only by the
+ # fx-materialize-unified suite, never by normal resolve consumers. This is the
+ # byte-equivalence proof for the ordered-dispatch engine: the suite deep-
+ # compares `.phaseFold` to `.unified` per topology. Not consumed by production.
+ materializeEquiv =
+ let
+ piTop = pi;
+ unifiedInputs = {
+ inherit pi;
+ seed = phase1;
+ inherit
+ ctx
+ scopedProvides
+ scopedRoutes
+ spawnNode
+ ;
+ inherit (handlers) buildForwardAspect;
+ };
+ # The OLD production path (phase2 provides ∘ phase3 routes), recomputed
+ # locally over the SAME live seed. Production now folds materializeUnified
+ # directly (above), so this independent recomputation is the equivalence
+ # ORACLE the fx-materialize-unified suite deep-compares against `.unified`.
+ oraclePhase2 = applyProvidesEdges ctx scopedProvides phase1;
+ oraclePhase3 =
+ applyRoutes spawnNode ctx augmentedScopeContexts result.state.rootScopeId scopeParent scopeIsolated
+ scopedRoutes
+ oraclePhase2;
+ in
+ let
+ # The production dispatch order: ALL provides (dedup order) THEN ALL kept
+ # routes (orderedKeptRoutes order) — the phase2∘phase3 sequence.
+ provideId =
+ spec:
+ "provide:${spec.__providePolicyName or ""}/${spec.class}/${
+ lib.concatStringsSep "/" (spec.path or [ ])
+ }";
+ routeId =
+ spec:
+ "route:${spec.fromClass or "?"}>${spec.intoClass or "?"}@${spec.sourceScopeId or "?"}/${
+ lib.concatStringsSep "/" (spec.path or [ ])
+ }${lib.optionalString (spec.__complexForward or false) "#complex"}";
+ dispatchId = d: if d.kind == "provide" then provideId d.spec else routeId d.spec;
+ orderedProvideSpecs = dedupProvides (lib.concatLists (lib.attrValues scopedProvides));
+ orderedRouteSpecs = routeEdges.orderedKeptRoutes result.state.rootScopeId (
+ lib.concatLists (lib.attrValues scopedRoutes)
+ );
+ # Production dispatch: all provides (dedup order) then all kept routes.
+ phaseFoldDispatch = (map provideId orderedProvideSpecs) ++ (map routeId orderedRouteSpecs);
+ # The unified engine's dispatch order via the SAME identity functions.
+ unifiedDispatch = map dispatchId (materializeUnified unifiedInputs { exposeDispatch = true; });
+
+ # Task-18 capture: the SAME no-merge call with exposeEdges = true. The
+ # accumulator is byte-identical to `unified` (the `// { edges; }` only
+ # adds the capture key), and `.edges` carries the folded trace edges the
+ # fold dispatched. The suite proves capture fidelity by comparing these
+ # to the constructor-built provides+route edges over the same inputs.
+ unifiedWithEdges = materializeUnified unifiedInputs {
+ doFinalMerge = false;
+ exposeEdges = true;
+ };
+ # The constructor-built oracle: provides trace edges (dedup order) ++
+ # route trace edges (kept+ordered), the SAME edges materializeUnified
+ # builds internally before its toposort. Same SET as `.edges`, so a
+ # sort-key comparison proves capture fidelity.
+ piRoot = result.state.rootScopeId;
+ edgeName = scopeName {
+ scopeEntityKind = pi.scopeEntityKind or { };
+ inherit (pi) scopeContexts;
+ };
+ oracleEdges =
+ providesEdges {
+ name = edgeName;
+ inherit scopedProvides;
+ }
+ ++ routeEdges.routeEdges {
+ name = edgeName;
+ inherit (pi) scopeParent;
+ rootScopeId = piRoot;
+ rawRoutes = routeEdges.orderedKeptRoutes piRoot (lib.concatLists (lib.attrValues scopedRoutes));
+ };
+ in
+ {
+ inherit phaseFoldDispatch unifiedDispatch;
+ # phase2 ∘ phase3 over the live seed (the production order: all provides
+ # then all routes) — recomputed by the local oracle, since production now
+ # folds materializeUnified directly.
+ phaseFold = oraclePhase3;
+ # materializeUnified over the SAME seed, doFinalMerge = false (returns the
+ # raw accumulator, byte-comparable to phaseFold). This is the SAME call
+ # production uses (`materialized`).
+ unified = materializeUnified unifiedInputs { doFinalMerge = false; };
+ # Task-18 edge capture surface: the folded edges (with their accumulator)
+ # and the constructor-built oracle, for the fx-materialize-unified proof.
+ inherit unifiedWithEdges oracleEdges;
+ # The doFinalMerge = true variant, comparable to assembleSubtree over the
+ # phaseFold result (the final-extraction merge step, unchanged).
+ unifiedMerged = materializeUnified unifiedInputs { doFinalMerge = true; };
+ phaseFoldMerged = assembleSubtree {
+ root = result.state.rootScopeId;
+ pi = piTop // {
+ perScope = oraclePhase3.perScope;
+ classImports = oraclePhase3.classImports;
+ provides = scopedProvides;
+ routes = scopedRoutes;
+ };
+ };
+ };
};
# Back-compatible projection: imports only. Protects deferredModule consumers
@@ -777,6 +1087,7 @@ let
scopeContexts = result.state.scopeContexts null;
scopedClassImportsRaw = result.state.scopedClassImports null;
scopeParent = result.state.scopeParent null;
+ scopeIsolated = (result.state.scopeIsolated or (_: { })) null;
augmentedScopeContexts = assemblePipes {
inherit scopeContexts;
@@ -790,7 +1101,12 @@ let
# threaded spawned node rather than an isolated pipeline. No drain/phase4
# here, so this only matters for nested node resolution.
parentState = {
- inherit scopeContexts scopeParent ctx;
+ inherit
+ scopeContexts
+ scopeParent
+ scopeIsolated
+ ctx
+ ;
scopeEntityKind = (result.state.scopeEntityKind or (_: { })) null;
scopedClassImports = scopedClassImportsRaw;
scopedPipeEffects = result.state.scopedPipeEffects null;
@@ -800,21 +1116,38 @@ let
# so a nested complex forward inside a spawned node resolves its source via
# the same fleet-visible spawn (matching resolveSourceFallback's contract).
spawnNode = mkSpawnNode {
- inherit wrapPerScope applyProvides applyRoutes;
+ inherit wrapPerScope;
inherit (den.lib.aspects) normalizeRoot;
inherit (den.lib.aspects.fx.aspect) ctxFromHandlers;
selfRef = spawnNode;
} mkPipeline parentState;
phase1 = wrapPerScope ctx augmentedScopeContexts scopedClassImportsRaw;
- phase2 = applyProvides ctx augmentedScopeContexts (result.state.scopedProvides null) phase1;
- phase3 =
- applyRoutes spawnNode ctx augmentedScopeContexts result.state.rootScopeId scopeParent
- (result.state.scopedRoutes null)
- phase2;
+ # Production delivery (Task 17): one ordered-dispatch fold over the unified
+ # provides+routes edge set, replacing the phase2 (provides) ∘ phase3 (routes)
+ # sequence. doFinalMerge = false → returns the raw { classImports; perScope }
+ # accumulator; this non-instantiating path reads the flat classImports, as
+ # before (no drain / phase4 / assembleSubtree here).
+ pi =
+ (mkStaticPi {
+ rootScopeId = result.state.rootScopeId;
+ scopeContexts = augmentedScopeContexts;
+ inherit scopeParent scopeIsolated;
+ isolationMode = "aware";
+ })
+ // {
+ scopeEntityKind = (result.state.scopeEntityKind or (_: { })) null;
+ };
+ materialized = materializeUnified {
+ inherit pi ctx spawnNode;
+ seed = phase1;
+ scopedProvides = result.state.scopedProvides null;
+ scopedRoutes = result.state.scopedRoutes null;
+ inherit (handlers) buildForwardAspect;
+ } { doFinalMerge = false; };
in
{
- imports = phase3.classImports.${class} or [ ];
+ imports = materialized.classImports.${class} or [ ];
};
in
{
diff --git a/nix/lib/aspects/fx/route/apply.nix b/nix/lib/aspects/fx/route/apply.nix
deleted file mode 100644
index c5dac8818..000000000
--- a/nix/lib/aspects/fx/route/apply.nix
+++ /dev/null
@@ -1,359 +0,0 @@
-# Apply registered routes — fold over deduped route specs,
-# dispatching complex (forward-derived) vs simple (path nesting).
-{
- lib,
- den,
- wrapRouteModules,
- collectClassMods,
-}:
-let
- # Root-scope `fromClass` content a child-scope forward may pull in. When
- # `fromClass` is a class some entity in the chain owns, root content under it
- # is that entity's own declaration, not aggregation fodder — restrict to
- # shared `den.default` (host class content reaches users opt-in via
- # host-aspects). A custom forward-only class (e.g. `atuin`) only exists to
- # feed forwards, so its root content is a legitimate source; keep it in full.
- filterRootModules =
- scopeContexts: spec: rootModules: isDenDefaultModule:
- let
- childCtx = scopeContexts.${spec.sourceScopeId} or { };
- # Classes owned by each entity kind in the chain. Total over kinds so the
- # filter never falls open for a non-user-owned scope (host, standalone home).
- ownedClasses =
- (childCtx.user.classes or [ ])
- ++ lib.optional (childCtx ? host) childCtx.host.class
- ++ lib.optional (childCtx ? home) childCtx.home.class;
- in
- if builtins.elem spec.fromClass ownedClasses then
- builtins.filter isDenDefaultModule rootModules
- else
- rootModules;
-
- getCollectedSource =
- acc: spec: rootScopeId: isDenDefaultModule: scopeContexts:
- let
- sid = spec.sourceScopeId;
- in
- if rootScopeId != null && sid != rootScopeId then
- let
- ownModules = (acc.perScope.${sid} or { }).${spec.fromClass} or [ ];
- rootModules = (acc.perScope.${rootScopeId} or { }).${spec.fromClass} or [ ];
- in
- filterRootModules scopeContexts spec rootModules isDenDefaultModule ++ ownModules
- else
- acc.classImports.${spec.fromClass} or [ ];
-
- resolveSourceFallback =
- spec: spawnNode: scopeParent: scopeContexts: ctx:
- # Early-out to no source: the spec has nothing to resolve from (no source
- # aspect/scope), or spawnNode wasn't threaded (non-home callers pass
- # null via the applyRoutes default).
- if !(spec ? sourceAspect) || spawnNode == null || !(spec ? sourceScopeId) then
- [ ]
- else
- (spawnNode {
- # spec.sourceScopeId is the USER scope (the forward compiles at the
- # current scope, per compile-forward.nix sourceScopeId = scope). `from`
- # must be the HOST scope = the user scope's parent. Using sourceScopeId
- # directly gives a self-parent edge -> policyBoundAncestor returns null
- # -> zero fleet peers (and spawnNode's spawnRoot == from assert trips).
- from = scopeParent.${spec.sourceScopeId} or spec.sourceScopeId;
- class = spec.fromClass;
- aspect = den.lib.aspects.normalizeRoot spec.sourceAspect;
- # The user binding is re-supplied by the source aspect's __scopeHandlers
- # (ctxFromHandlers in spawnNode's seedCtx), so spawnRoot resolves to
- # the user scope; do NOT strip it here.
- bindings = { };
- }).imports;
-
- appendToClass = acc: cls: sid: newMods: {
- classImports = acc.classImports // {
- ${cls} = (acc.classImports.${cls} or [ ]) ++ newMods;
- };
- perScope = acc.perScope // {
- ${sid} = (acc.perScope.${sid} or { }) // {
- ${cls} = ((acc.perScope.${sid} or { }).${cls} or [ ]) ++ newMods;
- };
- };
- };
-
- applyComplexRoute =
- acc:
- {
- route,
- rootScopeId,
- scopeContexts,
- scopeParent,
- ctx,
- spawnNode,
- buildForwardAspect,
- isDenDefaultModule,
- }:
- let
- spec = route;
- collected = getCollectedSource acc spec rootScopeId isDenDefaultModule scopeContexts;
- sourceModules =
- if collected != [ ] then
- collected
- else
- resolveSourceFallback spec spawnNode scopeParent scopeContexts ctx;
- sourceModule = spec.mapModule { imports = sourceModules; };
- newMods = collectClassMods spec.intoClass (buildForwardAspect spec sourceModule);
- in
- appendToClass acc spec.intoClass spec.sourceScopeId newMods;
-
- mkAdapterFunctor =
- route: sourceModules:
- let
- adapterMod = route.adapterModule or null;
- sourceModule = {
- imports = sourceModules;
- };
- guardFn = route.guard or (_: lib.id);
- adaptArgsFn = route.adaptArgs or (_: { });
- intoPathFn = route.intoPathFn or (_: route.path);
- key = route.adapterKey;
- guardArgs = route.guardArgs or { };
- intoPathArgs = route.intoPathArgs or { };
- adaptArgv = route.adaptArgv or { };
- freeformMod =
- route.freeformMod or {
- config._module.freeformType = lib.types.lazyAttrsOf lib.types.unspecified;
- };
- adapterMods =
- if adapterMod != null then
- [
- freeformMod
- adapterMod
- ]
- else
- [ freeformMod ];
- in
- {
- __functionArgs = guardArgs // intoPathArgs // adaptArgv;
- __functor = _: args: {
- options.den.fwd.${key} = lib.mkOption {
- defaultText = lib.literalExpression "{ }";
- default = { };
- type = lib.types.submoduleWith {
- specialArgs = adaptArgsFn args;
- modules = adapterMods ++ [ sourceModule ];
- };
- };
- config = guardFn args (lib.setAttrByPath (intoPathFn args) args.config.den.fwd.${key});
- };
- };
-
- # Collect class modules from a scope and all its descendants.
- collectFromSubtree =
- wrappedPerScope: scopeParent: rootScopeId: fromClass:
- let
- allScopeIds = builtins.attrNames wrappedPerScope;
- isInSubtree =
- sid:
- sid == rootScopeId
- || (
- let
- parent = scopeParent.${sid} or null;
- in
- parent != null && parent != sid && isInSubtree parent
- );
- subtreeScopes = builtins.filter isInSubtree allScopeIds;
- in
- lib.concatMap (sid: wrappedPerScope.${sid}.${fromClass} or [ ]) subtreeScopes;
-
- applySimpleRoute =
- acc:
- {
- route,
- wrappedPerScope,
- scopeParent,
- }:
- let
- isFlakeRoute = route.intoClass == "flake";
- # Subtree collection only for flake routes — parametric class keys
- # resolve at entity scopes (host/user) which are descendants of the
- # route's scope. Non-flake routes (e.g. into "flake-parts") collect
- # only from their own scope to avoid pulling modules from unrelated
- # entity subtrees whose scope args won't be available after adaptArgs.
- sourceModules =
- if isFlakeRoute || (route.collectSubtree or false) then
- collectFromSubtree wrappedPerScope scopeParent route.sourceScopeId route.fromClass
- else
- let
- scopeExists = wrappedPerScope ? ${route.sourceScopeId};
- in
- if !scopeExists then [ ] else wrappedPerScope.${route.sourceScopeId}.${route.fromClass} or [ ];
- hasInstantiate = route.instantiate or null != null;
- adapterMod = route.adapterModule or null;
- modulesWithAdapter = if adapterMod == null then sourceModules else sourceModules ++ [ adapterMod ];
- ensureEntry =
- lib.optional
- (
- !isFlakeRoute
- && route.adaptArgs or null != null
- && route.path or [ ] != [ ]
- && modulesWithAdapter == [ ]
- )
- {
- config = lib.setAttrByPath route.path { };
- };
- isAdapterRoute = route.adapterKey or null != null;
- adapterWrapped = lib.optional isAdapterRoute (mkAdapterFunctor route sourceModules);
- # Route with instantiate: collect modules, call instantiate function,
- # place the result (a derivation) at the target path.
- instantiateWrapped =
- let
- adaptArgsFn = route.adaptArgs or (_: { });
- extraArgs = adaptArgsFn { };
- evaluated = route.instantiate ({ modules = sourceModules; } // extraArgs);
- in
- [
- {
- config = lib.setAttrByPath route.path evaluated;
- }
- ];
- wrappedModules =
- if hasInstantiate then
- if sourceModules == [ ] then [ ] else instantiateWrapped
- else if modulesWithAdapter == [ ] then
- ensureEntry
- else if isAdapterRoute then
- adapterWrapped
- else
- wrapRouteModules {
- modules = modulesWithAdapter;
- inherit (route) path;
- guard = route.guard or null;
- adaptArgs = route.adaptArgs or null;
- };
- in
- appendToClass acc route.intoClass route.sourceScopeId wrappedModules;
-
- isDenDefaultModule = mod: lib.hasSuffix "@default" (mod.key or mod._file or "");
-
- # Collect adapterKeys that exist at child (non-root) scopes.
- findChildScopeKeys =
- rootScopeId: rawRoutes:
- builtins.foldl' (
- acc: r:
- let
- ak = r.adapterKey or null;
- in
- if ak != null && rootScopeId != null && r.sourceScopeId != rootScopeId then
- acc // { ${ak} = true; }
- else
- acc
- ) { } rawRoutes;
-
- # Dedup routes: suppress root-scope when child-scope handles the same forward,
- # and dedup same adapterKey@scope.
- dedupRoutes =
- rootScopeId: rawRoutes:
- let
- childScopeKeys = findChildScopeKeys rootScopeId rawRoutes;
- go =
- seen: routes:
- if routes == [ ] then
- [ ]
- else
- let
- r = builtins.head routes;
- rest = builtins.tail routes;
- ak = r.adapterKey or null;
- isRedundantRoot =
- ak != null && rootScopeId != null && r.sourceScopeId == rootScopeId && childScopeKeys ? ${ak};
- key = if ak != null then "${ak}@${r.sourceScopeId}" else null;
- in
- if isRedundantRoot then
- go seen rest
- else if key != null && seen ? ${key} then
- go seen rest
- else
- [ r ] ++ go (if key != null then seen // { ${key} = true; } else seen) rest;
- in
- go { } rawRoutes;
-
- # Topologically sort routes: when forward A's intoClass feeds forward
- # B's fromClass at the same scope, A must fire before B. Only
- # reorders __complexForward routes; non-forward routes keep their
- # original position relative to other non-forwards. (#567)
- topoSortRoutes =
- routes:
- let
- indexed = lib.imap0 (i: r: { inherit i r; }) routes;
- # Build producer map: intoClass@scope → [route indices]. Both simple
- # routes and complex forwards are producers: a simple route injecting
- # into a home-env class (e.g. homeManager) feeds the complex forward that
- # carries that class to its host output (makeHomeEnv's userForward).
- # Complex forwards read from the accumulating fold state, so any producer
- # of their fromClass must fire first or the injected content is lost.
- producerMap = builtins.foldl' (
- acc:
- { i, r }:
- let
- key = "${r.intoClass}@${r.sourceScopeId}";
- in
- acc // { ${key} = (acc.${key} or [ ]) ++ [ i ]; }
- ) { } indexed;
- # A complex forward "depends on producers" when its fromClass@scope has
- # producer entries from *other* routes (meaning another route produces
- # into the class it consumes). Simple routes read the original per-scope
- # data, not the fold state, so they never depend on ordering themselves.
- hasDeps =
- { i, r }:
- (r.__complexForward or false)
- && builtins.any (j: j != i) (producerMap."${r.fromClass}@${r.sourceScopeId}" or [ ]);
- # Partition: routes without deps first, routes with deps last.
- # This is a single-level toposort (sufficient for A→B chains).
- noDeps = builtins.filter (ir: !hasDeps ir) indexed;
- withDeps = builtins.filter hasDeps indexed;
- in
- map (ir: ir.r) (noDeps ++ withDeps);
-
- # Main entry: dedup routes, fold applying each.
- applyRoutes =
- {
- scopedRoutes,
- wrappedPerScope,
- classImports,
- scopeParent ? { },
- scopeContexts ? { },
- ctx ? { },
- spawnNode ? null,
- rootScopeId ? null,
- buildForwardAspect ? null,
- }:
- let
- allRoutes = topoSortRoutes (
- dedupRoutes rootScopeId (lib.concatLists (lib.attrValues scopedRoutes))
- );
- in
- builtins.foldl'
- (
- acc: route:
- if route.__complexForward or false then
- applyComplexRoute acc {
- inherit
- route
- rootScopeId
- scopeContexts
- scopeParent
- ctx
- spawnNode
- buildForwardAspect
- isDenDefaultModule
- ;
- }
- else
- applySimpleRoute acc { inherit route wrappedPerScope scopeParent; }
- )
- {
- inherit classImports;
- perScope = wrappedPerScope;
- }
- allRoutes;
-in
-{
- inherit applyRoutes;
-}
diff --git a/nix/lib/aspects/fx/route/default.nix b/nix/lib/aspects/fx/route/default.nix
deleted file mode 100644
index ca6229f61..000000000
--- a/nix/lib/aspects/fx/route/default.nix
+++ /dev/null
@@ -1,23 +0,0 @@
-# Route module delivery — move modules between entity scopes/classes.
-{
- lib,
- den,
- ...
-}:
-let
- inherit (import ./wrap.nix { inherit lib den; }) wrapRouteModules collectClassMods;
- inherit
- (import ./apply.nix {
- inherit
- lib
- den
- wrapRouteModules
- collectClassMods
- ;
- })
- applyRoutes
- ;
-in
-{
- inherit wrapRouteModules applyRoutes;
-}
diff --git a/nix/lib/aspects/fx/route/wrap.nix b/nix/lib/aspects/fx/route/wrap.nix
deleted file mode 100644
index a57bed952..000000000
--- a/nix/lib/aspects/fx/route/wrap.nix
+++ /dev/null
@@ -1,169 +0,0 @@
-# Route module wrapping — path nesting, guards, adaptArgs.
-{ lib, ... }:
-let
- # Freeform type for route nesting evalModules: merges like NixOS
- # (attrsets deep-merge, lists concatenate) but errors on conflicting
- # scalar or derivation values instead of silently clobbering.
- mergeableType = lib.mkOptionType {
- name = "mergeable";
- description = "auto-merged value (attrsets merge, lists concatenate, scalars conflict)";
- merge =
- loc: defs:
- let
- values = map (d: d.value) defs;
- first = builtins.head values;
- allLists = builtins.all builtins.isList values;
- # Derivations are attrsets but must not deep-merge — treat as opaque.
- allMergeableAttrs = builtins.all (v: builtins.isAttrs v && !(lib.isDerivation v)) values;
- in
- if builtins.length defs == 1 then
- first
- else if allLists then
- builtins.concatLists values
- else if allMergeableAttrs then
- (lib.types.lazyAttrsOf mergeableType).merge loc defs
- else
- throw "den: the option `${lib.showOption loc}' has conflicting definitions from multiple aspects";
- };
- nestingFreeformType = lib.types.lazyAttrsOf mergeableType;
-
- # Adapt a module's args when path is empty (top-level adaptArgs).
- adaptModule =
- adaptArgs: path: mod:
- if adaptArgs == null || path != [ ] then
- mod
- else if builtins.isFunction mod then
- args: mod (adaptArgs args)
- else
- mod;
-
- # Nest a module at a path using submodule evaluation with adapted specialArgs.
- nestWithAdaptArgs =
- path: adaptArgs: mod: args:
- let
- fullArgs = args // (args.config._module.args or { });
- adapted = adaptArgs fullArgs;
- sourceModules = if builtins.isAttrs mod && mod ? imports then mod.imports else [ mod ];
- evaluated = lib.evalModules {
- specialArgs = adapted;
- modules = [
- { config._module.freeformType = nestingFreeformType; }
- ]
- ++ sourceModules;
- };
- in
- {
- config = lib.setAttrByPath path (
- builtins.removeAttrs evaluated.config [
- "_module"
- "warnings"
- "assertions"
- ]
- );
- };
-
- # Nest a module at a path by evaluating imports with full outer args.
- # Uses evalModules with raw freeform type so conflicting keys error
- # instead of silently clobbering via recursiveUpdate.
- nestPlain =
- path: mod: args:
- let
- fullArgs = args // (args.config._module.args or { });
- resolveImport = imp: if builtins.isFunction imp then imp fullArgs else imp;
- sourceModules = if builtins.isAttrs mod && mod ? imports then mod.imports else [ mod ];
- resolved = map resolveImport sourceModules;
- evaluated = lib.evalModules {
- specialArgs = fullArgs;
- modules = [
- { config._module.freeformType = nestingFreeformType; }
- ]
- ++ resolved;
- };
- in
- {
- config = lib.setAttrByPath path (
- builtins.removeAttrs evaluated.config [
- "_module"
- "warnings"
- "assertions"
- ]
- );
- };
-
- # Nest a module at a target path (dispatch between adapt and plain strategies).
- nestModule =
- path: adaptArgs: mod:
- if path == [ ] then
- mod
- else if adaptArgs != null then
- nestWithAdaptArgs path adaptArgs mod
- else
- nestPlain path mod;
-
- # Wrap a module with a conditional guard.
- #
- # A bool guard gates content with `lib.optionalAttrs`, not `lib.mkIf`, to
- # match the forward path (forward.nix guardFn): a false guard must contribute
- # *nothing* — `mkIf false` still requires the target option to exist, so an
- # empty-path route into an undeclared option would fail option type-checking
- # even when skipped. `optionalAttrs false` drops the subtree entirely.
- #
- # A structural module (carries `imports`/`_file`/`key` module metadata but no
- # flat `config`) cannot be merged under `config` — its module-level keys would
- # be mis-read as option definitions and fail (e.g. "the option `_file' does
- # not exist"). This is the empty-path case, where the source is the raw
- # collector module. Recurse into its `imports`, gating each leaf's config and
- # leaving module metadata at module level.
- guardModule =
- guard: mod:
- if guard == null then
- mod
- else
- let
- guardOne =
- node: args:
- let
- inner = if builtins.isFunction node then node args else node;
- in
- if inner ? imports && !(inner ? config) then
- { imports = map guardOne inner.imports; } // builtins.removeAttrs inner [ "imports" ]
- else
- { config = lib.optionalAttrs (guard args) (inner.config or inner); };
- in
- guardOne mod;
-
- # Apply the adapt → nest → guard pipeline to a list of modules.
- # When adaptArgs is non-null (nestWithAdaptArgs path), all modules are
- # combined into a single evalModules call so that multiple aspects
- # emitting to the same class merge correctly inside evalModules,
- # rather than producing separate config definitions that get
- # shallow-merged by the freeform `unspecified` type. (#572)
- wrapRouteModules =
- {
- modules,
- path,
- guard ? null,
- adaptArgs ? null,
- }:
- let
- adapted = map (adaptModule adaptArgs path) modules;
- in
- if adapted == [ ] then
- [ ]
- else if adaptArgs != null && path != [ ] then
- [ (guardModule guard (nestWithAdaptArgs path adaptArgs { imports = adapted; })) ]
- else
- map (mod: guardModule guard (nestModule path adaptArgs mod)) adapted;
-
- # Collect class modules from a forward aspect (recursing into includes).
- collectClassMods =
- cls: aspect:
- let
- own = lib.optional (aspect ? ${cls}) aspect.${cls};
- nested = builtins.concatMap (collectClassMods cls) (aspect.includes or [ ]);
- in
- own ++ nested;
-in
-{
- inherit wrapRouteModules collectClassMods;
-}
diff --git a/nix/lib/aspects/fx/scope-walk.nix b/nix/lib/aspects/fx/scope-walk.nix
new file mode 100644
index 000000000..af8182f99
--- /dev/null
+++ b/nix/lib/aspects/fx/scope-walk.nix
@@ -0,0 +1,60 @@
+# Shared subtree walk + key-dedup for the delivery half.
+#
+# One walk implementation backs every "scope + descendants" collection in the
+# pipeline (default fold, route subtree collect, per-host re-walk, spawn final
+# extraction) and the edge-trace oracle, so production and oracle can never
+# diverge. The `isolated` argument is REQUIRED at every call site: there is no
+# default. Two live callers need the isolation-BLIND variant (`isolated = {}`)
+# and they are NOT the same call site: the per-host re-walk's
+# sub-phase collect, and spawn-node's final extraction. Defaulting `isolated`
+# would silently collapse this deliberate blind/aware split.
+{ lib, ... }:
+{
+ # Scope IDs in `root`'s subtree. `root` is ALWAYS included: isolation gates
+ # crossing INTO a descendant, not collecting AT the root. `isolated` is a
+ # `{ = bool; }` map; `isolated = {}` gives the isolation-blind variant.
+ subtreeScopes =
+ {
+ scopeParent,
+ isolated,
+ root,
+ allScopeIds,
+ }:
+ let
+ isIn =
+ sid:
+ sid == root
+ || (
+ !(isolated.${sid} or false)
+ && (
+ let
+ parent = scopeParent.${sid} or null;
+ in
+ parent != null && parent != sid && isIn parent
+ )
+ );
+ in
+ builtins.filter isIn allScopeIds;
+
+ # First-occurrence-wins dedup of `list` by the key `getKey` extracts from each
+ # element. Elements whose key is null are always kept (never deduped).
+ dedupByKey =
+ getKey: list:
+ let
+ go =
+ seen: items:
+ if items == [ ] then
+ [ ]
+ else
+ let
+ x = builtins.head items;
+ rest = builtins.tail items;
+ k = getKey x;
+ in
+ if k != null && seen ? ${k} then
+ go seen rest
+ else
+ [ x ] ++ go (if k != null then seen // { ${k} = true; } else seen) rest;
+ in
+ go { } list;
+}
diff --git a/nix/lib/aspects/fx/spawn-node.nix b/nix/lib/aspects/fx/spawn-node.nix
index 7dfbe911d..be79702ff 100644
--- a/nix/lib/aspects/fx/spawn-node.nix
+++ b/nix/lib/aspects/fx/spawn-node.nix
@@ -9,19 +9,20 @@
{ lib, den }:
let
inherit (import ./assemble-pipes.nix { inherit lib den; }) assemblePipes;
+ inherit (import ./scope-walk.nix { inherit lib; }) subtreeScopes;
inherit (import ./handlers/route.nix { inherit lib; }) routeKey;
+ inherit (import ./edges/materialize.nix { inherit lib den; }) assembleSpawnSubtree;
pipeNamesSet = lib.genAttrs (builtins.attrNames (den.quirks or { })) (_: true);
in
{
- # Phase helpers (wrapPerScope/applyProvides/applyRoutes) and the recursive
- # nested-route resolver (selfRef) are injected to avoid a resolve.nix import
- # cycle. mkPipeline + parentState are captured once per run; the inner
- # { from, class, aspect, bindings } call materializes a single class.
+ # The phase-1 wrap (wrapPerScope) and the recursive nested-route resolver
+ # (selfRef) are injected to avoid a resolve.nix import cycle. mkPipeline +
+ # parentState are captured once per run; the inner { from, class, aspect,
+ # bindings } call materializes a single class. (provides + routes fold through
+ # materializeUnified inside assembleSpawnSubtree, so they are no longer injected.)
mkSpawnNode =
{
wrapPerScope,
- applyProvides,
- applyRoutes,
normalizeRoot,
ctxFromHandlers,
selfRef,
@@ -100,6 +101,8 @@ in
// {
${spawnRoot} = from;
};
+ mergedScopeIsolated =
+ (parentState.scopeIsolated or { }) // ((result.state.scopeIsolated or (_: { })) null);
# 3. Re-derive pipes over merged state. hostConfigs = null: config-dependent
# stay deferred (via __configThunk); pipeline-parametric resolve eagerly.
@@ -112,19 +115,41 @@ in
hostConfigs = null;
});
- # 4. Phases 1-3 over the spawned subtree; class isolation -> one class emitted.
- phase1 = wrapPerScope parentState.ctx augmented mergedClassImports;
- phase2 = applyProvides parentState.ctx augmented (result.state.scopedProvides null) phase1;
- # Parent-pipeline routes sourced inside the spawned subtree must apply
- # here too: the spawn re-emits class content at the same scope ids but
- # never re-fires schema policies, so without them a user-schema route
- # (homeLinux->homeManager) never fires and the content drops.
- # Dedup against the spawn's own registrations — an aspect-borne route
- # can register in both pipelines, and a duplicated path != [] simple
- # route would re-nest content in fresh keyless wrappers and conflict
- # at the target.
+ # The subtree-membership universe: the merged
+ # parent DAG keys ∪ the route-scope keys. WIDER than perScope: a route-only
+ # scope can sit on the subtree parent-chain without a class bucket. Both the
+ # parentSubtreeRoutes filter (below) and the final extraction (inside
+ # assembleSpawnSubtree, via Π.allScopeIds) walk over this same universe.
+ spawnAllScopeIds = lib.unique (
+ builtins.attrNames mergedScopeParent ++ builtins.attrNames parentState.scopedRoutes
+ );
+
+ # Isolation-BLIND subtree membership rooted at spawnRoot, over the merged
+ # parent DAG. `isolated = {}` is passed EXPLICITLY (documented invariant:
+ # isolated entities resolve via resolve.to in the host pipeline,
+ # never through spawnNode, so no isolated descendant can appear under
+ # spawnRoot). Used ONLY for the parentSubtreeRoutes filter; the final
+ # extraction's identical blind walk happens inside assembleSpawnSubtree
+ # (Π.isolationMode = "blind"), both over spawnAllScopeIds — one shared walk.
+ subtreeSet = lib.genAttrs (subtreeScopes {
+ scopeParent = mergedScopeParent;
+ isolated = { };
+ root = spawnRoot;
+ allScopeIds = spawnAllScopeIds;
+ }) (_: true);
+
+ # DELIBERATE: parent-pipeline routes sourced inside the spawned
+ # subtree MUST re-apply — the spawn re-emits class content at the same scope
+ # ids but never re-fires schema policies, so without them a user-schema route
+ # (homeLinux->homeManager) never fires and the content drops. This is the
+ # `mergedSpawnRoutes` edge-identity dedup: the spawn's OWN route edges win
+ # over parent-subtree route edges with the same routeKey identity (an
+ # aspect-borne route can register in both pipelines; a duplicated path != []
+ # simple route would re-nest content in fresh keyless wrappers and conflict
+ # at the target). Order/precedence preserved exactly: freshParent (parent
+ # routes whose key ∉ spawn keys) ++ spawnHere.
spawnRoutes = result.state.scopedRoutes null;
- parentSubtreeRoutes = lib.filterAttrs (sid: _: isInSubtree sid) parentState.scopedRoutes;
+ parentSubtreeRoutes = lib.filterAttrs (sid: _: subtreeSet ? ${sid}) parentState.scopedRoutes;
mergedSpawnRoutes =
spawnRoutes
// lib.mapAttrs (
@@ -137,29 +162,35 @@ in
freshParent ++ spawnHere
) parentSubtreeRoutes;
- phase3 =
- applyRoutes selfRef parentState.ctx augmented spawnRoot mergedScopeParent mergedSpawnRoutes
- phase2;
-
- # Restrict extraction to the spawned subtree (spawnRoot + descendants).
- # phase3.classImports aggregates across ALL merged scopes — including the
- # host and SIBLING user scopes (the pipe-collection peers, and other users
- # on the same host) — so reading it directly would leak a peer user's
- # homeManager content into this node. The fleet pipe values still resolve
- # correctly because assemblePipes ran over the full merged state; only the
- # final per-scope class buckets are subtree-restricted here.
- isInSubtree =
- sid:
- sid == spawnRoot
- || (
- let
- parent = mergedScopeParent.${sid} or null;
- in
- parent != null && parent != sid && isInSubtree parent
- );
- subtreeScopes = builtins.filter isInSubtree (builtins.attrNames phase3.perScope);
+ # The spawn's provides + routes fold + isolation-BLIND, dedup-FREE final
+ # extraction, expressed over the edge machinery (materializeUnified inside
+ # assembleSpawnSubtree). wrapPerScope is forwarded (injection seam preserved —
+ # no resolve.nix import cycle); the inline phase1/phase2/phase3 + subtree
+ # concat dissolved into one entry. The fold aggregates across ALL merged
+ # scopes (host + sibling users), so the extraction is subtree-restricted via
+ # Π.allScopeIds + isolationMode="blind" to avoid leaking a peer user's
+ # content; fleet pipe values still resolve correctly because assemblePipes ran
+ # over the full merged state before the fold.
in
- {
- imports = lib.concatMap (sid: phase3.perScope.${sid}.${class} or [ ]) subtreeScopes;
+ # The self-parent assert is forced via `augmented` (which the fold reads),
+ # matching the prior inline form's laziness: the throw surfaces only when this
+ # node's content is actually collected, not at attrset construction.
+ assembleSpawnSubtree {
+ inherit
+ class
+ spawnRoot
+ mergedScopeParent
+ mergedScopeIsolated
+ mergedSpawnRoutes
+ selfRef
+ wrapPerScope
+ ;
+ ctx = parentState.ctx;
+ inherit augmented;
+ inherit mergedClassImports;
+ # Merged parent + spawned entity kinds — the surfaced edges' scopeName map.
+ scopeEntityKind = parentState.scopeEntityKind // ((result.state.scopeEntityKind or (_: { })) null);
+ ownProvides = result.state.scopedProvides null;
+ allScopeIds = spawnAllScopeIds;
};
}
diff --git a/nix/lib/aspects/has-aspect.nix b/nix/lib/aspects/has-aspect.nix
index 092f91b70..c8ba22fc1 100644
--- a/nix/lib/aspects/has-aspect.nix
+++ b/nix/lib/aspects/has-aspect.nix
@@ -15,10 +15,12 @@ let
else
throw "hasAspect: ref must have `name`+`meta` or `__provider` (got ${builtins.typeOf ref}).";
- # Resolve tree via fx pipeline and extract pathSet from state.
+ # Resolve tree via fx pipeline, returning the full result state. One run
+ # yields both the pathSet (membership, for hasAspect) and resolvedNodes
+ # (for .aspects), so callers share a single resolution per class.
# Inlines the same root normalization as fxResolveTree (default.nix)
# to handle raw lambdas and functor attrsets.
- collectPathSet =
+ resolveClassState =
{ tree, class }:
let
normalized = den.lib.aspects.normalizeRoot tree;
@@ -30,7 +32,13 @@ let
self = normalized;
};
in
- (result.state.pathSet or (_: { })) null;
+ result.state;
+
+ collectPathSet =
+ { tree, class }:
+ identity.flattenPathSetByScope (
+ ((resolveClassState { inherit tree class; }).pathSetByScope or (_: { })) null
+ );
hasAspectIn =
{
@@ -41,13 +49,14 @@ let
(collectPathSet { inherit tree class; }) ? ${refKey ref};
# Projected hasAspect: pure lookup over an already-computed per-scope path
- # set (byproduct of the owning entity's production run). No pipeline.
- # `pathSetByScope`/`scopeId` are read lazily — forced only when the result
- # boolean is scrutinised (e.g. at a class-module `mkIf`, post-run).
+ # set (byproduct of the owning entity's production run), keyed by entity
+ # identity (`id_hash`). No pipeline. `pathSetByScope`/`key` are read lazily —
+ # forced only when the result boolean is scrutinised (e.g. at a class-module
+ # `mkIf`, post-run).
mkProjectedHasAspect =
- { pathSetByScope, scopeId }:
+ { pathSetByScope, key }:
let
- check = ref: (pathSetByScope.${scopeId} or { }) ? ${refKey ref};
+ check = ref: key != null && (pathSetByScope.${key} or { }) ? ${refKey ref};
in
{
__functor = _: check;
@@ -55,6 +64,31 @@ let
forAnyClass = check;
};
+ # Augment a resolved node with its identity accessors for .aspects callers.
+ # Shallow: every node already appears as its own flat entry, so children
+ # reached via `.includes` are also present (augmented) at top level.
+ augment =
+ node:
+ let
+ baseId = identity.baseKey node;
+ in
+ node
+ // {
+ # Base FQN, ctx-stripped — pretty + stable (e.g. "roles/workstation").
+ identity = baseId;
+ # Full unique key incl {ctxId} — distinguishes anonymous instances.
+ identityKey = identity.key node;
+ # Named only if neither the node's own name nor any provider-chain segment
+ # is an anonymous/synthetic sentinel. isMeaningfulName catches an exact
+ # ""/""/"[definition …]" name; the infix guards catch
+ # nested anonymous instances like "roles/dev/:3" (whose name
+ # ":3" slips past isMeaningfulName), so consumers can filter on it.
+ isNamed =
+ den.lib.aspects.isMeaningfulName (node.name or "")
+ && !(lib.hasInfix "" baseId)
+ && !(lib.hasInfix "" baseId);
+ };
+
mkEntityHasAspect =
{
tree,
@@ -62,22 +96,36 @@ let
classes,
}:
let
- setFor = builtins.listToAttrs (
+ # One resolution per unique class, shared between membership (pathSet)
+ # and the node list (resolvedNodes).
+ stateFor = builtins.listToAttrs (
map (c: {
name = c;
- value = collectPathSet {
+ value = resolveClassState {
inherit tree;
class = c;
};
}) (lib.unique ([ primaryClass ] ++ classes))
);
- check = class: ref: (setFor.${class} or { }) ? ${refKey ref};
- bareFn = check primaryClass;
+ pathSetFor =
+ c: identity.flattenPathSetByScope (((stateFor.${c} or { }).pathSetByScope or (_: { })) null);
+ nodesFor =
+ c: map augment (lib.attrValues (((stateFor.${c} or { }).resolvedNodes or (_: { })) null));
+ check = class: ref: (pathSetFor class) ? ${refKey ref};
in
{
- __functor = _: bareFn;
+ __functor = _: check primaryClass;
forClass = check;
forAnyClass = ref: lib.any (c: check c ref) classes;
+
+ # Flat list of all resolved aspect nodes (every depth) for the primary
+ # class, each augmented with .identity / .identityKey / .isNamed.
+ aspects = nodesFor primaryClass;
+ aspectsForClass = nodesFor;
+ # Union across classes, deduped by full identity key.
+ allAspects = lib.attrValues (
+ builtins.listToAttrs (map (n: lib.nameValuePair n.identityKey n) (lib.concatMap nodesFor classes))
+ );
};
in
diff --git a/nix/lib/aspects/types.nix b/nix/lib/aspects/types.nix
index 0e3f68434..d2dd86d09 100644
--- a/nix/lib/aspects/types.nix
+++ b/nix/lib/aspects/types.nix
@@ -599,14 +599,21 @@ let
aspectKeyType =
typeCfg:
let
- classReg = den.classes or { };
contentType = aspectContentType typeCfg;
+ inherit (den.lib.aspects.fx.keyClassification) structuralKeysSet;
in
lib.types.mkOptionType {
name = "aspectKey";
description = "class module or nested aspect (dispatch by registry)";
check = _: true;
- merge = loc: defs: contentType.merge loc defs;
+ # Reserved/structural keys are metadata, not aspect content: pass their
+ # value through untouched (last def wins) so consumers read it back as
+ # declared. Without this, the content wrapper mangles the value into a
+ # __contentValues/__provider shape even though the pipeline ignores the
+ # key for dispatch. Everything else gets the provenance/content wrapper.
+ merge =
+ loc: defs:
+ if structuralKeysSet ? ${lib.last loc} then (lib.last defs).value else contentType.merge loc defs;
};
# Aspect meta submodule type: handleWith, provider, collisionPolicy.
diff --git a/nix/lib/default.nix b/nix/lib/default.nix
index a1e28e828..9e88b6fa0 100644
--- a/nix/lib/default.nix
+++ b/nix/lib/default.nix
@@ -36,6 +36,7 @@ let
schemaUtil = ./schema-util.nix;
synthesizePolicies = ./synthesize-policies.nix;
fx = ./fx.nix;
+ schema = ./schema.nix;
};
in
den-lib
diff --git a/nix/lib/diag/capture.nix b/nix/lib/diag/capture.nix
index 4b4d48a8f..e76b9976f 100644
--- a/nix/lib/diag/capture.nix
+++ b/nix/lib/diag/capture.nix
@@ -74,9 +74,12 @@ let
in
{
entries = lib.concatMap (c: rawPerClass.${c}.state.entries) classes;
- # Unwrap thunked pathSet — pipeline wraps growing state fields as
- # (_: value) to survive deepSeq. Apply null to unwrap.
- pathsByClass = lib.mapAttrs (_: r: (r.state.pathSet or (_: { })) null) rawPerClass;
+ # Flat membership per class = union of the per-scope buckets. Unwrap the
+ # thunked pathSetByScope (pipeline wraps growing state as (_: value) to
+ # survive deepSeq; apply null), then flatten.
+ pathsByClass = lib.mapAttrs (
+ _: r: den.lib.aspects.fx.identity.flattenPathSetByScope ((r.state.pathSetByScope or (_: { })) null)
+ ) rawPerClass;
ctxTrace =
let
first = rawPerClass.${lib.head classes};
diff --git a/nix/lib/entities/_types.nix b/nix/lib/entities/_types.nix
index c9b199728..e0732223f 100644
--- a/nix/lib/entities/_types.nix
+++ b/nix/lib/entities/_types.nix
@@ -4,6 +4,7 @@
# Extracted from nix/lib/types.nix — no new functionality.
{
lib,
+ den,
...
}:
let
@@ -22,6 +23,16 @@ let
else
lib.warn "den.aspects.${config.name} not defined — entity gets empty aspect" { };
+ # Recursive merge without forcing leaf values. Unlike lib.types.anything this
+ # does not inspect values deeply (no mapAttrsRecursiveCond), avoiding infinite
+ # recursion when values reference other options (e.g. den.aspects).
+ deepMergeAttrs = lib.mkOptionType {
+ name = "deepMergeAttrs";
+ description = "recursively merged attribute set";
+ check = builtins.isAttrs;
+ merge = _loc: defs: builtins.foldl' (acc: def: lib.recursiveUpdate acc def.value) { } defs;
+ };
+
# Single shared production run: imports + per-scope path set from ONE fx.handle.
# Declared as an option so the module fixpoint memoizes it — every consumer
# (mainModule, __pathSetByScope) reads the same value, guaranteeing one resolve.
@@ -48,24 +59,128 @@ let
default = { inherit (config.__resolveResult) imports; };
};
- # Per-scope path set, surfaced for the projected (in-context) hasAspect.
+ # Per-scope path set for the projected (in-context) hasAspect, RE-KEYED from
+ # scope-string to ENTITY IDENTITY (id_hash).
+ #
+ # The structural walk buckets each node under its scope STRING (`host=…`,
+ # `host=…,user=…`). But projected hasAspect is consumed in a DEEPER context —
+ # the fleet resolve, where the owner inherits ancestor scopes (`environment=…`)
+ # — so a scope-string key can never match the owner's standalone-rooted bucket.
+ # An entity's `id_hash` is context-free (kind+name, NOT ancestry) and stable
+ # across the standalone-produce and fleet-consume runs, so re-keying by it lets
+ # the consumer look up by the consuming entity's OWN id_hash with zero ancestor
+ # reconciliation. The root scope's entity is `config` itself (its kind is
+ # passed in, since the root scope is seeded without a push-scope record).
pathSetByScopeOption =
- _den: config:
+ _den: kind: config:
lib.mkOption {
internal = true;
visible = false;
readOnly = true;
type = lib.types.raw;
- defaultText = "config.__resolveResult.pathSetByScope";
- default = config.__resolveResult.pathSetByScope;
+ defaultText = "config.__resolveResult.pathSetByScope (re-keyed by entity id_hash)";
+ default =
+ let
+ r = config.__resolveResult;
+ scopeCtxs = r.scopeContexts or { };
+ entityKinds = r.scopeEntityKind or { };
+ entityForScope =
+ scopeStr:
+ let
+ k = entityKinds.${scopeStr} or kind;
+ in
+ (scopeCtxs.${scopeStr} or { }).${k} or config;
+ in
+ # Fold (not mapAttrs') so that if two scopes share an id_hash — id_hash
+ # is parent-blind (kind+name), so same-named siblings under different
+ # parents collide — their path sets UNION rather than last-wins. Union
+ # over-approximates membership (the safe direction); dropping a bucket
+ # would false-negative, the original /persist regression.
+ lib.foldl' (
+ acc: scopeStr:
+ let
+ k = (entityForScope scopeStr).id_hash or scopeStr;
+ in
+ acc // { ${k} = (acc.${k} or { }) // r.pathSetByScope.${scopeStr}; }
+ ) { } (builtins.attrNames r.pathSetByScope);
+ };
+
+ # Entity kinds from the schema's own kind list (gen-schema _kindNames is
+ # sorted and excludes _-prefixed introspection keys), minus the shared
+ # `conf` base.
+ schemaKinds = builtins.filter (n: n != "conf") (den.schema._kindNames or [ ]);
+
+ # Module injected into entity submodules for resolved aspect and
+ # collisionPolicy. Extracted here so host.nix, home.nix, and future entity
+ # types all share it. Identity (id_hash) is owned by the schema — supplied
+ # by gen-schema's mkInstanceType via mkIdentityModule — not duplicated here.
+ resolvedCtxModule =
+ kind:
+ { config, ... }:
+ {
+ options.resolved = lib.mkOption {
+ description = "The resolved aspect for this ${kind}.";
+ readOnly = true;
+ type = lib.types.raw;
+ default =
+ let
+ isContextArg = n: builtins.elem n schemaKinds;
+ ctx = lib.filterAttrs (n: v: isContextArg n && v != null) config._module.args // {
+ ${kind} = config;
+ };
+ in
+ den.lib.resolveEntity kind ctx;
+ };
+ options.collisionPolicy = lib.mkOption {
+ description = "How to handle collisions between den context args and module-system args.";
+ type = lib.types.nullOr (
+ lib.types.enum [
+ "error"
+ "class-wins"
+ "den-wins"
+ ]
+ );
+ default = null;
+ };
};
+ # System strings recognized as two-level group keys rather than host names.
+ reservedSystems = lib.genAttrs lib.systems.flakeExposed (_: true);
+
+ # Normalize mixed host declarations into canonical two-level form.
+ # Two-level entries (key is a system string) pass through.
+ # Flat entries (key is a host name) are grouped by their `system` attribute.
+ preprocessHosts =
+ raw:
+ let
+ systemGroups = lib.filterAttrs (k: _: reservedSystems ? ${k}) raw;
+ directHosts = lib.filterAttrs (k: _: !(reservedSystems ? ${k})) raw;
+ grouped = lib.foldlAttrs (
+ acc: name: cfg:
+ let
+ system =
+ cfg.system
+ or (throw "den: flat host '${name}' must specify 'system' (e.g. system = \"x86_64-linux\")");
+ in
+ acc
+ // {
+ ${system} = (acc.${system} or { }) // {
+ ${name} = builtins.removeAttrs cfg [ "system" ];
+ };
+ }
+ ) { } directHosts;
+ in
+ lib.recursiveUpdate systemGroups grouped;
in
{
inherit
strOpt
lookupAspect
+ deepMergeAttrs
mainModuleOption
resolveResultOption
pathSetByScopeOption
+ resolvedCtxModule
+ reservedSystems
+ preprocessHosts
;
}
diff --git a/nix/lib/entities/home.nix b/nix/lib/entities/home.nix
index fa8fe91e8..0f292a939 100644
--- a/nix/lib/entities/home.nix
+++ b/nix/lib/entities/home.nix
@@ -6,18 +6,40 @@
...
}@top:
let
- inherit (import ./_types.nix { inherit lib; })
+ inherit (import ./_types.nix { inherit lib den; })
strOpt
lookupAspect
+ deepMergeAttrs
mainModuleOption
resolveResultOption
pathSetByScopeOption
+ resolvedCtxModule
+ preprocessHosts
;
+ # Entity instances are gen-schema instances: mkInstanceType injects name,
+ # strict/freeform, _module.args., and schema-owned id_hash (identity).
+ schemaLib = den.lib.schema;
+
+ innerType = lib.types.attrsOf homeSystemType;
+
homesOption = lib.mkOption {
description = "den standalone home-manager configurations";
default = { };
- type = lib.types.attrsOf homeSystemType;
+ type = lib.types.attrsOf (lib.types.submodule { freeformType = deepMergeAttrs; });
+ apply =
+ raw:
+ let
+ normalized = preprocessHosts raw;
+ in
+ innerType.merge
+ [ "den" "homes" ]
+ [
+ {
+ file = "";
+ value = normalized;
+ }
+ ];
};
homeSystemType = lib.types.submodule (
@@ -29,136 +51,142 @@ let
homeType =
system:
- lib.types.submodule (
- { name, config, ... }:
- let
- parts = builtins.split "@" name;
- nameWithHost = builtins.length parts > 1;
- userName = lib.head parts;
- hostName = if nameWithHost then lib.last parts else null;
- hostByName = if hostName != null then den.hosts.${system}.${hostName} or null else null;
- userByName = if hostByName != null then hostByName.users.${userName} or null else null;
+ schemaLib.mkInstanceType den.schema.home {
+ strict = false;
+ extraModules = [
+ (resolvedCtxModule "home")
+ (
+ { name, config, ... }:
+ let
+ parts = builtins.split "@" name;
+ nameWithHost = builtins.length parts > 1;
+ userName = lib.head parts;
+ hostName = if nameWithHost then lib.last parts else null;
+ hostByName = if hostName != null then den.hosts.${system}.${hostName} or null else null;
+ userByName = if hostByName != null then hostByName.users.${userName} or null else null;
- # A home named `user@host` carries a host identity even when that host
- # isn't declared in `den.hosts`. Synthesize a minimal `{ name = ...; }`
- # so host-keyed provides/policies (which match on `host.name`) resolve
- # for an otherwise-standalone home — without instantiating a real host
- # entity, which would pull in its platform builder (e.g. nix-darwin).
- # A declared host always wins and remains the only thing that wires
- # `osConfig`.
- #
- # Only `host` is synthesized, never `user`: a synthetic host alone fires
- # `{ host }`-keyed policies (gated on `host ? class` so OS-class routing
- # stays inert for a classless synthetic host), while `{ host, user }`-keyed
- # OS batteries (define-user, user-to-host, …) keep their existing
- # null-user gating and fall back to their home-scope path.
- hostCtx =
- if hostByName != null then
- hostByName
- else if nameWithHost then
- { name = hostName; }
- else
- null;
+ # A home named `user@host` carries a host identity even when that host
+ # isn't declared in `den.hosts`. Synthesize a minimal `{ name = ...; }`
+ # so host-keyed provides/policies (which match on `host.name`) resolve
+ # for an otherwise-standalone home — without instantiating a real host
+ # entity, which would pull in its platform builder (e.g. nix-darwin).
+ # A declared host always wins and remains the only thing that wires
+ # `osConfig`.
+ #
+ # Only `host` is synthesized, never `user`: a synthetic host alone fires
+ # `{ host }`-keyed policies (gated on `host ? class` so OS-class routing
+ # stays inert for a classless synthetic host), while `{ host, user }`-keyed
+ # OS batteries (define-user, user-to-host, …) keep their existing
+ # null-user gating and fall back to their home-scope path.
+ hostCtx =
+ if hostByName != null then
+ hostByName
+ else if nameWithHost then
+ { name = hostName; }
+ else
+ null;
- homeManagerConfiguration =
- if nameWithHost && hostByName != null then
- { pkgs, modules }:
- inputs.home-manager.lib.homeManagerConfiguration {
- inherit pkgs modules;
- extraSpecialArgs.osConfig = lib.attrByPath (
- [ "flake" ] ++ hostByName.intoAttr ++ [ "config" ]
- ) null top.config;
- }
- else
- inputs.home-manager.lib.homeManagerConfiguration;
- in
- {
- freeformType = lib.types.attrsOf lib.types.anything;
- imports = [ den.schema.home ];
- config._module.args.home = config;
- config._module.args.host = hostCtx;
- config._module.args.user = userByName;
- options = {
- name = strOpt "home configuration name" userName;
- userName = strOpt "user account name" userName;
- hostName = lib.mkOption {
- type = lib.types.nullOr lib.types.str;
- default = hostName;
- description = "host name (null for unbound standalone homes)";
- };
- user = lib.mkOption {
- default = userByName;
- defaultText = lib.literalExpression "user";
- };
- host = lib.mkOption {
- default = hostCtx;
- defaultText = lib.literalExpression "host";
- };
- system = strOpt "platform system" system;
- class = strOpt "home management nix class" "homeManager";
- aspect = lib.mkOption {
- description = "Aspect that configures this home.";
- type = lib.types.raw; # no merging
- defaultText = "den.aspects.";
- default = lookupAspect den config;
- };
- description = strOpt "home description" "home.${config.name}@${config.system}";
- pkgs = lib.mkOption {
- description = ''
- nixpkgs instance used to build the home configuration.
- '';
- example = lib.literalExpression ''inputs.nixpkgs.legacyPackages.''${home.system}'';
- type = lib.types.raw;
- defaultText = lib.literalExpression ''inputs.nixpkgs.legacyPackages.''${home.system}'';
- default = inputs.nixpkgs.legacyPackages.${config.system};
- };
- instantiate = lib.mkOption {
- description = ''
- Function used to instantiate the home configuration.
+ homeManagerConfiguration =
+ if nameWithHost && hostByName != null then
+ { pkgs, modules }:
+ inputs.home-manager.lib.homeManagerConfiguration {
+ inherit pkgs modules;
+ extraSpecialArgs.osConfig = lib.attrByPath (
+ [ "flake" ] ++ hostByName.intoAttr ++ [ "config" ]
+ ) null top.config;
+ }
+ else
+ inputs.home-manager.lib.homeManagerConfiguration;
+ in
+ {
+ # mkInstanceType defaults name to the registry key (e.g. "tux@igloo");
+ # den's name is the bare user name, so identity/description stay stable.
+ config.name = lib.mkForce userName;
+ config._module.args.host = hostCtx;
+ config._module.args.user = userByName;
+ options = {
+
+ userName = strOpt "user account name" userName;
+ hostName = lib.mkOption {
+ type = lib.types.nullOr lib.types.str;
+ default = hostName;
+ description = "host name (null for unbound standalone homes)";
+ };
+ user = lib.mkOption {
+ default = userByName;
+ defaultText = lib.literalExpression "user";
+ };
+ host = lib.mkOption {
+ default = hostCtx;
+ defaultText = lib.literalExpression "host";
+ };
+ system = strOpt "platform system" system;
+ class = strOpt "home management nix class" "homeManager";
+ aspect = lib.mkOption {
+ description = "Aspect that configures this home.";
+ type = lib.types.raw; # no merging
+ defaultText = "den.aspects.";
+ default = lookupAspect den config;
+ };
+ description = strOpt "home description" "home.${config.name}@${config.system}";
+ pkgs = lib.mkOption {
+ description = ''
+ nixpkgs instance used to build the home configuration.
+ '';
+ example = lib.literalExpression ''inputs.nixpkgs.legacyPackages.''${home.system}'';
+ type = lib.types.raw;
+ defaultText = lib.literalExpression ''inputs.nixpkgs.legacyPackages.''${home.system}'';
+ default = inputs.nixpkgs.legacyPackages.${config.system};
+ };
+ instantiate = lib.mkOption {
+ description = ''
+ Function used to instantiate the home configuration.
- Depending on class, defaults to:
- `homeManager`: inputs.home-manager.lib.homeManagerConfiguration
+ Depending on class, defaults to:
+ `homeManager`: inputs.home-manager.lib.homeManagerConfiguration
- Set explicitly if you need:
+ Set explicitly if you need:
- - a custom input name, eg, home-manager-unstable.
- - adding extraSpecialArgs when absolutely required.
- '';
- example = lib.literalExpression "inputs.home-manager.lib.homeManagerConfiguration";
- type = lib.types.raw;
- defaultText = lib.literalExpression "inputs.home-manager.lib.homeManagerConfiguration";
- default =
- {
- homeManager = homeManagerConfiguration;
- }
- .${config.class};
- };
- intoAttr = lib.mkOption {
- description = ''
- Flake attr where to add the named result of this configuration.
- flake..
+ - a custom input name, eg, home-manager-unstable.
+ - adding extraSpecialArgs when absolutely required.
+ '';
+ example = lib.literalExpression "inputs.home-manager.lib.homeManagerConfiguration";
+ type = lib.types.raw;
+ defaultText = lib.literalExpression "inputs.home-manager.lib.homeManagerConfiguration";
+ default =
+ {
+ homeManager = homeManagerConfiguration;
+ }
+ .${config.class};
+ };
+ intoAttr = lib.mkOption {
+ description = ''
+ Flake attr where to add the named result of this configuration.
+ flake..
- Depending on class, defaults to:
- `homeManager`: homeConfigurations
- '';
- example = lib.literalExpression ''[ "homeConfigurations" userName ]'';
- type = lib.types.listOf lib.types.str;
- defaultText = lib.literalExpression ''[ "homeConfigurations" userName ]'';
- default =
- {
- homeManager = [
- "homeConfigurations"
- name
- ];
- }
- .${config.class};
- };
- mainModule = mainModuleOption den config;
- __resolveResult = resolveResultOption den config;
- __pathSetByScope = pathSetByScopeOption den config;
- };
- }
- );
+ Depending on class, defaults to:
+ `homeManager`: homeConfigurations
+ '';
+ example = lib.literalExpression ''[ "homeConfigurations" userName ]'';
+ type = lib.types.listOf lib.types.str;
+ defaultText = lib.literalExpression ''[ "homeConfigurations" userName ]'';
+ default =
+ {
+ homeManager = [
+ "homeConfigurations"
+ name
+ ];
+ }
+ .${config.class};
+ };
+ mainModule = mainModuleOption den config;
+ __resolveResult = resolveResultOption den config;
+ __pathSetByScope = pathSetByScopeOption den "home" config;
+ };
+ }
+ )
+ ];
+ };
in
{
inherit homesOption;
diff --git a/nix/lib/entities/host.nix b/nix/lib/entities/host.nix
index cca8644cc..8d76d1df7 100644
--- a/nix/lib/entities/host.nix
+++ b/nix/lib/entities/host.nix
@@ -6,19 +6,41 @@
...
}:
let
- inherit (import ./_types.nix { inherit lib; })
+ inherit (import ./_types.nix { inherit lib den; })
strOpt
lookupAspect
+ deepMergeAttrs
mainModuleOption
resolveResultOption
pathSetByScopeOption
+ resolvedCtxModule
+ preprocessHosts
;
+ # Entity instances are gen-schema instances: mkInstanceType injects name,
+ # strict/freeform, _module.args., and schema-owned id_hash (identity).
+ schemaLib = den.lib.schema;
+
+ innerType = lib.types.attrsOf systemType;
+
hostsOption = lib.mkOption {
description = "den hosts definition";
default = { };
defaultText = lib.literalExpression "{ }";
- type = lib.types.attrsOf systemType;
+ type = lib.types.attrsOf (lib.types.submodule { freeformType = deepMergeAttrs; });
+ apply =
+ raw:
+ let
+ normalized = preprocessHosts raw;
+ in
+ innerType.merge
+ [ "den" "hosts" ]
+ [
+ {
+ file = "";
+ value = normalized;
+ }
+ ];
};
systemType = lib.types.submodule (
@@ -30,125 +52,129 @@ let
hostType =
system:
- lib.types.submodule (
- { name, config, ... }:
- {
- freeformType = lib.types.attrsOf lib.types.anything;
- imports = [ den.schema.host ];
- config._module.args.host = config;
- options = {
- name = strOpt "host configuration name" name;
- hostName = strOpt "Network hostname" config.name;
- system = strOpt "platform system" system;
- class = strOpt "os-configuration nix class for host" (
- if lib.hasSuffix "darwin" config.system then "darwin" else "nixos"
- );
- aspect = lib.mkOption {
- description = "Aspect that configures this host.";
- type = lib.types.raw; # no merging
- defaultText = "den.aspects.";
- default = lookupAspect den config;
- };
- description = strOpt "host description" "${config.class}.${config.hostName}@${config.system}";
- users = lib.mkOption {
- description = "user accounts";
- default = { };
- defaultText = lib.literalExpression "{ }";
- type = lib.types.attrsOf (userType config);
- };
- instantiate = lib.mkOption {
- description = ''
- Function used to instantiate the OS configuration.
+ schemaLib.mkInstanceType den.schema.host {
+ strict = false;
+ extraModules = [
+ (resolvedCtxModule "host")
+ (
+ { config, ... }:
+ {
+ options = {
+ hostName = strOpt "Network hostname" config.name;
+ system = strOpt "platform system" system;
+ class = strOpt "os-configuration nix class for host" (
+ if lib.hasSuffix "darwin" config.system then "darwin" else "nixos"
+ );
+ aspect = lib.mkOption {
+ description = "Aspect that configures this host.";
+ type = lib.types.raw; # no merging
+ defaultText = "den.aspects.";
+ default = lookupAspect den config;
+ };
+ description = strOpt "host description" "${config.class}.${config.hostName}@${config.system}";
+ users = lib.mkOption {
+ description = "user accounts";
+ default = { };
+ defaultText = lib.literalExpression "{ }";
+ type = lib.types.attrsOf (userType config);
+ };
+ instantiate = lib.mkOption {
+ description = ''
+ Function used to instantiate the OS configuration.
- Depending on class, defaults to:
- `darwin`: inputs.darwin.lib.darwinSystem
- `nixos`: inputs.nixpkgs.lib.nixosSystem
- `systemManager`: inputs.system-manager.lib.makeSystemConfig
+ Depending on class, defaults to:
+ `darwin`: inputs.darwin.lib.darwinSystem
+ `nixos`: inputs.nixpkgs.lib.nixosSystem
+ `systemManager`: inputs.system-manager.lib.makeSystemConfig
- Set explicitly if you need:
+ Set explicitly if you need:
- - a custom input name, eg, nixos-unstable.
- - adding specialArgs when absolutely required.
- '';
- example = lib.literalExpression "inputs.nixpkgs.lib.nixosSystem";
- type = lib.types.raw;
- defaultText = lib.literalExpression "inputs.nixpkgs.lib.nixosSystem";
- default =
- {
- nixos = inputs.nixpkgs.lib.nixosSystem;
- darwin = inputs.darwin.lib.darwinSystem;
- systemManager = inputs.system-manager.lib.makeSystemConfig;
- }
- .${config.class};
- };
- intoAttr = lib.mkOption {
- description = ''
- Flake attr where to add the named result of this configuration.
- flake..
+ - a custom input name, eg, nixos-unstable.
+ - adding specialArgs when absolutely required.
+ '';
+ example = lib.literalExpression "inputs.nixpkgs.lib.nixosSystem";
+ type = lib.types.raw;
+ defaultText = lib.literalExpression "inputs.nixpkgs.lib.nixosSystem";
+ default =
+ {
+ nixos = inputs.nixpkgs.lib.nixosSystem;
+ darwin = inputs.darwin.lib.darwinSystem;
+ systemManager = inputs.system-manager.lib.makeSystemConfig;
+ }
+ .${config.class};
+ };
+ intoAttr = lib.mkOption {
+ description = ''
+ Flake attr where to add the named result of this configuration.
+ flake..
- Depending on class, defaults to:
- `darwin`: darwinConfigurations
- `nixos`: nixosConfigurations
- `systemManager`: systemConfigs
- '';
- example = lib.literalExpression ''[ "nixosConfigurations" hostName ]'';
- type = lib.types.listOf lib.types.str;
- defaultText = lib.literalExpression ''[ "nixosConfigurations" hostName ]'';
- default =
- {
- nixos = [
- "nixosConfigurations"
- config.name
- ];
- darwin = [
- "darwinConfigurations"
- config.name
- ];
- systemManager = [
- "systemConfigs"
- config.name
- ];
- }
- .${config.class};
- };
- mainModule = mainModuleOption den config;
- __resolveResult = resolveResultOption den config;
- __pathSetByScope = pathSetByScopeOption den config;
- };
- }
- );
+ Depending on class, defaults to:
+ `darwin`: darwinConfigurations
+ `nixos`: nixosConfigurations
+ `systemManager`: systemConfigs
+ '';
+ example = lib.literalExpression ''[ "nixosConfigurations" hostName ]'';
+ type = lib.types.listOf lib.types.str;
+ defaultText = lib.literalExpression ''[ "nixosConfigurations" hostName ]'';
+ default =
+ {
+ nixos = [
+ "nixosConfigurations"
+ config.name
+ ];
+ darwin = [
+ "darwinConfigurations"
+ config.name
+ ];
+ systemManager = [
+ "systemConfigs"
+ config.name
+ ];
+ }
+ .${config.class};
+ };
+ mainModule = mainModuleOption den config;
+ __resolveResult = resolveResultOption den config;
+ __pathSetByScope = pathSetByScopeOption den "host" config;
+ };
+ }
+ )
+ ];
+ };
userType =
host:
- lib.types.submodule (
- { name, config, ... }:
- {
- freeformType = lib.types.attrsOf lib.types.anything;
- imports = [ den.schema.user ];
- config._module.args.host = host;
- config._module.args.user = config;
- options = {
- name = strOpt "user configuration name" name;
- userName = strOpt "user account name" name;
- classes = lib.mkOption {
- type = lib.types.listOf lib.types.str;
- description = "home management nix classes";
- defaultText = lib.literalExpression ''[ "user" ]'';
- default = [ "user" ];
- };
- aspect = lib.mkOption {
- description = "Aspect that configures this user.";
- type = lib.types.raw; # no merging
- defaultText = "den.aspects.";
- default = lookupAspect den config;
- };
- host = lib.mkOption {
- default = host;
- defaultText = lib.literalExpression "host";
- };
- };
- }
- );
+ schemaLib.mkInstanceType den.schema.user {
+ strict = false;
+ extraModules = [
+ (resolvedCtxModule "user")
+ (
+ { config, ... }:
+ {
+ config._module.args.host = host;
+ options = {
+ userName = strOpt "user account name" config.name;
+ classes = lib.mkOption {
+ type = lib.types.listOf lib.types.str;
+ description = "home management nix classes";
+ defaultText = lib.literalExpression ''[ "user" ]'';
+ default = [ "user" ];
+ };
+ aspect = lib.mkOption {
+ description = "Aspect that configures this user.";
+ type = lib.types.raw; # no merging
+ defaultText = "den.aspects.";
+ default = lookupAspect den config;
+ };
+ host = lib.mkOption {
+ default = host;
+ defaultText = lib.literalExpression "host";
+ };
+ };
+ }
+ )
+ ];
+ };
in
{
inherit hostsOption;
diff --git a/nix/lib/policy-effects.nix b/nix/lib/policy-effects.nix
index 023d36cde..952527be5 100644
--- a/nix/lib/policy-effects.nix
+++ b/nix/lib/policy-effects.nix
@@ -1,6 +1,6 @@
# Typed policy effect constructors.
# Policies return lists of these; the pipeline dispatches on __policyEffect.
-{ ... }:
+{ lib, ... }:
let
# Coerce a value into an inner policy record for use in `for` / `when`.
# Accepts: policies (__isPolicy), effect descriptors (__policyEffect),
@@ -32,8 +32,95 @@ let
name = "";
fn = p;
};
+
+ # ===== deliver — the user-facing delivery-edge constructor ============
+ # `deliver` is the public primitive over the edge layer (spec §4): it declares
+ # ONE delivery edge `(S, T, P, M)` and nothing else. `route` and `provides`
+ # (the attrset entries below) are PERMANENT shims that desugar onto it, so
+ # `deliver` lives in this `let` block where both can reference it.
+ #
+ # deliver {
+ # from; # the source S:
+ # # - a class name (string) → collect that class's bucket
+ # # and move it (the route case);
+ # # - { module = ; } → inject a NEW module that
+ # # did not come from the walk (the provides case).
+ # to; # target class T (the instantiation-root class).
+ # at ? [ ]; # attrpath P inside T's config ([] = merge at root).
+ # mode ? "merge"; # M, explicit and exhaustive: "merge" | "nest" | "verbatim".
+ # guard ? null; # optional conditional gate (class-source only).
+ # adaptArgs ? null;# optional specialArgs adapter (class-source only).
+ # }
+ #
+ # Surface rules (spec §4, binding):
+ # - `mode = "verbatim"` is EXPLICIT — there is no `reinstantiate` flag on
+ # `deliver` and no isolation-derived verbatim magic (rejected by the spec).
+ # The `route` shim maps its legacy `reinstantiate = true` to `mode =
+ # "verbatim"` so existing users keep working without touching `deliver`.
+ # - `appendToParent` is NOT accepted here — `to` already names the target;
+ # parent-targeting is constructor-internal (T = parent root, fixed at
+ # construction) and reachable only through the `route` shim for compat.
+ #
+ # `deliver` produces the same effect descriptor the edge constructors already
+ # consume (`route` / `provide`), so the materializer never learns a new
+ # mechanism name (spec §2 invariant). Shims thread their mechanism-only fields
+ # through the internal `__extra` escape hatch — never part of the user surface.
+ deliver =
+ {
+ from,
+ to,
+ at ? [ ],
+ mode ? "merge",
+ guard ? null,
+ adaptArgs ? null,
+ __extra ? { },
+ }:
+ let
+ validModes = {
+ merge = true;
+ nest = true;
+ verbatim = true;
+ };
+ isModuleSource = builtins.isAttrs from && from ? module;
+ in
+ if !(validModes ? ${mode}) then
+ throw "den: deliver: mode must be one of merge|nest|verbatim, got '${toString mode}'"
+ else if isModuleSource then
+ # Module source S = the provided module — a `provide` edge (nest ∘ merge,
+ # §B Decision 1). `verbatim` is meaningless for injected content (there is
+ # no collected wrapper to keep by reference) — reject it explicitly.
+ if mode == "verbatim" then
+ throw "den: deliver: mode \"verbatim\" applies to class sources (collected modules), not module sources"
+ else
+ {
+ __policyEffect = "provide";
+ value = {
+ class = to;
+ inherit (from) module;
+ path = at;
+ }
+ // __extra;
+ }
+ else
+ # Class source S = collected(from) — a `route` edge. mode → reinstantiate
+ # (verbatim) is the ONLY mode→flag translation; nest/merge are derived by
+ # the route classifier from path, matching the legacy route shape.
+ {
+ __policyEffect = "route";
+ value = {
+ fromClass = from;
+ intoClass = to;
+ path = at;
+ reinstantiate = mode == "verbatim";
+ }
+ // lib.optionalAttrs (guard != null) { inherit guard; }
+ // lib.optionalAttrs (adaptArgs != null) { inherit adaptArgs; }
+ // __extra;
+ };
in
{
+ inherit deliver;
+
# Create a new context scope (fan-out). Each resolve creates a parallel
# branch — a sibling context with new bindings merged into parent.
# policy.resolve {} (empty bindings) is a no-op.
@@ -100,6 +187,16 @@ in
# Route class or quirk content from one scope partition into a target class.
# Tier 1 delivery — replaces den.batteries.forward for the common case.
#
+ # SHIM over `deliver` (spec §4): `route` is PERMANENT user-API sugar and may
+ # live on indefinitely. It desugars its full legacy signature onto a single
+ # `deliver` edge: `fromClass`→`from`, `intoClass`→`to`, `path`/`intoPath`→`at`,
+ # and `reinstantiate = true`→`mode = "verbatim"`. The mechanism-only fields
+ # (`collectSubtree`, `appendToParent`, `instantiate`, `adapterKey`,
+ # `adapterModule`, …) are route-internal and ride through `deliver`'s `__extra`
+ # escape hatch — they are NOT part of the `deliver` surface.
+ #
+ # TODO: add deprecation warning before any future removal.
+ #
# `intoPath` is the public target-path name — it pairs with `intoClass` and
# `fromClass`, matching the forward API. `path` is kept as a back-compat
# alias; both normalize to the internal `path` key the route handler reads.
@@ -109,15 +206,36 @@ in
spec:
let
path = spec.intoPath or spec.path or [ ];
+ reinstantiate = spec.reinstantiate or false;
+ # Everything that is not a clean `deliver` field is a route-internal
+ # mechanism field threaded verbatim through __extra.
+ extra = builtins.removeAttrs spec [
+ "fromClass"
+ "intoClass"
+ "intoPath"
+ "path"
+ "reinstantiate"
+ "guard"
+ "adaptArgs"
+ ];
in
if (spec ? intoPath) && (spec ? path) then
throw "den: policy.route: pass either `intoPath` or `path`, not both"
else
- {
- __policyEffect = "route";
- value = builtins.removeAttrs spec [ "intoPath" ] // {
- inherit path;
- };
+ deliver {
+ from = spec.fromClass or "?";
+ to = spec.intoClass or "?";
+ at = path;
+ mode =
+ if reinstantiate then
+ "verbatim"
+ else if path == [ ] then
+ "merge"
+ else
+ "nest";
+ guard = spec.guard or null;
+ adaptArgs = spec.adaptArgs or null;
+ __extra = extra;
};
# Request post-pipeline instantiation of an entity's class content.
@@ -131,10 +249,32 @@ in
# Unlike route (which moves existing pipeline content), provide injects
# new content that didn't come from the pipeline walk.
# spec: { class, module, path? }
- provide = spec: {
- __policyEffect = "provide";
- value = spec;
- };
+ #
+ # SHIM over `deliver` (spec §4): `provide` is PERMANENT user-API sugar. It
+ # desugars onto a module-source `deliver` edge: `class`→`to`, `module`→
+ # `from.module`, `path`→`at` (P=[] degenerates to a plain merge contribution,
+ # P≠[] is the `nest ∘ merge` decomposition). Any extra spec fields ride
+ # through `__extra`.
+ #
+ # TODO: add deprecation warning before any future removal.
+ provide =
+ spec:
+ let
+ extra = builtins.removeAttrs spec [
+ "class"
+ "module"
+ "path"
+ ];
+ in
+ deliver {
+ from = {
+ inherit (spec) module;
+ };
+ to = spec.class;
+ at = spec.path or [ ];
+ mode = if (spec.path or [ ]) == [ ] then "merge" else "nest";
+ __extra = extra;
+ };
# Request a deferred node spawn. Records a marker resolved post-walk
# over the parent pipeline's full scope-tree state (host + siblings), so the
diff --git a/nix/lib/schema-util.nix b/nix/lib/schema-util.nix
index f6d01d7f5..60c80f6d9 100644
--- a/nix/lib/schema-util.nix
+++ b/nix/lib/schema-util.nix
@@ -4,20 +4,20 @@
...
}:
let
- schemaNames = builtins.attrNames (den.schema or { });
+ # Canonical kind list from gen-schema introspection: _kindNames is sorted
+ # and already excludes _-prefixed introspection keys (_topology, _edges, …).
+ kindNames = den.schema._kindNames or [ ];
- # Canonical entity kind predicate: excludes conf, private keys,
- # and non-entity schema entries.
+ # Canonical entity kind predicate: excludes the shared `conf` base and
+ # non-entity schema entries (isEntity computed by gen-schema).
schemaEntityKinds = builtins.filter (
- k: k != "conf" && !(lib.hasPrefix "_" k) && (den.schema.${k}.isEntity or false)
- ) schemaNames;
+ k: k != "conf" && (den.schema.${k}.isEntity or false)
+ ) kindNames;
# Variant for class-module.nix warnings: all schema-like arg names
- # (excludes conf, aspect, private keys) WITHOUT the isEntity check.
+ # (excludes conf, aspect) WITHOUT the isEntity check.
# Used to detect missing den args in class module functions.
- schemaArgKinds = builtins.filter (
- k: k != "conf" && k != "aspect" && !(lib.hasPrefix "_" k)
- ) schemaNames;
+ schemaArgKinds = builtins.filter (k: k != "conf" && k != "aspect") kindNames;
schemaEntityKindsSet = lib.genAttrs schemaEntityKinds (_: true);
in
{
diff --git a/nix/lib/schema.nix b/nix/lib/schema.nix
new file mode 100644
index 000000000..862ce1edf
--- /dev/null
+++ b/nix/lib/schema.nix
@@ -0,0 +1,13 @@
+# Resolve gen-schema the same way fx.nix resolves nix-effects: prefer the
+# consumer-provided flake input, fall back to the rev pinned in the CI lock so
+# den evaluates without forcing every consumer to declare the input.
+{ inputs, lib, ... }:
+let
+ lock = builtins.fromJSON (builtins.readFile ../../templates/ci/flake.lock);
+ locked = lock.nodes.gen-schema.locked;
+ gen-schema = builtins.fetchTarball {
+ url = "https://github.com/${locked.owner}/${locked.repo}/archive/${locked.rev}.zip";
+ sha256 = locked.narHash;
+ };
+in
+inputs.gen-schema.lib or (import gen-schema { inherit lib; })
diff --git a/templates/ci/flake.lock b/templates/ci/flake.lock
index 92708f12c..60f193bf0 100644
--- a/templates/ci/flake.lock
+++ b/templates/ci/flake.lock
@@ -7,11 +7,11 @@
]
},
"locked": {
- "lastModified": 1775037210,
- "narHash": "sha256-KM2WYj6EA7M/FVZVCl3rqWY+TFV5QzSyyGE2gQxeODU=",
+ "lastModified": 1779036909,
+ "narHash": "sha256-zXcwYQGCT6pzinK+1dBB2ekTVtfxGZAapb3Evdcu4fY=",
"owner": "nix-darwin",
"repo": "nix-darwin",
- "rev": "06648f4902343228ce2de79f291dd5a58ee12146",
+ "rev": "56c666e108467d87d13508936aade6d567f2a501",
"type": "github"
},
"original": {
@@ -22,11 +22,11 @@
},
"den": {
"locked": {
- "lastModified": 1776710169,
- "narHash": "sha256-q4WXIX2E3w9Ld3MZ1Pl8Lh5SgrEFdEuzvY1Lj/Wo2kY=",
+ "lastModified": 1779919306,
+ "narHash": "sha256-eo2Snm2hoYAq8MA7bfjnn8p36riLcpJDA74Fzx9gcsY=",
"owner": "denful",
"repo": "den",
- "rev": "0af82e24be89b9fd400bd0b58b0fed5ea0f269ad",
+ "rev": "fba67817bd16955e10ff158c9758874031af089c",
"type": "github"
},
"original": {
@@ -35,24 +35,44 @@
"type": "github"
}
},
- "flake-parts": {
+ "gen-schema": {
"inputs": {
- "nixpkgs-lib": [
- "nix-unit",
+ "nixpkgs": [
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1779986641,
+ "narHash": "sha256-KcZuS+hpaloICFcepNXNLpbehh6XoPjWPBteYpTqMRw=",
+ "owner": "sini",
+ "repo": "gen-schema",
+ "rev": "4bd0f6eb1799bf3c38eb3707419157b1f70eb1f5",
+ "type": "github"
+ },
+ "original": {
+ "owner": "sini",
+ "repo": "gen-schema",
+ "type": "github"
+ }
+ },
+ "gen-schema_2": {
+ "inputs": {
+ "nixpkgs": [
+ "provider",
"nixpkgs"
]
},
"locked": {
- "lastModified": 1762440070,
- "narHash": "sha256-xxdepIcb39UJ94+YydGP221rjnpkDZUlykKuF54PsqI=",
- "owner": "hercules-ci",
- "repo": "flake-parts",
- "rev": "26d05891e14c88eb4a5d5bee659c0db5afb609d8",
+ "lastModified": 1779986641,
+ "narHash": "sha256-KcZuS+hpaloICFcepNXNLpbehh6XoPjWPBteYpTqMRw=",
+ "owner": "sini",
+ "repo": "gen-schema",
+ "rev": "4bd0f6eb1799bf3c38eb3707419157b1f70eb1f5",
"type": "github"
},
"original": {
- "owner": "hercules-ci",
- "repo": "flake-parts",
+ "owner": "sini",
+ "repo": "gen-schema",
"type": "github"
}
},
@@ -63,11 +83,11 @@
]
},
"locked": {
- "lastModified": 1776964438,
- "narHash": "sha256-AF0cby9Xuijr5qaFpYKbm1mExV956Hk233bel6QxpFw=",
+ "lastModified": 1779969295,
+ "narHash": "sha256-HwIJ3tOcwSMiV75L7KqJXciXR9UfT+d7rwOZMX7cTnA=",
"owner": "nix-community",
"repo": "home-manager",
- "rev": "e09259dd2e147d35ef889784b51e89b0a10ffe15",
+ "rev": "61e2c9659324181e0f0ed911958c536333b1d4f6",
"type": "github"
},
"original": {
@@ -78,11 +98,11 @@
},
"import-tree": {
"locked": {
- "lastModified": 1773693634,
- "narHash": "sha256-BtZ2dtkBdSUnFPPFc+n0kcMbgaTxzFNPv2iaO326Ffg=",
+ "lastModified": 1778781969,
+ "narHash": "sha256-Jjuz5CmSkur8KvLDoGa+vylEp+RkQtv4mt/qcMznpH0=",
"owner": "vic",
"repo": "import-tree",
- "rev": "c41e7d58045f9057880b0d85e1152d6a4430dbf1",
+ "rev": "d321337efd0f23a9eb14a42adb7b2c29313ab274",
"type": "github"
},
"original": {
@@ -110,7 +130,6 @@
},
"original": {
"owner": "denful",
- "ref": "den",
"repo": "nix-effects",
"type": "github"
}
@@ -138,7 +157,6 @@
},
"nix-unit": {
"inputs": {
- "flake-parts": "flake-parts",
"nix-github-actions": "nix-github-actions",
"nixpkgs": [
"nixpkgs"
@@ -146,11 +164,11 @@
"treefmt-nix": "treefmt-nix"
},
"locked": {
- "lastModified": 1762774186,
- "narHash": "sha256-hRADkHjNt41+JUHw2EiSkMaL4owL83g5ZppjYUdF/Dc=",
+ "lastModified": 1779338171,
+ "narHash": "sha256-affUbv/bwE8SLGhuWKniDr7SVO+Lo1XEPjCZdyU5kgQ=",
"owner": "nix-community",
"repo": "nix-unit",
- "rev": "1c9ab50554eed0b768f9e5b6f646d63c9673f0f7",
+ "rev": "6ab1f232562a01d18b40d5ed6a58718c4f3a74bc",
"type": "github"
},
"original": {
@@ -161,11 +179,11 @@
},
"nixpkgs": {
"locked": {
- "lastModified": 1776548001,
- "narHash": "sha256-qH3mBrZnNsPdwpAgvG2Olgzsp5kt+Sibpm1tx1pxkcQ=",
- "rev": "b12141ef619e0a9c1c84dc8c684040326f27cdcc",
+ "lastModified": 1779560665,
+ "narHash": "sha256-NpH8iEQ5JHv/BtUuzTEXUMDxPLetCDzIv4OxL8H7Kps=",
+ "rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
"type": "tarball",
- "url": "https://releases.nixos.org/nixos/unstable/nixos-26.05pre982522.b12141ef619e/nixexprs.tar.xz?lastModified=1776548001&rev=b12141ef619e0a9c1c84dc8c684040326f27cdcc"
+ "url": "https://releases.nixos.org/nixos/unstable/nixos-26.05pre1004030.64c08a7ca051/nixexprs.tar.xz?lastModified=1779560665&rev=64c08a7ca051951c8eae34e3e3cb1e202fe36786"
},
"original": {
"type": "tarball",
@@ -177,6 +195,7 @@
"den": [
"den"
],
+ "gen-schema": "gen-schema_2",
"import-tree": [
"import-tree"
],
@@ -185,19 +204,21 @@
]
},
"locked": {
+ "lastModified": 1,
+ "narHash": "sha256-Hqizev6Ij0Q4O7Xpm6TDhblKxoY2et2BxRc67Gklff4=",
"path": "./provider",
"type": "path"
},
"original": {
"path": "./provider",
"type": "path"
- },
- "parent": []
+ }
},
"root": {
"inputs": {
"darwin": "darwin",
"den": "den",
+ "gen-schema": "gen-schema",
"home-manager": "home-manager",
"import-tree": "import-tree",
"nix-effects": "nix-effects",
@@ -214,11 +235,11 @@
]
},
"locked": {
- "lastModified": 1762410071,
- "narHash": "sha256-aF5fvoZeoXNPxT0bejFUBXeUjXfHLSL7g+mjR/p5TEg=",
+ "lastModified": 1775636079,
+ "narHash": "sha256-pc20NRoMdiar8oPQceQT47UUZMBTiMdUuWrYu2obUP0=",
"owner": "numtide",
"repo": "treefmt-nix",
- "rev": "97a30861b13c3731a84e09405414398fbf3e109f",
+ "rev": "790751ff7fd3801feeaf96d7dc416a8d581265ba",
"type": "github"
},
"original": {
diff --git a/templates/ci/flake.nix b/templates/ci/flake.nix
index 8df911244..608d35dff 100644
--- a/templates/ci/flake.nix
+++ b/templates/ci/flake.nix
@@ -29,5 +29,8 @@
nix-effects.url = "github:denful/nix-effects/den";
nix-effects.inputs.nixpkgs.follows = "nixpkgs";
nix-effects.inputs.nix-unit.follows = "nix-unit";
+
+ gen-schema.url = "github:sini/gen-schema";
+ gen-schema.inputs.nixpkgs.follows = "nixpkgs";
};
}
diff --git a/templates/ci/modules/deadbugs/bprime-basedrain-crosshost.nix b/templates/ci/modules/deadbugs/bprime-basedrain-crosshost.nix
new file mode 100644
index 000000000..0ebaee09d
--- /dev/null
+++ b/templates/ci/modules/deadbugs/bprime-basedrain-crosshost.nix
@@ -0,0 +1,158 @@
+# B′ cross-host peer-config divergence witness (§A #8/#2/#7).
+#
+# `hostConfigs` (re-entry B′, resolve.nix) builds each peer host's full nixos
+# config for cross-host config-dependent pipe-thunk resolution (assemble-pipes
+# resolveEntry reads `config = hostConfigs.${sourceScopeId}`). Pre-fix B′ built
+# those configs over the RAW scopeContexts (§A #8) and RAW (undrained) class
+# imports (§A #2/#7) — so a peer whose config CONSUMES a pipe value (or
+# depends on a deferred include) diverged from its real instantiate output
+# (variant B): the pipe value was never injected / the include never drained, so
+# the peer config threw `attribute 'feat' missing` instead of resolving.
+#
+# Witness shape (the §A probe template, minimised to the natural host topology):
+# - a pipe-consuming peer aspect `needs-feat.nixos = { feat, ... }:
+# { networking.domain = builtins.head feat; }` — reads the `feat` quirk value
+# from its context. `feat` is a host-scope pipe (pipe.for → [ host.name ]),
+# resolved by assemblePipes, NOT present in raw scopeContexts;
+# - a cross-host `collectAll` config-thunk `host-marks = { config, ... }:
+# [ "d-${config.networking.domain}" ]` reading each peer's config — this is
+# what forces hostConfigs (B′) to build the peer configs.
+#
+# Only igloo COLLECTS (asymmetric on purpose): a mutual collectAll (both hosts
+# reading each other's config) is a genuine inter-config cycle no pass can break.
+# Both hosts EMIT a host-marks value, so igloo's collectAll builds BOTH configs.
+#
+# Before the fix: igloo's collectAll reads iceberg's `config.networking.domain`
+# via hostConfigs.${iceberg} (B′), built over raw contexts → `feat` unbound →
+# `feat missing`. After the fix (B′ over augmentedScopeContextsNoCfg +
+# drainedForHostConfigs): the peer config matches variant B and resolves.
+#
+# Control: a cross-host thunk reading a NON-pipe config field (hostName, set from
+# the host record) resolves under B′ regardless — isolating the pipe/deferred
+# injection as the cause.
+{ denTest, lib, ... }:
+{
+ flake.tests.bprime-basedrain-crosshost = {
+
+ # Defect case: cross-host config-thunk reads a PIPE-derived config field.
+ # Pre-fix this threw `feat missing`; after, it resolves to the peer's value.
+ test-crosshost-thunk-reads-pipe-config = denTest (
+ {
+ den,
+ igloo,
+ lib,
+ ...
+ }:
+ let
+ inherit (den.lib.policy) pipe;
+ in
+ {
+ den.quirks.feat.description = "A host-scope pipe (scalar feature value).";
+ den.quirks.host-marks.description = "Cross-host config-derived marks.";
+
+ # Every host emits its own `feat` pipe value; only igloo collects marks.
+ den.policies.emit-feat = { host, ... }: [ (pipe.from "feat" [ (pipe.for (_: [ host.name ])) ]) ];
+ den.policies.collect-marks = _: [
+ (pipe.from "host-marks" [ (pipe.collectAll ({ host, ... }: true)) ])
+ ];
+ den.schema.host.includes = [ den.policies.emit-feat ];
+
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.hosts.x86_64-linux.iceberg.users.alice = { };
+
+ # The pipe-CONSUMING peer aspect: reads `feat` from context (resolved by
+ # assemblePipes, absent from raw scopeContexts). Sets a config field FROM
+ # the pipe value — so a peer's config.networking.domain depends on it.
+ den.aspects.needs-feat.nixos =
+ { feat, ... }:
+ {
+ networking.domain = builtins.head feat;
+ };
+ den.aspects.igloo.includes = [
+ den.aspects.needs-feat
+ den.policies.collect-marks
+ ];
+ den.aspects.iceberg.includes = [ den.aspects.needs-feat ];
+
+ # Each host EMITS a host-marks value = a config-thunk reading its OWN
+ # pipe-derived domain. igloo's collectAll gathers them, forcing
+ # hostConfigs (B′) to build each peer's config (where the defect lived).
+ den.aspects.igloo.host-marks = { config, ... }: [ "d-${config.networking.domain}" ];
+ den.aspects.iceberg.host-marks = { config, ... }: [ "d-${config.networking.domain}" ];
+
+ # Consume the collected marks into igloo's config so the test reads them.
+ den.aspects.igloo.nixos =
+ { host-marks, lib, ... }:
+ {
+ networking.search = lib.sort (a: b: a < b) host-marks;
+ };
+
+ expr = {
+ # igloo's own pipe-derived domain = its feat = "igloo".
+ domain = igloo.networking.domain;
+ # Cross-host collectAll sees BOTH peers' pipe-derived domains via B′.
+ marks = igloo.networking.search;
+ };
+ expected = {
+ domain = "igloo";
+ marks = [
+ "d-iceberg"
+ "d-igloo"
+ ];
+ };
+ }
+ );
+
+ # Control: cross-host config-thunk reads a NON-pipe config field (hostName,
+ # set from the host record — present in raw contexts). Resolves under B′
+ # regardless of the augmented-context fix — confirms the pipe injection is
+ # the cause.
+ test-crosshost-thunk-reads-nonpipe-config = denTest (
+ {
+ den,
+ igloo,
+ lib,
+ ...
+ }:
+ let
+ inherit (den.lib.policy) pipe;
+ in
+ {
+ den.quirks.host-marks.description = "Cross-host config-derived marks.";
+
+ den.policies.collect-marks = _: [
+ (pipe.from "host-marks" [ (pipe.collectAll ({ host, ... }: true)) ])
+ ];
+
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.hosts.x86_64-linux.iceberg.users.alice = { };
+
+ # NON-pipe config field: hostName set from the host record (raw context).
+ den.aspects.set-hostname.nixos =
+ { host, ... }:
+ {
+ networking.hostName = host.name;
+ };
+ den.aspects.igloo.includes = [
+ den.aspects.set-hostname
+ den.policies.collect-marks
+ ];
+ den.aspects.iceberg.includes = [ den.aspects.set-hostname ];
+ den.aspects.igloo.host-marks = { config, ... }: [ "n-${config.networking.hostName}" ];
+ den.aspects.iceberg.host-marks = { config, ... }: [ "n-${config.networking.hostName}" ];
+
+ den.aspects.igloo.nixos =
+ { host-marks, lib, ... }:
+ {
+ networking.search = lib.sort (a: b: a < b) host-marks;
+ };
+
+ expr = igloo.networking.search;
+ expected = [
+ "n-iceberg"
+ "n-igloo"
+ ];
+ }
+ );
+ };
+}
diff --git a/templates/ci/modules/deadbugs/hasaspect-host-provides-to-users.nix b/templates/ci/modules/deadbugs/hasaspect-host-provides-to-users.nix
new file mode 100644
index 000000000..0d96bef58
--- /dev/null
+++ b/templates/ci/modules/deadbugs/hasaspect-host-provides-to-users.nix
@@ -0,0 +1,49 @@
+# Regression: a host's projected `host.hasAspect` must see aspects the host
+# delivers DOWN to its users via `provides.to-users` — checked from inside a
+# delivered home-manager aspect. Per the projected-hasAspect spec (#602), every
+# in-context binding answers membership at the ACTIVE (consuming) scope. The
+# id_hash re-key (e8876f3e) regressed this by keying each binding to its OWN
+# bucket, so `host.hasAspect` stopped seeing provides-to-user aspects (those
+# resolve under the consuming user scope). Fixed by keying all in-context
+# bindings to the active scope, per spec.
+#
+# Reported via github.com/tschan/den-hasaspect-bug.
+{ denTest, ... }:
+{
+ flake.tests.hasaspect-host-provides-to-users = {
+
+ test-host-sees-aspect-it-provides-to-users = denTest (
+ {
+ den,
+ lib,
+ igloo,
+ ...
+ }:
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+
+ den.aspects.test.nixos = { };
+
+ den.aspects.effect = {
+ homeManager =
+ { host, ... }:
+ {
+ home.username =
+ if host.hasAspect den.aspects.test then lib.mkForce "right" else lib.mkForce "wrong";
+ };
+ };
+
+ den.aspects.igloo = {
+ provides.to-users.includes = [
+ den.aspects.test
+ den.aspects.effect
+ ];
+ };
+
+ expr = igloo.home-manager.users.tux.home.username;
+ expected = "right";
+ }
+ );
+
+ };
+}
diff --git a/templates/ci/modules/deadbugs/issue-297-mutual-not-including-host-owned-and-included-statics.nix b/templates/ci/modules/deadbugs/issue-297-mutual-not-including-host-owned-and-included-statics.nix
index 8913ecfee..183f2a48c 100644
--- a/templates/ci/modules/deadbugs/issue-297-mutual-not-including-host-owned-and-included-statics.nix
+++ b/templates/ci/modules/deadbugs/issue-297-mutual-not-including-host-owned-and-included-statics.nix
@@ -100,11 +100,11 @@
{
den.hosts.x86_64-linux.igloo.users.tux.classes = [ "homeManager" ];
- # NOTE: Under policies, use perHost for host-only options
+ # NOTE: host-owned static; nixos-class content emits at the host scope.
den.aspects.igloo.includes = [
- (den.lib.perHost {
+ {
nixos.options.foo = lib.mkOption { default = "foo"; };
- })
+ }
];
expr = igloo.foo;
diff --git a/templates/ci/modules/deadbugs/issue-609-host-scope-hm-leak.nix b/templates/ci/modules/deadbugs/issue-609-host-scope-hm-leak.nix
new file mode 100644
index 000000000..573564798
--- /dev/null
+++ b/templates/ci/modules/deadbugs/issue-609-host-scope-hm-leak.nix
@@ -0,0 +1,136 @@
+# Acceptance for denful/den#609: a host-scope aspect must NOT leak homeManager
+# content into its users' HM evaluation. The formal rule (class-local emission):
+# a parametric aspect at scope S (entity kind K_S) destructuring entity-kind K_a
+# - K_a in ctx → bind once at S
+# - K_a descendant of S → fan out over S's K_a-children, EMIT AT S
+# - neither → misplaced → whole aspect inert, silently
+# Emission is always class-local to the EMITTING scope. The host scope resolves
+# nixos but NOT homeManager, so homeManager content in a host-scope aspect is
+# inert — it never reaches users. This is the #609 fix: homeManager must reach
+# users only via a to-users policy, never a bare host-scope include.
+{ denTest, ... }:
+{
+ flake.tests.issue-609 = {
+
+ # (a) { user, … } at host scope: user is a descendant of host → fan out.
+ # The nixos content lands per-user, merged on the host, once per user.
+ test-user-param-nixos-lands-on-host = denTest (
+ {
+ den,
+ igloo,
+ lib,
+ ...
+ }:
+ {
+ den.hosts.x86_64-linux.igloo.users = {
+ tux = { };
+ pingu = { };
+ };
+
+ den.aspects.igloo.nixos.options.funny = lib.mkOption {
+ default = [ ];
+ type = lib.types.listOf lib.types.str;
+ };
+
+ den.aspects.igloo.includes = [
+ (
+ { user, ... }:
+ {
+ nixos.funny = [ "nixos ${user.name}" ];
+ homeManager.home.sessionVariables.LEAK = "hm ${user.name}";
+ }
+ )
+ ];
+
+ expr = lib.sort lib.lessThan igloo.funny;
+ expected = [
+ "nixos pingu"
+ "nixos tux"
+ ];
+ }
+ );
+
+ # (b) Same aspect's homeManager content does NOT reach users' HM eval.
+ # The host scope resolves no homeManager class → the LEAK is inert.
+ test-user-param-hm-does-not-leak = denTest (
+ {
+ den,
+ tuxHm,
+ pinguHm,
+ ...
+ }:
+ {
+ den.hosts.x86_64-linux.igloo.users = {
+ tux = { };
+ pingu = { };
+ };
+
+ den.aspects.igloo.includes = [
+ (
+ { user, ... }:
+ {
+ homeManager.home.sessionVariables.LEAK = "hm ${user.name}";
+ }
+ )
+ ];
+
+ expr = [
+ (tuxHm.home.sessionVariables.LEAK or "MISSING")
+ (pinguHm.home.sessionVariables.LEAK or "MISSING")
+ ];
+ expected = [
+ "MISSING"
+ "MISSING"
+ ];
+ }
+ );
+
+ # (c) Plain (non-parametric) host-scope aspect with homeManager content:
+ # still class-local to the host → MISSING in users' HM eval.
+ test-plain-hm-at-host-missing = denTest (
+ {
+ den,
+ tuxHm,
+ ...
+ }:
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+
+ den.aspects.igloo.includes = [
+ {
+ homeManager.home.sessionVariables.LEAK = "plain";
+ }
+ ];
+
+ expr = tuxHm.home.sessionVariables.LEAK or "MISSING";
+ expected = "MISSING";
+ }
+ );
+
+ # (d) { host, … } host-scope aspect with homeManager content: host is in ctx
+ # (bound once), but the emission is still class-local to the host → MISSING.
+ test-host-param-hm-at-host-missing = denTest (
+ {
+ den,
+ tuxHm,
+ ...
+ }:
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+
+ den.aspects.igloo.includes = [
+ (
+ { host, ... }:
+ {
+ homeManager.home.sessionVariables.LEAK = "hm ${host.name}";
+ }
+ )
+ ];
+
+ expr = tuxHm.home.sessionVariables.LEAK or "MISSING";
+ expected = "MISSING";
+ }
+ );
+
+ };
+}
diff --git a/templates/ci/modules/deadbugs/issue-613-exclude-sibling-isolation.nix b/templates/ci/modules/deadbugs/issue-613-exclude-sibling-isolation.nix
new file mode 100644
index 000000000..091b3c557
--- /dev/null
+++ b/templates/ci/modules/deadbugs/issue-613-exclude-sibling-isolation.nix
@@ -0,0 +1,50 @@
+# #613 analog for EXCLUDES — sibling-scope isolation of the constraint registry.
+#
+# #613 fixed sibling leakage in the conditional-guard hasAspect (the pathSet was
+# fleet-wide). This verifies the EXCLUDE path (the constraint registry, applied at
+# check-constraint during the tree walk) has the SAME per-entity isolation: one
+# host excluding an aspect must NOT suppress a SIBLING host that includes it, and
+# vice-versa — regardless of host eval order (iceberg vs igloo).
+#
+# Mirrors github.com/tschan/den-hasaspect-bug modules/bug.nix (the `bogus` +
+# `working` pair). Both must pass: an include on host X delivers the aspect to X
+# even when a sibling Y excludes it.
+{ denTest, ... }:
+{
+ flake.tests.issue-613-exclude-sibling-isolation = {
+
+ # iceberg excludes, igloo includes → igloo (the includer) must get the aspect.
+ # (the tschan `bogus` case — the sibling exclude must not leak into igloo.)
+ test-sibling-exclude-does-not-suppress-includer = denTest (
+ { den, igloo, ... }:
+ {
+ den.hosts.x86_64-linux.iceberg.users.tux = { };
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+
+ den.aspects.test.nixos.networking.hostName = "right";
+ den.aspects.iceberg.excludes = [ den.aspects.test ];
+ den.aspects.igloo.includes = [ den.aspects.test ];
+
+ expr = igloo.networking.hostName;
+ expected = "right";
+ }
+ );
+
+ # symmetric: iceberg includes, igloo excludes → iceberg (the includer) gets it.
+ # (the tschan `working` case.)
+ test-sibling-exclude-does-not-suppress-includer-swapped = denTest (
+ { den, iceberg, ... }:
+ {
+ den.hosts.x86_64-linux.iceberg.users.tux = { };
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+
+ den.aspects.test.nixos.networking.hostName = "right";
+ den.aspects.iceberg.includes = [ den.aspects.test ];
+ den.aspects.igloo.excludes = [ den.aspects.test ];
+
+ expr = iceberg.networking.hostName;
+ expected = "right";
+ }
+ );
+ };
+}
diff --git a/templates/ci/modules/deadbugs/issue-613-policy-exclude-sibling.nix b/templates/ci/modules/deadbugs/issue-613-policy-exclude-sibling.nix
new file mode 100644
index 000000000..4e48894e7
--- /dev/null
+++ b/templates/ci/modules/deadbugs/issue-613-policy-exclude-sibling.nix
@@ -0,0 +1,45 @@
+# #613 analog for POLICY-NAME exclusion (dispatch-policies / policy-schema late
+# filter). Sibling parity: one host excluding a POLICY must not suppress a sibling
+# host that includes+fires it. Companion to issue-613-exclude-sibling-isolation
+# (aspect-content excludes). Both flavors now route through scopedConstraintsFor
+# (entity-scoped + schema-broadcast), so the fleet-wide leak is gone while
+# schema-tier (den.schema.KIND.excludes) excludes still broadcast.
+{ denTest, ... }:
+{
+ flake.tests.issue-613-policy-exclude-sibling = {
+
+ # iceberg excludes the policy, igloo includes it → igloo must still fire it.
+ test-sibling-policy-exclude-does-not-suppress-includer = denTest (
+ { den, igloo, ... }:
+ {
+ den.hosts.x86_64-linux.iceberg.users.tux = { };
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.policies.add-marker = _: [
+ (den.lib.policy.include { nixos.environment.variables.MARKER = "yes"; })
+ ];
+ den.aspects.iceberg.excludes = [ den.policies.add-marker ];
+ den.aspects.igloo.includes = [ den.policies.add-marker ];
+
+ expr = igloo.environment.variables.MARKER or "absent";
+ expected = "yes";
+ }
+ );
+
+ # swapped: igloo excludes, iceberg includes → iceberg must still fire it.
+ test-sibling-policy-exclude-does-not-suppress-includer-swapped = denTest (
+ { den, iceberg, ... }:
+ {
+ den.hosts.x86_64-linux.iceberg.users.tux = { };
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.policies.add-marker = _: [
+ (den.lib.policy.include { nixos.environment.variables.MARKER = "yes"; })
+ ];
+ den.aspects.iceberg.includes = [ den.policies.add-marker ];
+ den.aspects.igloo.excludes = [ den.policies.add-marker ];
+
+ expr = iceberg.environment.variables.MARKER or "absent";
+ expected = "yes";
+ }
+ );
+ };
+}
diff --git a/templates/ci/modules/deprecated/debug-fwd-perhost.nix b/templates/ci/modules/deprecated/debug-fwd-perhost.nix
index 15a48f41a..14203d16c 100644
--- a/templates/ci/modules/deprecated/debug-fwd-perhost.nix
+++ b/templates/ci/modules/deprecated/debug-fwd-perhost.nix
@@ -1,9 +1,9 @@
-# Forward custom class to leaf option using evalConfig (deprecated parametric/perHost variant).
+# Forward custom class to leaf option using evalConfig (parametric children variant).
{ denTest, ... }:
{
flake.tests.fwd-leaf-option = {
- # perHost parametric children with evalConfig.
+ # parametric children with evalConfig.
test-fwd-perHost-variables = denTest (
{
den,
@@ -34,8 +34,16 @@
)
];
}
- { den.aspects.foo._.sub1 = den.lib.perHost { variables.TEST = "test-var"; }; }
- { den.aspects.foo._.sub2 = den.lib.perHost { variables.OTHER = "other-var"; }; }
+ {
+ den.aspects.foo._.sub1 = {
+ variables.TEST = "test-var";
+ };
+ }
+ {
+ den.aspects.foo._.sub2 = {
+ variables.OTHER = "other-var";
+ };
+ }
];
den.aspects.igloo.includes = [ den.aspects.foo ];
diff --git a/templates/ci/modules/deprecated/homes-perhome.nix b/templates/ci/modules/deprecated/homes-perhome.nix
index d4d60b3dc..445b1eaef 100644
--- a/templates/ci/modules/deprecated/homes-perhome.nix
+++ b/templates/ci/modules/deprecated/homes-perhome.nix
@@ -24,12 +24,12 @@
lib.optional (home.hostName == "igloo") (include {
homeManager.home.keyboard.layout = "enthium";
includes = [
- (den.lib.perHome (
- { home }:
+ (
+ { home, ... }:
{
homeManager.home.keyboard.variant = home.name;
}
- ))
+ )
];
});
den.aspects.tux.includes = [
diff --git a/templates/ci/modules/deprecated/perUser-perHost.nix b/templates/ci/modules/deprecated/perUser-perHost.nix
index f8aab1930..0e192d035 100644
--- a/templates/ci/modules/deprecated/perUser-perHost.nix
+++ b/templates/ci/modules/deprecated/perUser-perHost.nix
@@ -26,51 +26,65 @@
# directly resolved." Both see {host, user} without home.
# Test updated to reflect post-ctx drain semantics.
den.aspects.igloo.includes = [
- (den.lib.perHost { nixos.funny = [ "atHost perHost static" ]; })
- (den.lib.perHost (
- { host }:
+ { nixos.funny = [ "atHost perHost static" ]; }
+ (
+ { host, ... }:
{
nixos.funny = [ "atHost perHost ${host.name} fun" ];
}
- ))
- (den.lib.perUser { nixos.funny = [ "atHost perUser static" ]; })
- (den.lib.perUser (
- { user, host }:
+ )
+ { nixos.funny = [ "atHost perUser static" ]; }
+ (
+ { user, host, ... }:
{
nixos.funny = [ "atHost perUser ${user.name}@${host.name} fun" ];
}
- ))
+ )
];
den.aspects.tux.includes = [
- (den.lib.perHost { nixos.funny = [ "atUser perHost static" ]; })
- (den.lib.perHost (
- { host }:
+ { nixos.funny = [ "atUser perHost static" ]; }
+ (
+ { host, ... }:
{
nixos.funny = [ "atUser perHost ${host.name} fun" ];
}
- ))
- (den.lib.perUser { nixos.funny = [ "atUser perUser static" ]; })
- (den.lib.perUser (
- { user, host }:
+ )
+ { nixos.funny = [ "atUser perUser static" ]; }
+ (
+ { user, host, ... }:
{
nixos.funny = [ "atUser perUser ${user.name}@${host.name} fun" ];
}
- ))
+ )
];
expr = lib.sort lib.lessThan igloo.funny;
- # Fan-out: perUser includes on host aspect drain once per user
- # scope. Static variants produce identical output at each scope
- # (appearing twice in the merged list). Fun variants produce
- # per-user distinct output.
+ # §6: host-aspect (igloo) includes resolve at host scope (ctx={host});
+ # user-aspect (tux) includes resolve at user scope (ctx={host,user}).
+ # { host } fn → in-ctx at its scope → binds once. { host, user } fn →
+ # user is a descendant at host scope → fan-out per user; both in-ctx at
+ # user scope → binds once. Bare static attrset → unconditional, emits
+ # once at its scope (NO fan-out, no destructure to defer on).
+ #
+ # FLIPS vs perCtx shim:
+ # - "atHost perUser static" x2 → x1: rule-correct. The old shim
+ # genuinely made the static parametric-on-user (intended ×2 fan-out);
+ # under the migration the per-user fan-out of identical static content
+ # RELOCATED to `relationship-fanout.test-identical-static-appears-per-user`.
+ # The bare static here has no destructure → emits ONCE at host scope.
+ # - + "atUser perHost igloo fun", + "atUser perHost static": rule-correct.
+ # { host } / static on a USER aspect now BIND host from ctx at the user
+ # scope and emit there; the old perHost shim silently no-op'd (saw user
+ # extra). nixos content folds into the host config → NEW entries.
expected = [
"atHost perHost igloo fun"
"atHost perHost static"
"atHost perUser pingu@igloo fun"
"atHost perUser static"
- "atHost perUser static"
"atHost perUser tux@igloo fun"
+ "atUser perHost igloo fun"
+ "atUser perHost static"
"atUser perUser static"
"atUser perUser tux@igloo fun"
];
@@ -103,48 +117,69 @@
[
(include {
includes = [
- (den.lib.perHost { nixos.funny = [ (throw "atHost perHost static") ]; })
- (den.lib.perHost (
- { host }:
+ { nixos.funny = [ "atHost perHost static" ]; }
+ (
+ { host, ... }:
{
- nixos.funny = [ (throw "atHost perHost ${host.name} fun") ];
+ nixos.funny = [ "atHost perHost ${host.name} fun" ];
}
- ))
- (den.lib.perUser { nixos.funny = [ "atHost perUser static" ]; })
- (den.lib.perUser (
- { user, host }:
+ )
+ { nixos.funny = [ "atHost perUser static" ]; }
+ (
+ { user, host, ... }:
{
nixos.funny = [ "atHost perUser ${user.name}@${host.name} fun" ];
}
- ))
+ )
];
})
];
den.aspects.igloo.includes = [ den.aspects.igloo.policies.to-users ];
den.aspects.tux.includes = [
- (den.lib.perHost { nixos.funny = [ "atUser ignored perHost static" ]; })
- (den.lib.perHost (
- { host }:
+ { nixos.funny = [ "atUser perHost static" ]; }
+ (
+ { host, ... }:
{
- nixos.funny = [ "atUser ignored perHost ${host.name} fun" ];
+ nixos.funny = [ "atUser perHost ${host.name} fun" ];
}
- ))
- (den.lib.perUser { nixos.funny = [ "atUser perUser static" ]; })
- (den.lib.perUser (
- { user, host }:
+ )
+ { nixos.funny = [ "atUser perUser static" ]; }
+ (
+ { user, host, ... }:
{
nixos.funny = [ "atUser perUser ${user.name}@${host.name} fun" ];
}
- ))
+ )
];
expr = lib.sort lib.lessThan igloo.funny;
+ # §6: the to-users policy on host-aspect igloo fans out per user
+ # (ctx={host,user}); its include block runs once per user (tux, pingu).
+ # Inside that ctx, { host } fn and bare statics BIND/emit (host in-ctx)
+ # → x2 each (one per user). { host, user } fn → both in-ctx → per user.
+ #
+ # FLIPS vs perCtx shim:
+ # - + "atHost perHost igloo fun" x2, + "atHost perHost static" x2:
+ # rule-correct. Inside to-users ctx={host,user}, host is in-ctx, so the
+ # perHost entries now bind and emit (per-user fan-out) instead of the
+ # shim's silent skip (it saw the user extra). The originals threw to
+ # assert non-emission; under the rule they DO emit, so the throws were
+ # replaced with plain strings.
+ # - + "atUser perHost igloo fun", + "atUser perHost static":
+ # rule-correct. On user-aspect tux, host binds from ctx and emits at the
+ # user scope (formerly "atUser ignored …", skipped by the shim).
expected = [
+ "atHost perHost igloo fun"
+ "atHost perHost igloo fun"
+ "atHost perHost static"
+ "atHost perHost static"
"atHost perUser pingu@igloo fun"
"atHost perUser static"
"atHost perUser static"
"atHost perUser tux@igloo fun"
+ "atUser perHost igloo fun"
+ "atUser perHost static"
"atUser perUser static"
"atUser perUser tux@igloo fun"
];
@@ -168,33 +203,49 @@
};
den.aspects.tux.includes = [
- (den.lib.perHost { homeManager.funny = [ "atHome IGNORED perHost static" ]; })
- (den.lib.perHost (
- { host }:
+ # Bare static attrsets are unconditional → emit at the home scope.
+ { homeManager.funny = [ "atHome perHost static" ]; }
+ # { host } fn: host is neither in-ctx nor a descendant of a standalone
+ # home → misplaced → inert (IGNORED).
+ (
+ { host, ... }:
{
homeManager.funny = [ "atHome IGNORED perHost ${host.name} fun" ];
}
- ))
- (den.lib.perUser { homeManager.funny = [ "atHome IGNORED perUser static" ]; })
- (den.lib.perUser (
- { user, host }:
+ )
+ { homeManager.funny = [ "atHome perUser static" ]; }
+ # { user, host } fn: neither in-ctx nor descendant → inert (IGNORED).
+ (
+ { user, host, ... }:
{
homeManager.funny = [ "atHome IGNORED perUser ${user.name}@${host.name} fun" ];
}
- ))
- (den.lib.perHome { homeManager.funny = [ "atHome perHome static" ]; })
- (den.lib.perHome (
- { home }:
+ )
+ { homeManager.funny = [ "atHome perHome static" ]; }
+ (
+ { home, ... }:
{
homeManager.funny = [ "atHome perHome ${home.name} fun" ];
}
- ))
+ )
];
expr = lib.sort lib.lessThan config.flake.homeConfigurations.tux.config.funny;
+ # §6: standalone home, ctx={home}. { host }/{ user,host } fns are
+ # misplaced (no host/user in ctx or as descendants) → inert. { home } fn
+ # binds in-ctx. Bare static attrsets are unconditional → emit.
+ #
+ # FLIPS vs perCtx shim:
+ # - + "atHome perHost static", + "atHome perUser static": rule-correct.
+ # These are bare static attrsets (no entity destructure) → unconditional
+ # emission at the home scope. The shim no-op'd them (it saw the home
+ # extra). Labels lost the "IGNORED" prefix since they now emit; the
+ # parametric { host }/{ user,host } fns remain genuinely inert.
expected = [
"atHome perHome static"
"atHome perHome tux fun"
+ "atHome perHost static"
+ "atHome perUser static"
];
}
);
diff --git a/templates/ci/modules/deprecated/perctx-shim.nix b/templates/ci/modules/deprecated/perctx-shim.nix
new file mode 100644
index 000000000..a4ea7e61b
--- /dev/null
+++ b/templates/ci/modules/deprecated/perctx-shim.nix
@@ -0,0 +1,59 @@
+# Compat-shim regression test: the RESTORED den.lib.perHost / perUser / perHome
+# aliases (modules/context/perHost-perUser.nix). Proves the API still resolves
+# and delivers the CURRENT binding rule (bind-at-scope / class-local fan-out),
+# i.e. it is a faithful alias for a plain `{ host, ... }:` function — NOT the
+# old self-suppressing guard. (The plain-function equivalents are asserted in
+# `perUser-perHost.nix`; this asserts the shim produces the same result.)
+{ denTest, ... }:
+{
+ flake.tests.perctx-shim = {
+
+ test-perhost-binds-once-peruser-fans-out = denTest (
+ {
+ den,
+ igloo,
+ lib,
+ ...
+ }:
+ {
+ den.hosts.x86_64-linux.igloo.users = {
+ tux = { };
+ pingu = { };
+ };
+
+ den.aspects.igloo.nixos.options.funny = lib.mkOption {
+ default = [ ];
+ type = lib.types.listOf lib.types.str;
+ };
+
+ # Included at the HOST aspect scope (ctx = { host }).
+ den.aspects.igloo.includes = [
+ # perHost: requires `host`, in-ctx at host scope → binds ONCE.
+ (den.lib.perHost (
+ { host, ... }:
+ {
+ nixos.funny = [ "perHost ${host.name}" ];
+ }
+ ))
+ # perUser: requires `host` + `user`; `user` is a descendant entity at
+ # the host scope → class-local fan-out per user (tux, pingu). The old
+ # shim would have self-suppressed here (saw the deeper `user` key);
+ # the restored shim delivers the rule-correct per-user fan-out.
+ (den.lib.perUser (
+ { host, user, ... }:
+ {
+ nixos.funny = [ "perUser ${user.name}@${host.name}" ];
+ }
+ ))
+ ];
+
+ expr = lib.sort lib.lessThan igloo.funny;
+ expected = [
+ "perHost igloo"
+ "perUser pingu@igloo"
+ "perUser tux@igloo"
+ ];
+ }
+ );
+ };
+}
diff --git a/templates/ci/modules/features/entity-isolation.nix b/templates/ci/modules/features/entity-isolation.nix
new file mode 100644
index 000000000..689b2ce0d
--- /dev/null
+++ b/templates/ci/modules/features/entity-isolation.nix
@@ -0,0 +1,269 @@
+# Entity-isolation marker + isolation-aware extraction (spec 2026-06-11).
+{ denTest, ... }:
+{
+ flake.tests.entity-isolation = {
+
+ # Kind-level marker: declared via gen-schema collection, default false.
+ test-isolated-marker = denTest (
+ { den, config, ... }:
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.schema.iso-kind = {
+ isEntity = true;
+ parent = "host";
+ isolated = true;
+ };
+
+ expr = {
+ iso = config.den.schema.iso-kind.isolated;
+ host = config.den.schema.host.isolated;
+ };
+ expected = {
+ iso = true;
+ host = false;
+ };
+ }
+ );
+
+ # The marker alone must not flip the computed isEntity heuristic.
+ test-isolated-alone-not-entity = denTest (
+ { den, config, ... }:
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.schema.marker-only.isolated = true;
+
+ expr = config.den.schema.marker-only.isEntity;
+ expected = false;
+ }
+ );
+
+ # An isolated child's nixos-authored content must NOT be absorbed into
+ # the parent's own nixos config (the cortex microvm.guest leak).
+ test-isolated-content-not-absorbed = denTest (
+ {
+ den,
+ lib,
+ igloo,
+ ...
+ }:
+ let
+ guestEntity = {
+ name = "guest";
+ system = "x86_64-linux";
+ class = "nixos";
+ intoAttr = [ ];
+ users = { };
+ aspect = den.aspects.guest-aspect;
+ };
+ in
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.schema.iso-kind = {
+ isEntity = true;
+ parent = "host";
+ isolated = true;
+ };
+ # Gate on the parent's name so the policy does not re-fire inside the
+ # guest scope. Bind only the iso-kind record — rebinding `host` to the
+ # guest would trigger the host home-env synthesis (which the bare guest
+ # record cannot satisfy) and is unnecessary: the iso-kind scope inherits
+ # the parent `host` (igloo) from the enriched ctx, and isolation gates
+ # whether its nixos content leaks back into igloo.
+ den.policies.resolve-iso-child =
+ { host, ... }:
+ lib.optionals (host.name == "igloo") [
+ (den.lib.policy.resolve.to "iso-kind" {
+ iso-kind = guestEntity;
+ })
+ ];
+ den.schema.host.includes = [ den.policies.resolve-iso-child ];
+ den.aspects.guest-aspect.nixos.boot.kernelModules = [ "guest-only-module" ];
+
+ # igloo carries nixpkgs hardware defaults (atkbd/loop), so assert the
+ # guest's module specifically does NOT leak in rather than equality to [].
+ expr = lib.elem "guest-only-module" igloo.boot.kernelModules;
+ expected = false;
+ }
+ );
+
+ # SPIKE falsifier: a route registered inside the isolated guest scope with
+ # appendToParent delivers the guest subtree's nixos exactly once at the
+ # parent path — and the parent's own toplevel stays clean.
+ test-isolated-delivery-exactly-once = denTest (
+ {
+ den,
+ lib,
+ igloo,
+ ...
+ }:
+ let
+ guestEntity = {
+ name = "guest";
+ system = "x86_64-linux";
+ class = "nixos";
+ intoAttr = [ ];
+ users = { };
+ aspect = den.aspects.guest-aspect;
+ };
+ # Fires inside the guest scope (resolve includes): sourceScopeId
+ # defaults to the guest scope = collection root. Gated against
+ # re-fire in nested sub-scopes where it would self-deliver.
+ deliverPolicy = den.lib.policy.mkPolicy "deliver-iso" (
+ { ... }@args:
+ lib.optionals (!(args ? user) && !(args ? home)) [
+ (den.lib.policy.route {
+ fromClass = "nixos";
+ intoClass = "nixos";
+ collectSubtree = true;
+ appendToParent = true;
+ path = [
+ "microvm"
+ "vms"
+ "guest"
+ "config"
+ ];
+ })
+ ]
+ );
+ # Freeform slot on the parent so delivered content has a landing path.
+ microvmSlot =
+ { lib, ... }:
+ {
+ options.microvm.vms = lib.mkOption {
+ type = lib.types.attrsOf (
+ lib.types.submodule {
+ options.config = lib.mkOption {
+ type = lib.types.submoduleWith {
+ modules = [
+ { config._module.freeformType = lib.types.lazyAttrsOf lib.types.anything; }
+ ];
+ };
+ default = { };
+ };
+ }
+ );
+ default = { };
+ };
+ };
+ in
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.schema.iso-kind = {
+ isEntity = true;
+ parent = "host";
+ isolated = true;
+ };
+ den.policies.resolve-iso-child =
+ { host, ... }:
+ lib.optionals (host.name == "igloo") [
+ (den.lib.policy.resolve.to.withIncludes "iso-kind" [ deliverPolicy ] {
+ iso-kind = guestEntity;
+ })
+ ];
+ den.schema.host.includes = [ den.policies.resolve-iso-child ];
+ den.aspects.igloo.nixos.imports = [ microvmSlot ];
+ den.aspects.guest-aspect.nixos.boot.kernelModules = [ "guest-only-module" ];
+
+ expr = {
+ delivered = lib.elem "guest-only-module" igloo.microvm.vms.guest.config.boot.kernelModules;
+ leaked = lib.elem "guest-only-module" igloo.boot.kernelModules;
+ };
+ expected = {
+ delivered = true;
+ leaked = false;
+ };
+ }
+ );
+
+ # A host-rooted collectSubtree route must NOT pull content from an isolated
+ # descendant: parent and guest both author a shared `side-chan` class; only
+ # the parent's own side-chan content may reach the route target path.
+ test-host-route-skips-isolated = denTest (
+ {
+ den,
+ lib,
+ igloo,
+ ...
+ }:
+ let
+ guestEntity = {
+ name = "guest";
+ system = "x86_64-linux";
+ class = "nixos";
+ intoAttr = [ ];
+ users = { };
+ aspect = den.aspects.guest-aspect;
+ };
+ microvmSlot =
+ { lib, ... }:
+ {
+ options.microvm.vms = lib.mkOption {
+ type = lib.types.attrsOf (
+ lib.types.submodule {
+ options.config = lib.mkOption {
+ type = lib.types.submoduleWith {
+ modules = [
+ { config._module.freeformType = lib.types.lazyAttrsOf lib.types.anything; }
+ ];
+ };
+ default = { };
+ };
+ }
+ );
+ default = { };
+ };
+ };
+ # Host-scope route moving side-chan content into a parent path. Gated on
+ # the parent name so it does not re-fire (and self-deliver) in the guest.
+ collectPolicy =
+ { host, ... }:
+ lib.optionals (host.name == "igloo") [
+ (den.lib.policy.route {
+ fromClass = "side-chan";
+ intoClass = "nixos";
+ collectSubtree = true;
+ path = [
+ "microvm"
+ "vms"
+ "side"
+ "config"
+ ];
+ })
+ ];
+ in
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.classes.side-chan.description = "side channel test class";
+ den.schema.iso-kind = {
+ isEntity = true;
+ parent = "host";
+ isolated = true;
+ };
+ den.policies.resolve-iso-child =
+ { host, ... }:
+ lib.optionals (host.name == "igloo") [
+ (den.lib.policy.resolve.to "iso-kind" {
+ iso-kind = guestEntity;
+ })
+ ];
+ den.policies.collect-side = collectPolicy;
+ den.schema.host.includes = [
+ den.policies.resolve-iso-child
+ den.policies.collect-side
+ ];
+ den.aspects.igloo.nixos.imports = [ microvmSlot ];
+ den.aspects.igloo.side-chan.boot.kernelModules = [ "host-side" ];
+ den.aspects.guest-aspect.side-chan.boot.kernelModules = [ "guest-side" ];
+
+ expr = {
+ hostSide = lib.elem "host-side" igloo.microvm.vms.side.config.boot.kernelModules;
+ guestSide = lib.elem "guest-side" igloo.microvm.vms.side.config.boot.kernelModules;
+ };
+ expected = {
+ hostSide = true;
+ guestSide = false;
+ };
+ }
+ );
+ };
+}
diff --git a/templates/ci/modules/features/projected-hasaspect-rules.nix b/templates/ci/modules/features/projected-hasaspect-rules.nix
new file mode 100644
index 000000000..d9ae2a049
--- /dev/null
+++ b/templates/ci/modules/features/projected-hasaspect-rules.nix
@@ -0,0 +1,140 @@
+# Behavior matrix for projected (in-context) `hasAspect` on the ANCESTOR-BINDING
+# axis — i.e. `host.hasAspect X` answering membership at the ACTIVE (consuming)
+# descendant scope. The consuming-entity axis (`user.hasAspect`) is covered by
+# deadbugs/projected-hasaspect.nix; the host-own-scope axis by
+# internal-api/hasaspect-ancestor-scope.nix. This suite pins the host-binding
+# axis with matched positives and negatives.
+#
+# Formal rule (specs/2026-06-09-projected-hasaspect-v1.md): every in-context
+# entity-kind binding answers "is X delivered INTO this active scope", keyed by
+# the active (consuming) scope — NOT the binding's own scope.
+{ denTest, lib, ... }:
+let
+ # An effect homeManager aspect that reports, via home.username, whether the
+ # host (the ancestor binding) sees `probe` at the active (this home's) scope.
+ effectFor = probe: {
+ homeManager =
+ { host, ... }:
+ {
+ home.username = if host.hasAspect probe then lib.mkForce "right" else lib.mkForce "wrong";
+ };
+ };
+in
+{
+ flake.tests.projected-hasaspect-rules = {
+
+ # R12 +: host sees an aspect it delivers DOWN to its users (provides.to-users),
+ # checked from inside the delivered home.
+ test-R12-host-sees-provided-POSITIVE = denTest (
+ { den, igloo, ... }:
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.aspects.test.nixos = { };
+ den.aspects.effect = effectFor den.aspects.test;
+ den.aspects.igloo.provides.to-users.includes = [
+ den.aspects.test
+ den.aspects.effect
+ ];
+ expr = igloo.home-manager.users.tux.home.username;
+ expected = "right";
+ }
+ );
+
+ # R12 -: host does NOT report an aspect it never provided. `other` exists but
+ # is never delivered anywhere, so it is in no bucket.
+ test-R12-host-absent-when-unprovided-NEGATIVE = denTest (
+ { den, igloo, ... }:
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.aspects.test.nixos = { };
+ den.aspects.other.nixos = { };
+ den.aspects.effect = effectFor den.aspects.other;
+ den.aspects.igloo.provides.to-users.includes = [
+ den.aspects.test
+ den.aspects.effect
+ ];
+ expr = igloo.home-manager.users.tux.home.username;
+ expected = "wrong";
+ }
+ );
+
+ # R4 -: the host's OWN aspect resolves under the HOST scope, so it is NOT
+ # visible via host.hasAspect from a USER's home (active scope = user). Matches
+ # the spec's scope-specificity rule (and main).
+ test-R4-host-own-aspect-not-visible-from-user-NEGATIVE = denTest (
+ { den, igloo, ... }:
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.aspects.hostonly.nixos = { };
+ den.aspects.effect = effectFor den.aspects.hostonly;
+ den.aspects.igloo = {
+ includes = [ den.aspects.hostonly ];
+ provides.to-users.includes = [ den.aspects.effect ];
+ };
+ expr = igloo.home-manager.users.tux.home.username;
+ expected = "wrong";
+ }
+ );
+
+ # R6 -: per-user delivery. `test` is provided ONLY to tux; the host-binding
+ # query is keyed by the ACTIVE scope, so tux sees it and sibling pingu does
+ # not. (positive + negative in one path.)
+ test-R6-per-user-provide-discriminates = denTest (
+ { den, igloo, ... }:
+ {
+ den.hosts.x86_64-linux.igloo.users = {
+ tux = { };
+ pingu = { };
+ };
+ den.aspects.test.nixos = { };
+ den.aspects.effect = effectFor den.aspects.test;
+ den.aspects.igloo = {
+ provides.tux.includes = [
+ den.aspects.test
+ den.aspects.effect
+ ];
+ provides.pingu.includes = [ den.aspects.effect ];
+ };
+ expr = {
+ tux = igloo.home-manager.users.tux.home.username;
+ pingu = igloo.home-manager.users.pingu.home.username;
+ };
+ expected = {
+ tux = "right";
+ pingu = "wrong";
+ };
+ }
+ );
+
+ # R2 -: per-active-path on the host-binding axis. `test` is provided to-users
+ # on igloo only; the same user `tux` under iceberg must NOT see it.
+ test-R2-multi-host-host-binding-discriminates = denTest (
+ {
+ den,
+ igloo,
+ iceberg,
+ ...
+ }:
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.hosts.x86_64-linux.iceberg.users.tux = { };
+ den.aspects.test.nixos = { };
+ den.aspects.effect = effectFor den.aspects.test;
+ den.aspects.igloo.provides.to-users.includes = [
+ den.aspects.test
+ den.aspects.effect
+ ];
+ den.aspects.iceberg.provides.to-users.includes = [ den.aspects.effect ];
+ expr = {
+ igloo = igloo.home-manager.users.tux.home.username;
+ iceberg = iceberg.home-manager.users.tux.home.username;
+ };
+ expected = {
+ igloo = "right";
+ iceberg = "wrong";
+ };
+ }
+ );
+
+ };
+}
diff --git a/templates/ci/modules/features/relationship-fanout.nix b/templates/ci/modules/features/relationship-fanout.nix
new file mode 100644
index 000000000..426048eb4
--- /dev/null
+++ b/templates/ci/modules/features/relationship-fanout.nix
@@ -0,0 +1,482 @@
+# Synchronous relationship fan-out: a parametric aspect at scope S destructuring
+# an entity-kind arg K_a that is a schema-DAG DESCENDANT of S's kind fans out
+# over S's K_a-children, refiring bound to each, EMITTING AT S. Class content
+# foreign to S (e.g. homeManager on a host scope) becomes inert.
+#
+# A misplaced entity arg (K_a neither in-ctx nor a descendant) → whole aspect
+# inert, silently. Zero children → inert. Fan-out is synchronous inside `bind`;
+# entity args never reach `defer`.
+{ denTest, lib, ... }:
+{
+ flake.tests.relationship-fanout = {
+
+ # 1. Per-user distinct content lands on the host once per user.
+ test-per-user-distinct-lands-on-host = denTest (
+ {
+ den,
+ igloo,
+ lib,
+ ...
+ }:
+ {
+ den.hosts.x86_64-linux.igloo.users = {
+ tux = { };
+ pingu = { };
+ };
+
+ den.aspects.igloo.nixos.options.funny = lib.mkOption {
+ default = [ ];
+ type = lib.types.listOf lib.types.str;
+ };
+
+ # { user } at host scope: user is a descendant of host → fan out.
+ den.aspects.igloo.includes = [
+ (
+ { user, ... }:
+ {
+ nixos.funny = [ "nixos ${user.name}" ];
+ }
+ )
+ ];
+
+ expr = lib.sort lib.lessThan igloo.funny;
+ expected = [
+ "nixos pingu"
+ "nixos tux"
+ ];
+ }
+ );
+
+ # 2. Dedup guard: identical STATIC content per user appears TWICE in the
+ # merged list (the per-child __ctxId tag must keep the two fan-out instances
+ # distinct so neither collapses).
+ test-identical-static-appears-per-user = denTest (
+ {
+ den,
+ igloo,
+ lib,
+ ...
+ }:
+ {
+ den.hosts.x86_64-linux.igloo.users = {
+ tux = { };
+ pingu = { };
+ };
+
+ den.aspects.igloo.nixos.options.funny = lib.mkOption {
+ default = [ ];
+ type = lib.types.listOf lib.types.str;
+ };
+
+ den.aspects.igloo.includes = [
+ (
+ { user, ... }:
+ {
+ nixos.funny = [ "static" ];
+ }
+ )
+ ];
+
+ expr = lib.sort lib.lessThan igloo.funny;
+ expected = [
+ "static"
+ "static"
+ ];
+ }
+ );
+
+ # 3. homeManager content in a HOST-scope fan-out aspect is class-local to the
+ # host (which does not resolve homeManager) → inert; it must NOT land on the
+ # users' HM eval. The old cross-scope defer carrier is starved (entity args
+ # bypass defer), so the leak is gone.
+ test-homemanager-content-inert-at-host-scope = denTest (
+ {
+ den,
+ tuxHm,
+ pinguHm,
+ ...
+ }:
+ {
+ den.hosts.x86_64-linux.igloo.users = {
+ tux = { };
+ pingu = { };
+ };
+
+ den.aspects.igloo.includes = [
+ (
+ { user, ... }:
+ {
+ homeManager.programs.direnv.enable = true;
+ }
+ )
+ ];
+
+ expr = [
+ tuxHm.programs.direnv.enable
+ pinguHm.programs.direnv.enable
+ ];
+ # Inert: host scope resolves no homeManager class. Users never receive it.
+ expected = [
+ false
+ false
+ ];
+ }
+ );
+
+ # 4. Misplaced: a fresh entity kind with no parent, destructured at host
+ # scope → inert, no error, host still evaluates.
+ test-misplaced-entity-arg-inert = denTest (
+ {
+ den,
+ igloo,
+ lib,
+ ...
+ }:
+ {
+ den.schema.gadget.isEntity = true;
+
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+
+ den.aspects.igloo = {
+ nixos.networking.hostName = "igloo";
+ includes = [
+ (
+ { gadget, ... }:
+ {
+ nixos.networking.hostName = "should-not-appear";
+ }
+ )
+ ];
+ };
+
+ expr = igloo.networking.hostName;
+ expected = "igloo";
+ }
+ );
+
+ # 5. Zero children: host with no users + { user } aspect → inert, no error.
+ test-zero-children-inert = denTest (
+ {
+ den,
+ igloo,
+ ...
+ }:
+ {
+ den.hosts.x86_64-linux.igloo = { };
+
+ den.aspects.igloo = {
+ nixos.networking.hostName = "igloo";
+ includes = [
+ (
+ { user, ... }:
+ {
+ nixos.networking.hostName = "should-not-appear";
+ }
+ )
+ ];
+ };
+
+ expr = igloo.networking.hostName;
+ expected = "igloo";
+ }
+ );
+
+ # 6. Composition of in-ctx + descendant: { host, user } at HOST scope. host is
+ # in ctx (bound once), user is a descendant (fan-out). Per-user distinct
+ # content carrying the bound host name lands on the host.
+ test-host-in-ctx-user-descendant = denTest (
+ {
+ den,
+ igloo,
+ lib,
+ ...
+ }:
+ {
+ den.hosts.x86_64-linux.igloo.users = {
+ tux = { };
+ pingu = { };
+ };
+
+ den.aspects.igloo.nixos.options.funny = lib.mkOption {
+ default = [ ];
+ type = lib.types.listOf lib.types.str;
+ };
+
+ den.aspects.igloo.includes = [
+ (
+ { host, user, ... }:
+ {
+ nixos.funny = [ "${user.name}@${host.name}" ];
+ }
+ )
+ ];
+
+ expr = lib.sort lib.lessThan igloo.funny;
+ expected = [
+ "pingu@igloo"
+ "tux@igloo"
+ ];
+ }
+ );
+
+ # 7. Root-scope entity arg → INERT (carrier removed @ this commit; rule: root
+ # entity arg → inert). The formal rule: a parametric entity arg at a scope
+ # whose entity kind is NEITHER able to bind it from ctx NOR an ancestor of it
+ # is misplaced → whole aspect inert, silently. The root (flake) scope has NO
+ # entity kind, so a `{ user, ... }` aspect there is strictly inert.
+ #
+ # Root-scope path: top-level resolution starts at the `flake` entity
+ # (modules/outputs.nix: resolveEntity "flake" {}), whose includes are
+ # `den.schema.flake.includes` (flake is a non-entity routing kind). That
+ # scope is the rootScopeId with NO scopeEntityKind entry, so bind.nix sees
+ # scopeKind == null: isDescendantOf is false, the `user` arg is misplaced →
+ # inert. Previously this rode the cross-scope defer carrier (push-scope
+ # deferred inheritance + walkDeferred refire); that carrier is now removed.
+ test-root-scope-descendant-arg-inert = denTest (
+ {
+ den,
+ igloo,
+ tuxHm,
+ pinguHm,
+ lib,
+ ...
+ }:
+ {
+ den.hosts.x86_64-linux.igloo.users = {
+ tux = { };
+ pingu = { };
+ };
+
+ den.aspects.igloo.nixos.options.funny = lib.mkOption {
+ default = [ ];
+ type = lib.types.listOf lib.types.str;
+ };
+
+ # { user } aspect at the ROOT (flake) scope — NOT host scope. Verified to
+ # take the defer path (scopeKind == null), not the fan-out path.
+ den.schema.flake.includes = [
+ (
+ { user, ... }:
+ {
+ nixos.funny = [ "root-saw ${user.name}" ];
+ homeManager.programs.direnv.enable = true;
+ }
+ )
+ ];
+
+ expr = {
+ hostFunny = lib.sort lib.lessThan igloo.funny;
+ tuxDirenv = tuxHm.programs.direnv.enable;
+ pinguDirenv = pinguHm.programs.direnv.enable;
+ };
+ # Inert: root scope has no entity kind, so the { user } aspect is
+ # misplaced. Neither class is delivered to descendants.
+ expected = {
+ hostFunny = [ ];
+ tuxDirenv = false;
+ pinguDirenv = false;
+ };
+ }
+ );
+
+ # 8. Shared-include suppression: when the SAME source aspect is registered
+ # into BOTH den.schema.host.includes AND den.schema.user.includes (the
+ # den.default pattern), it reaches each user directly via the user scope's
+ # own resolution. Fanning it out at the host scope too would double-cover.
+ # `sharedWithDescendant` detects the shared provenance root (matching the
+ # include node's identity.key against schema.user.includes) and makes the
+ # aspect INERT at the host scope, so each user's contribution lands EXACTLY
+ # once. If the suppression were broken, the list would be DOUBLED
+ # (["shared pingu" "shared pingu" "shared tux" "shared tux"]).
+ test-shared-include-suppresses-fanout = denTest (
+ {
+ den,
+ igloo,
+ lib,
+ ...
+ }:
+ {
+ den.hosts.x86_64-linux.igloo.users = {
+ tux = { };
+ pingu = { };
+ };
+
+ den.aspects.igloo.nixos.options.funny = lib.mkOption {
+ default = [ ];
+ type = lib.types.listOf lib.types.str;
+ };
+
+ # A NAMED aspect carries a stable identity.key from any registration
+ # site (a bare lambda would anonymize differently per site). Register
+ # the SAME node into BOTH host and user includes (the den.default
+ # shape).
+ den.aspects.shared.includes = [
+ (
+ { user, ... }:
+ {
+ nixos.funny = [ "shared ${user.name}" ];
+ }
+ )
+ ];
+
+ # Same provenance root in both lists. At each user scope `user` is
+ # in-ctx and it binds once, landing the per-user contribution. At the
+ # host scope `user` is a descendant → it would fan out, but
+ # sharedWithDescendant detects the shared user-include root and makes it
+ # INERT here → no double-cover. Each contribution lands EXACTLY once.
+ den.schema.host.includes = [ den.aspects.shared ];
+ den.schema.user.includes = [ den.aspects.shared ];
+
+ expr = lib.sort lib.lessThan igloo.funny;
+ expected = [
+ "shared pingu"
+ "shared tux"
+ ];
+ }
+ );
+
+ # 9. Independent descendant branches → CARTESIAN product. `{ user, pet }` at
+ # host: user and pet are BOTH direct descendants of host (siblings in the
+ # DAG), so every user pairs with every pet. Emergent from the rule (bind
+ # fans one descendant, recursion fans the other). Spec §3 "independent
+ # descendant branches → cartesian product".
+ test-cartesian-independent-descendants = denTest (
+ {
+ den,
+ igloo,
+ lib,
+ ...
+ }:
+ {
+ den.schema.pet.isEntity = true;
+ den.schema.pet.parent = "host";
+
+ den.hosts.x86_64-linux.igloo = {
+ users = {
+ tux = { };
+ pingu = { };
+ };
+ pets = {
+ rex = {
+ name = "rex";
+ };
+ fido = {
+ name = "fido";
+ };
+ };
+ };
+
+ den.aspects.igloo.nixos.options.funny = lib.mkOption {
+ default = [ ];
+ type = lib.types.listOf lib.types.str;
+ };
+
+ den.aspects.igloo.includes = [
+ (
+ { user, pet, ... }:
+ {
+ nixos.funny = [ "${user.name}-${pet.name}" ];
+ }
+ )
+ ];
+
+ expr = lib.sort lib.lessThan igloo.funny;
+ expected = [
+ "pingu-fido"
+ "pingu-rex"
+ "tux-fido"
+ "tux-rex"
+ ];
+ }
+ );
+
+ # 10. Cartesian with one EMPTY branch → whole product inert (no error). The
+ # host has users but zero pets, so `{ user, pet }` yields no pairs and emits
+ # nothing. Negative twin of #9.
+ test-cartesian-empty-branch-inert = denTest (
+ {
+ den,
+ igloo,
+ ...
+ }:
+ {
+ den.schema.pet.isEntity = true;
+ den.schema.pet.parent = "host";
+
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+
+ den.aspects.igloo = {
+ nixos.networking.hostName = "igloo";
+ includes = [
+ (
+ { user, pet, ... }:
+ {
+ nixos.networking.hostName = "should-not-appear";
+ }
+ )
+ ];
+ };
+
+ expr = igloo.networking.hostName;
+ expected = "igloo";
+ }
+ );
+
+ # 11. TRANSITIVE descendant fan-out (DAG nesting). host -> pet -> toy.
+ # `{ pet, toy }` at host: `pet` is a direct descendant (fan over host.pets);
+ # `toy` is a descendant of `pet`, so for each fanned pet it fans over THAT
+ # pet's toys (pet.toys), emitting at the host. Spec §3 "transitive descendants
+ # follow DAG nesting". Requires fanning the intermediate (pet) before its
+ # child (toy) and enumerating toy off the bound pet record, not the host.
+ test-transitive-descendant-chain = denTest (
+ {
+ den,
+ igloo,
+ lib,
+ ...
+ }:
+ {
+ den.schema.pet.isEntity = true;
+ den.schema.pet.parent = "host";
+ den.schema.toy.isEntity = true;
+ den.schema.toy.parent = "pet";
+
+ den.hosts.x86_64-linux.igloo = {
+ users.tux = { };
+ pets.rex = {
+ name = "rex";
+ toys = {
+ ball = {
+ name = "ball";
+ };
+ bone = {
+ name = "bone";
+ };
+ };
+ };
+ };
+
+ den.aspects.igloo.nixos.options.funny = lib.mkOption {
+ default = [ ];
+ type = lib.types.listOf lib.types.str;
+ };
+
+ den.aspects.igloo.includes = [
+ (
+ { pet, toy, ... }:
+ {
+ nixos.funny = [ "${pet.name}/${toy.name}" ];
+ }
+ )
+ ];
+
+ expr = lib.sort lib.lessThan igloo.funny;
+ expected = [
+ "rex/ball"
+ "rex/bone"
+ ];
+ }
+ );
+
+ };
+}
diff --git a/templates/ci/modules/internal-api/arg-class.nix b/templates/ci/modules/internal-api/arg-class.nix
new file mode 100644
index 000000000..5c336227c
--- /dev/null
+++ b/templates/ci/modules/internal-api/arg-class.nix
@@ -0,0 +1,113 @@
+# Pure-fn tests for the arg classifier over the schema entity DAG.
+# Exercises isDescendantOf (parent-chain walk, cycle-safe) and the
+# convention-based childrenOf enumeration. No entity wiring — these call
+# den.lib.aspects.fx.argClass.* directly against literal schemas.
+{ denTest, lib, ... }:
+let
+ schema = {
+ tier = { };
+ host.parent = "tier";
+ user.parent = "host";
+ home.parent = "host";
+ };
+ cyclic = {
+ a.parent = "b";
+ b.parent = "a";
+ };
+in
+{
+ flake.tests.arg-class = {
+
+ test-isDescendantOf-direct = denTest (
+ { den, ... }:
+ {
+ expr = den.lib.aspects.fx.argClass.isDescendantOf schema "host" "user";
+ expected = true;
+ }
+ );
+
+ test-isDescendantOf-transitive = denTest (
+ { den, ... }:
+ {
+ expr = den.lib.aspects.fx.argClass.isDescendantOf schema "tier" "user";
+ expected = true;
+ }
+ );
+
+ test-isDescendantOf-self = denTest (
+ { den, ... }:
+ {
+ expr = den.lib.aspects.fx.argClass.isDescendantOf schema "host" "host";
+ expected = false;
+ }
+ );
+
+ test-isDescendantOf-ancestor-inverted = denTest (
+ { den, ... }:
+ {
+ expr = den.lib.aspects.fx.argClass.isDescendantOf schema "user" "host";
+ expected = false;
+ }
+ );
+
+ test-isDescendantOf-null-scope = denTest (
+ { den, ... }:
+ {
+ expr = den.lib.aspects.fx.argClass.isDescendantOf schema null "user";
+ expected = false;
+ }
+ );
+
+ test-isDescendantOf-cycle-safe = denTest (
+ { den, ... }:
+ {
+ expr = den.lib.aspects.fx.argClass.isDescendantOf cyclic "x" "a";
+ expected = false;
+ }
+ );
+
+ test-childrenOf-convention = denTest (
+ { den, ... }:
+ {
+ expr = lib.sort builtins.lessThan (
+ map (c: c.n) (
+ den.lib.aspects.fx.argClass.childrenOf {
+ users = {
+ tux = {
+ n = 1;
+ };
+ pingu = {
+ n = 2;
+ };
+ };
+ } "user"
+ )
+ );
+ expected = [
+ 1
+ 2
+ ];
+ }
+ );
+
+ test-isDescendantOf-unknown-kind = denTest (
+ { den, ... }:
+ {
+ expr = den.lib.aspects.fx.argClass.isDescendantOf {
+ user.parent = "host";
+ host = { };
+ } "host" "ghost";
+ expected = false;
+ }
+ );
+
+ test-childrenOf-absent = denTest (
+ { den, ... }:
+ {
+ expr = den.lib.aspects.fx.argClass.childrenOf { } "user";
+ expected = [ ];
+ }
+ );
+
+ };
+}
diff --git a/templates/ci/modules/internal-api/aspect-path.nix b/templates/ci/modules/internal-api/aspect-path.nix
index cc7f75d93..d31944b75 100644
--- a/templates/ci/modules/internal-api/aspect-path.nix
+++ b/templates/ci/modules/internal-api/aspect-path.nix
@@ -228,17 +228,19 @@
param
];
den.aspects.leaf.nixos = { };
- den.aspects.param = den.lib.perHost (
- { host }:
+ den.aspects.param =
+ { host, ... }:
{
nixos = { };
- }
- );
+ };
expr = trace "nixos" den.aspects.role;
- # perHost wrapper defers when no host context is available in the
- # trace pipeline (ctx = {}). The deferred stub shows the name but
- # no inner children.
+ # §6: the trace pipeline runs at the ROOT scope (ctx = {}). A plain
+ # { host, ... } aspect there destructures an entity kind that is neither
+ # in-ctx nor a descendant → misplaced → inert (emits no nixos content).
+ # It still appears in the trace by NAME as a childless node `["param"]`:
+ # trace visibility tracks the include graph, not emission. (The prior
+ # cross-scope deferral carrier is gone, but structural visibility remains.)
expected.trace = [
"role"
[ "leaf" ]
diff --git a/templates/ci/modules/internal-api/edge-trace.nix b/templates/ci/modules/internal-api/edge-trace.nix
new file mode 100644
index 000000000..35a481f6b
--- /dev/null
+++ b/templates/ci/modules/internal-api/edge-trace.nix
@@ -0,0 +1,1062 @@
+# delivery-edges suite — snapshot fixtures + rule-corollary tests for the
+# PRODUCTION delivery-edge object (resolveWithPaths .edgeTrace, Task 18.2). As of
+# Task 18.2 `edgeTrace` is the production edge object: its fold-ordered
+# provides+routes portion is CAPTURED from the production materializeUnified folds
+# (not re-derived), with constructor-built default-fold + instantiate edges and the
+# SURFACED spawn / per-host edges. This means (vs the legacy re-derivation, now
+# `legacyEdgeTrace`): the dedup-suppressed route twins are ABSENT (production never
+# dispatches them), the spawn rewalk arm is replaced by the spawn's real surfaced
+# edges, and instantiate topologies carry the per-host fold edges.
+#
+# Each fixture resolves a minimal topology and asserts its normalized delivery
+# edge list. Scope id_hashes are normalized to ":" so the
+# expected lists stay readable and stable (the raw trace uses parent-blind
+# id_hash identity, spec §8). Where a topology's full list is large but the
+# mechanism under test is a small subset, the test asserts the exact subset for
+# that mechanism plus the total edge count (noted inline).
+#
+# `just ci delivery-edges` runs this suite; `just ci delivery-edges.` one
+# test with traces.
+{ denTest, lib, ... }:
+let
+ # Replace ":" scope strings in an edge trace with
+ # ":". id_hash → name is recovered from the resolve result's
+ # scopeContexts (each entity scope's own record carries name + id_hash).
+ normalizeTrace =
+ r:
+ let
+ sc = r.scopeContexts or { };
+ hashToName = lib.foldl' (
+ acc: sid:
+ let
+ ctx = sc.${sid} or { };
+ # Map EVERY entity record present in this scope's ctx (a child scope
+ # carries its own + ancestor records, e.g. a user scope has both `user`
+ # and `host`), so id_hash → name is built for all kinds, not just the
+ # scope's own.
+ entityKeys = lib.filter (k: builtins.isAttrs (ctx.${k} or null) && (ctx.${k} ? id_hash)) (
+ builtins.attrNames ctx
+ );
+ in
+ acc
+ // lib.listToAttrs (
+ map (k: lib.nameValuePair "${k}:${ctx.${k}.id_hash}" "${k}:${ctx.${k}.name or "?"}") entityKeys
+ )
+ ) { } (builtins.attrNames sc);
+ ren = s: hashToName.${s} or s;
+ # rewalk aspect ids are BARE id_hashes (no ":" prefix), so build a
+ # bare-hash → ":" map alongside the prefixed one above.
+ bareHashToName = lib.mapAttrs' (
+ k: v: lib.nameValuePair (lib.last (lib.splitString ":" k)) v
+ ) hashToName;
+ renAspect = s: bareHashToName.${s} or (hashToName.${s} or s);
+ renSource =
+ src:
+ if src ? collected then
+ {
+ collected = src.collected // {
+ scope = ren src.collected.scope;
+ };
+ }
+ else if src ? rewalk then
+ {
+ rewalk = src.rewalk // {
+ aspect = renAspect src.rewalk.aspect;
+ };
+ }
+ else
+ src;
+ renTarget = t: if t ? root then t // { root = ren t.root; } else t;
+ # Rename scope names inside the collectedScopes / spawnFrom annotations so
+ # default-fold and spawn edges stay readable in expected lists.
+ renAnnotations =
+ a:
+ a
+ // lib.optionalAttrs (a ? collectedScopes) {
+ collectedScopes = map ren a.collectedScopes;
+ }
+ // lib.optionalAttrs (a ? spawnFrom && a.spawnFrom != null) {
+ spawnFrom = ren a.spawnFrom;
+ };
+ in
+ map (
+ e:
+ e
+ // {
+ source = renSource e.source;
+ target = renTarget e.target;
+ annotations = renAnnotations e.annotations;
+ }
+ ) r.edgeTrace;
+
+ # Resolve a host entity to a normalized edge trace.
+ hostTrace =
+ den: cls: host:
+ normalizeTrace (
+ den.lib.aspects.resolveWithPaths cls (den.lib.resolveEntity "host" { inherit host; })
+ );
+
+ # Resolve the flake root to a normalized edge trace.
+ flakeTrace =
+ den: cls: normalizeTrace (den.lib.aspects.resolveWithPaths cls (den.lib.resolveEntity "flake" { }));
+
+ # Edge constructors mirroring edge-trace.nix's record shape (for readable
+ # expected lists).
+ collected = scope: class: { collected = { inherit scope class; }; };
+ rewalk = aspect: bindings: class: { rewalk = { inherit aspect bindings class; }; };
+ synthesize = forwardId: fromClass: intoClass: {
+ synthesize = { inherit forwardId fromClass intoClass; };
+ };
+ rootT = root: class: { inherit root class; };
+ outT = output: { inherit output; };
+ edge =
+ {
+ source,
+ target,
+ path ? [ ],
+ mode,
+ annotations ? { },
+ }:
+ {
+ inherit
+ source
+ target
+ path
+ mode
+ annotations
+ ;
+ };
+
+ # The host-default-user homeManager→nixos forward (every host with a user gets
+ # one) plus the user-class ensureEntry route — shared tail of the host+user
+ # fixtures. Parameterized by the host/user names and the os class (nixos |
+ # darwin). The production edge object (Task 18.2) CAPTURES the edges its fold
+ # dispatched (kept routes only), so the legacy oracle's dedup-suppressed twin is
+ # NOT present here — production never dispatches it.
+ userForwardTail =
+ {
+ user,
+ os,
+ }:
+ [
+ (edge {
+ source = synthesize "homeManager/${os}/home-manager/users/${user}" "homeManager" os;
+ target = rootT "user:${user}" os;
+ path = [
+ "home-manager"
+ "users"
+ user
+ ];
+ mode = "nest";
+ annotations = {
+ complexForward = true;
+ sourceVia = "unresolved";
+ };
+ })
+ (edge {
+ source = collected "user:${user}" "user";
+ target = rootT "user:${user}" os;
+ path = [
+ "users"
+ "users"
+ user
+ ];
+ mode = "nest";
+ annotations = {
+ adaptArgs = true;
+ ensureTargetPath = true;
+ };
+ })
+ ];
+in
+{
+ flake.tests.delivery-edges = {
+
+ # ===== (1) host + single user =====================================
+ # Full edge list. Default folds (host homeManager/nixos, host+user os→nixos),
+ # the user-forward tail (complex forward + dedup-suppressed twin + user
+ # ensureEntry route). os→nixos is the os-class delivery route.
+ test-topology-host-users = denTest (
+ { den, ... }:
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.aspects.igloo.nixos.networking.hostName = "igloo";
+
+ expr = hostTrace den "nixos" den.hosts.x86_64-linux.igloo;
+ expected = [
+ (edge {
+ source = collected "host:igloo" "homeManager";
+ target = rootT "host:igloo" "homeManager";
+ mode = "merge";
+ annotations = {
+ collectedScopes = [
+ "host:igloo"
+ "user:tux"
+ ];
+ };
+ })
+ (edge {
+ source = collected "host:igloo" "nixos";
+ target = rootT "host:igloo" "nixos";
+ mode = "merge";
+ annotations = {
+ collectedScopes = [
+ "host:igloo"
+ "user:tux"
+ ];
+ };
+ })
+ (edge {
+ source = collected "host:igloo" "os";
+ target = rootT "host:igloo" "nixos";
+ mode = "merge";
+ })
+ (edge {
+ source = collected "user:tux" "os";
+ target = rootT "user:tux" "nixos";
+ mode = "merge";
+ })
+ ]
+ ++ userForwardTail {
+ user = "tux";
+ os = "nixos";
+ };
+ }
+ );
+
+ # ===== (2) fleet with environment ancestor ========================
+ # Flake-level resolve through a fleet ancestor. The instantiate edge is
+ # SOURCED from the fleet ancestor scope (resolve.to "host" registered the
+ # instantiate at the fleet scope). The production object (Task 18.2) also
+ # carries the per-host surfaced fold edges (host:igloo HM/nixos folds + the
+ # os→nixos delivery route) the instantiate projection adds. The os→nixos route
+ # appears twice (the per-host + B′ projections both surface it; the union does
+ # not dedup). Full list.
+ test-topology-fleet-environment = denTest (
+ { den, lib, ... }:
+ {
+ den.quirks.host-addrs.description = "addrs";
+ den.policies.to-fleet = _: [
+ (den.lib.policy.resolve.to "fleet" {
+ fleet = {
+ name = "fleet";
+ };
+ })
+ ];
+ den.policies.fleet-to-hosts =
+ { fleet, ... }:
+ lib.concatMap (
+ system:
+ lib.concatMap (
+ hostName:
+ let
+ host = den.hosts.${system}.${hostName};
+ in
+ [
+ (den.lib.policy.resolve.to "host" { inherit host; })
+ (den.lib.policy.instantiate host)
+ ]
+ ) (builtins.attrNames (den.hosts.${system} or { }))
+ ) (builtins.attrNames (den.hosts or { }));
+ den.schema.flake.includes = [ den.policies.to-fleet ];
+ den.schema.fleet.includes = [ den.policies.fleet-to-hosts ];
+ den.schema.flake-system.excludes = [
+ den.policies.system-to-os-outputs
+ den.policies.system-to-hm-outputs
+ ];
+ den.hosts.x86_64-linux.igloo.users = { };
+ den.aspects.igloo.nixos.networking.hostName = "igloo";
+
+ expr = flakeTrace den "flake";
+ expected = [
+ (edge {
+ source = collected "fleet=fleet" "nixos";
+ target = outT [
+ "flake"
+ "nixosConfigurations"
+ "igloo"
+ ];
+ mode = "merge";
+ annotations = {
+ resolvedRootVia = "scope-link";
+ system = "x86_64-linux";
+ };
+ })
+ (edge {
+ source = collected "" "homeManager";
+ target = rootT "" "homeManager";
+ mode = "merge";
+ annotations = {
+ collectedScopes = [
+ ""
+ "fleet=fleet"
+ "host:igloo"
+ "system=x86_64-linux"
+ ];
+ };
+ })
+ (edge {
+ source = collected "" "nixos";
+ target = rootT "" "nixos";
+ mode = "merge";
+ annotations = {
+ collectedScopes = [
+ ""
+ "fleet=fleet"
+ "host:igloo"
+ "system=x86_64-linux"
+ ];
+ };
+ })
+ # Per-host surfaced default folds (the instantiate projection).
+ (edge {
+ source = collected "host:igloo" "homeManager";
+ target = rootT "host:igloo" "homeManager";
+ mode = "merge";
+ annotations = {
+ collectedScopes = [ "host:igloo" ];
+ };
+ })
+ (edge {
+ source = collected "host:igloo" "nixos";
+ target = rootT "host:igloo" "nixos";
+ mode = "merge";
+ annotations = {
+ collectedScopes = [ "host:igloo" ];
+ };
+ })
+ (edge {
+ source = collected "host:igloo" "os";
+ target = rootT "host:igloo" "nixos";
+ mode = "merge";
+ })
+ (edge {
+ source = collected "host:igloo" "os";
+ target = rootT "host:igloo" "nixos";
+ mode = "merge";
+ })
+ ];
+ }
+ );
+
+ # ===== (3) microvm-guest-style isolated kind + verbatim route =====
+ # Full list. The isolated guest gets its OWN default-fold edge (it is an
+ # entity-root because isolated) — isolation = edge-absence: the guest's nixos
+ # does NOT fold into the host root. The delivery route (reinstantiate=true,
+ # appendToParent, collectSubtree) is nest-verbatim into the host root.
+ test-topology-isolated-guest = denTest (
+ { den, lib, ... }:
+ let
+ guestEntity = {
+ name = "guest";
+ system = "x86_64-linux";
+ class = "nixos";
+ intoAttr = [ ];
+ users = { };
+ aspect = den.aspects.guest-aspect;
+ };
+ deliverPolicy = den.lib.policy.mkPolicy "deliver-iso" (
+ { ... }@args:
+ lib.optionals (!(args ? user) && !(args ? home)) [
+ (den.lib.policy.route {
+ fromClass = "nixos";
+ intoClass = "nixos";
+ collectSubtree = true;
+ appendToParent = true;
+ reinstantiate = true;
+ path = [
+ "microvm"
+ "vms"
+ "guest"
+ ];
+ })
+ ]
+ );
+ in
+ {
+ den.hosts.x86_64-linux.igloo.users = { };
+ den.schema.iso-kind = {
+ isEntity = true;
+ parent = "host";
+ isolated = true;
+ };
+ den.policies.resolve-iso-child =
+ { host, ... }:
+ lib.optionals (host.name == "igloo") [
+ (den.lib.policy.resolve.to.withIncludes "iso-kind" [ deliverPolicy ] { iso-kind = guestEntity; })
+ ];
+ den.schema.host.includes = [ den.policies.resolve-iso-child ];
+ den.aspects.guest-aspect.nixos.boot.kernelModules = [ "g" ];
+ den.aspects.igloo.nixos.networking.hostName = "igloo";
+
+ expr = hostTrace den "nixos" den.hosts.x86_64-linux.igloo;
+ expected = [
+ (edge {
+ source = collected "host:igloo" "homeManager";
+ target = rootT "host:igloo" "homeManager";
+ mode = "merge";
+ # The isolated guest scope is ABSENT from the host fold's collected
+ # subtree — isolation as edge-absence (spec §2).
+ annotations = {
+ collectedScopes = [ "host:igloo" ];
+ };
+ })
+ (edge {
+ source = collected "host:igloo" "nixos";
+ target = rootT "host:igloo" "nixos";
+ mode = "merge";
+ annotations = {
+ collectedScopes = [ "host:igloo" ];
+ };
+ })
+ (edge {
+ source = collected "host:igloo" "os";
+ target = rootT "host:igloo" "nixos";
+ mode = "merge";
+ })
+ # Verbatim delivery route from the isolated guest subtree into the host.
+ (edge {
+ source = collected "host=igloo,iso-kind=guest" "nixos";
+ target = rootT "host:igloo" "nixos";
+ path = [
+ "microvm"
+ "vms"
+ "guest"
+ ];
+ mode = "nest-verbatim";
+ annotations = {
+ appendToParent = true;
+ collectSubtree = true;
+ };
+ })
+ # The isolated guest's OWN default fold (it is its own entity-root).
+ (edge {
+ source = collected "host=igloo,iso-kind=guest" "nixos";
+ target = rootT "host=igloo,iso-kind=guest" "nixos";
+ mode = "merge";
+ annotations = {
+ collectedScopes = [ "host=igloo,iso-kind=guest" ];
+ };
+ })
+ ];
+ }
+ );
+
+ # ===== (4) standalone home (#605 synthetic host) ==================
+ # Flake-level resolve of a standalone home → a homeConfigurations output
+ # edge sourced from the system scope, the empty flake-root default folds,
+ # AND the per-host (per-home) default folds the instantiate projection
+ # surfaces (Task 18.2: the standalone home IS instantiated as a
+ # homeConfigurations output, so its per-host fold edges are present). Full list.
+ test-topology-standalone-home = denTest (
+ { den, ... }:
+ {
+ den.homes.x86_64-linux.solo.homeManager.home.username = "solo";
+
+ expr = flakeTrace den "flake";
+ expected = [
+ (edge {
+ source = collected "system=x86_64-linux" "homeManager";
+ target = outT [
+ "flake"
+ "homeConfigurations"
+ "solo"
+ ];
+ mode = "merge";
+ annotations = {
+ resolvedRootVia = "scope-link";
+ system = "x86_64-linux";
+ };
+ })
+ (edge {
+ source = collected "" "homeManager";
+ target = rootT "" "homeManager";
+ mode = "merge";
+ annotations = {
+ collectedScopes = [
+ ""
+ "home:solo"
+ "system=x86_64-linux"
+ ];
+ };
+ })
+ (edge {
+ source = collected "" "nixos";
+ target = rootT "" "nixos";
+ mode = "merge";
+ annotations = {
+ collectedScopes = [
+ ""
+ "home:solo"
+ "system=x86_64-linux"
+ ];
+ };
+ })
+ # Per-home default folds (the instantiate projection's surfaced edges).
+ (edge {
+ source = collected "home:solo" "homeManager";
+ target = rootT "home:solo" "homeManager";
+ mode = "merge";
+ annotations = {
+ collectedScopes = [ "home:solo" ];
+ };
+ })
+ (edge {
+ source = collected "home:solo" "nixos";
+ target = rootT "home:solo" "nixos";
+ mode = "merge";
+ annotations = {
+ collectedScopes = [ "home:solo" ];
+ };
+ })
+ ];
+ }
+ );
+
+ # ===== (5) home-extraction (host-projected HM onto a user) ========
+ # A host aspect projects homeManager content onto the user; the host-aspects
+ # battery spawns the user home node. Subset+count assertion: this topology's
+ # full list includes the user-forward tail and several default folds; the
+ # mechanism under test is the host's homeManager default fold reaching the
+ # user via the forward. We assert the user-forward synthesize edges are
+ # present (the extraction edge) plus the total count.
+ test-topology-home-extraction = denTest (
+ { den, lib, ... }:
+ let
+ trace = hostTrace den "nixos" den.hosts.x86_64-linux.igloo;
+ synthEdges = lib.filter (e: e.source ? synthesize) trace;
+ in
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.aspects.igloo.homeManager.home.sessionVariables.X = "y";
+ den.aspects.tux.includes = [ den.batteries.host-aspects ];
+
+ expr = {
+ # The HM-into-user forward (the extraction edge), targeting the user
+ # root. Production (Task 18.2) captures the kept route only — the legacy
+ # oracle's dedup-suppressed twin is absent.
+ synth = synthEdges;
+ # Stable total edge count for this topology.
+ count = builtins.length trace;
+ };
+ expected = {
+ synth = [
+ (edge {
+ source = synthesize "homeManager/nixos/home-manager/users/tux" "homeManager" "nixos";
+ target = rootT "user:tux" "nixos";
+ path = [
+ "home-manager"
+ "users"
+ "tux"
+ ];
+ mode = "nest";
+ annotations = {
+ complexForward = true;
+ sourceVia = "unresolved";
+ };
+ })
+ ];
+ count = 6;
+ };
+ }
+ );
+
+ # ===== (6) multi-system same-name (the @system arm) ===============
+ # Two homes named `ben` on different systems → two homeConfigurations output
+ # edges, each disambiguated to `ben@`. Full list.
+ test-topology-multi-system = denTest (
+ { den, ... }:
+ {
+ den.homes.x86_64-linux.ben.homeManager.home.username = "ben";
+ den.homes.aarch64-linux.ben.homeManager.home.username = "ben";
+
+ expr = flakeTrace den "flake";
+ expected = [
+ (edge {
+ source = collected "system=aarch64-linux" "homeManager";
+ target = outT [
+ "flake"
+ "homeConfigurations"
+ "ben@aarch64-linux"
+ ];
+ mode = "merge";
+ annotations = {
+ disambiguatedTo = "flake.homeConfigurations.ben@aarch64-linux";
+ resolvedRootVia = "scope-link";
+ system = "aarch64-linux";
+ };
+ })
+ (edge {
+ source = collected "system=x86_64-linux" "homeManager";
+ target = outT [
+ "flake"
+ "homeConfigurations"
+ "ben@x86_64-linux"
+ ];
+ mode = "merge";
+ annotations = {
+ disambiguatedTo = "flake.homeConfigurations.ben@x86_64-linux";
+ resolvedRootVia = "scope-link";
+ system = "x86_64-linux";
+ };
+ })
+ (edge {
+ source = collected "" "homeManager";
+ target = rootT "" "homeManager";
+ mode = "merge";
+ annotations = {
+ # Two distinct `ben` entities (different systems, distinct id_hashes
+ # collapsing to the same readable name) both fold into the flake root.
+ collectedScopes = [
+ ""
+ "home:ben"
+ "home:ben"
+ "system=aarch64-linux"
+ "system=x86_64-linux"
+ ];
+ };
+ })
+ (edge {
+ source = collected "" "nixos";
+ target = rootT "" "nixos";
+ mode = "merge";
+ annotations = {
+ collectedScopes = [
+ ""
+ "home:ben"
+ "home:ben"
+ "system=aarch64-linux"
+ "system=x86_64-linux"
+ ];
+ };
+ })
+ # Per-home default folds (one pair per `ben` instantiate; the two homes
+ # collapse to the same readable name but are distinct entity scopes).
+ (edge {
+ source = collected "home:ben" "homeManager";
+ target = rootT "home:ben" "homeManager";
+ mode = "merge";
+ annotations = {
+ collectedScopes = [ "home:ben" ];
+ };
+ })
+ (edge {
+ source = collected "home:ben" "nixos";
+ target = rootT "home:ben" "nixos";
+ mode = "merge";
+ annotations = {
+ collectedScopes = [ "home:ben" ];
+ };
+ })
+ (edge {
+ source = collected "home:ben" "homeManager";
+ target = rootT "home:ben" "homeManager";
+ mode = "merge";
+ annotations = {
+ collectedScopes = [ "home:ben" ];
+ };
+ })
+ (edge {
+ source = collected "home:ben" "nixos";
+ target = rootT "home:ben" "nixos";
+ mode = "merge";
+ annotations = {
+ collectedScopes = [ "home:ben" ];
+ };
+ })
+ ];
+ }
+ );
+
+ # ===== (7) darwin host (different class set) ======================
+ # The default-fold class set is darwin (not nixos); the os→darwin route and
+ # the homeManager→darwin user forward carry the darwin class. Full list.
+ test-topology-darwin = denTest (
+ { den, ... }:
+ {
+ den.hosts.aarch64-darwin.apple = {
+ users.tux = { };
+ };
+ den.aspects.apple.nixos.networking.hostName = "apple";
+
+ expr = hostTrace den "nixos" den.hosts.aarch64-darwin.apple;
+ expected = [
+ (edge {
+ source = collected "host:apple" "darwin";
+ target = rootT "host:apple" "darwin";
+ mode = "merge";
+ annotations = {
+ collectedScopes = [
+ "host:apple"
+ "user:tux"
+ ];
+ };
+ })
+ (edge {
+ source = collected "host:apple" "os";
+ target = rootT "host:apple" "darwin";
+ mode = "merge";
+ })
+ (edge {
+ source = collected "host:apple" "homeManager";
+ target = rootT "host:apple" "homeManager";
+ mode = "merge";
+ annotations = {
+ collectedScopes = [
+ "host:apple"
+ "user:tux"
+ ];
+ };
+ })
+ (edge {
+ source = collected "host:apple" "nixos";
+ target = rootT "host:apple" "nixos";
+ mode = "merge";
+ annotations = {
+ collectedScopes = [
+ "host:apple"
+ "user:tux"
+ ];
+ };
+ })
+ (edge {
+ source = collected "user:tux" "os";
+ target = rootT "user:tux" "darwin";
+ mode = "merge";
+ })
+ ]
+ ++ userForwardTail {
+ user = "tux";
+ os = "darwin";
+ };
+ }
+ );
+
+ # ===== (7b) spawn — NO rewalk edge at host level (Task 18.2) =======
+ # The host-aspects battery on a user emits a deferred policy.spawn marker. The
+ # LEGACY oracle (legacyEdgeTrace) renders that as a REWALK edge (the spawn
+ # UNDERCOUNT). The PRODUCTION object (edgeTrace) drops the rewalk arm: at HOST
+ # level the host is the ctx-seeded root (not a resolve.to-created entity scope
+ # in scopeEntityKind), so the drain-fold spawn arm is a no-op — neither the
+ # rewalk edge NOR a surfaced-spawn edge exists here. The surfaced-spawn edges
+ # only appear at FLAKE level (asserted in fx-unified-edges /
+ # fx-oracle-production-differential). So the production host trace carries NO
+ # rewalk-source edge.
+ test-topology-host-aspects-spawn = denTest (
+ { den, lib, ... }:
+ let
+ trace = hostTrace den "nixos" den.hosts.x86_64-linux.igloo;
+ spawnEdges = lib.filter (e: e.source ? rewalk) trace;
+ in
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ # A host aspect projecting homeManager content (gives the spawned class
+ # content; the spawn marker fires regardless of content).
+ den.aspects.igloo.homeManager.home.sessionVariables.X = "y";
+ den.aspects.tux.includes = [ den.batteries.host-aspects ];
+ den.aspects.igloo.nixos.networking.hostName = "igloo";
+
+ # The production host trace has NO rewalk-source edge.
+ expr = spawnEdges;
+ expected = [ ];
+ }
+ );
+
+ # ===== (8) fleet pipe value flowing through a delivery edge =======
+ # A fleet-collected pipe (host-addrs) feeds a host nixos consumer, and the
+ # host instantiate edge carries that into the flake output. Subset+count:
+ # we assert the instantiate output edge (the delivery edge the pipe value
+ # flows through) is present + the total count. The pipe VALUE itself is
+ # config-level (not an edge property); this fixture pins that the delivery
+ # topology is unchanged by pipe flow.
+ test-topology-fleet-pipe = denTest (
+ { den, lib, ... }:
+ let
+ trace = flakeTrace den "flake";
+ outEdges = lib.filter (e: e.target ? output) trace;
+ in
+ {
+ den.quirks.host-addrs.description = "addrs";
+ den.policies.to-fleet = _: [
+ (den.lib.policy.resolve.to "fleet" {
+ fleet = {
+ name = "fleet";
+ };
+ })
+ ];
+ den.policies.fleet-to-hosts =
+ { fleet, ... }:
+ lib.concatMap (
+ system:
+ lib.concatMap (
+ hostName:
+ let
+ host = den.hosts.${system}.${hostName};
+ in
+ [
+ (den.lib.policy.resolve.to "host" { inherit host; })
+ (den.lib.policy.instantiate host)
+ ]
+ ) (builtins.attrNames (den.hosts.${system} or { }))
+ ) (builtins.attrNames (den.hosts or { }));
+ den.policies.collect-addrs = _: [
+ (den.lib.policy.pipe.from "host-addrs" [ (den.lib.policy.pipe.collectAll ({ host, ... }: true)) ])
+ ];
+ den.schema.flake.includes = [ den.policies.to-fleet ];
+ den.schema.fleet.includes = [ den.policies.fleet-to-hosts ];
+ den.schema.host.includes = [ den.policies.collect-addrs ];
+ den.schema.flake-system.excludes = [
+ den.policies.system-to-os-outputs
+ den.policies.system-to-hm-outputs
+ ];
+ den.hosts.x86_64-linux.igloo.users = { };
+ den.aspects.igloo.host-addrs =
+ { host, ... }:
+ {
+ hostname = host.name;
+ };
+ den.aspects.igloo.nixos =
+ { host-addrs, lib, ... }:
+ {
+ networking.extraHosts = lib.concatStringsSep "," (map (e: e.hostname) host-addrs);
+ };
+
+ expr = {
+ outputs = outEdges;
+ count = builtins.length trace;
+ };
+ expected = {
+ # The host's flake-output delivery edge (pipe value flows through it).
+ outputs = [
+ (edge {
+ source = collected "fleet=fleet" "nixos";
+ target = outT [
+ "flake"
+ "nixosConfigurations"
+ "igloo"
+ ];
+ mode = "merge";
+ annotations = {
+ resolvedRootVia = "scope-link";
+ system = "x86_64-linux";
+ };
+ })
+ ];
+ # Production object (Task 18.2): the top-level folds + the per-host
+ # surfaced fold/route edges the instantiate projection adds (the pipe
+ # host-addrs class adds its own per-scope folds too).
+ count = 9;
+ };
+ }
+ );
+
+ # ===== rule-corollary tests (spec §5.3) ===========================
+
+ # Default-fold-edge existence: every entity-root scope with class content
+ # has a merge edge collected(root, class) → (root, class), P=[].
+ test-corollary-default-fold-exists = denTest (
+ { den, lib, ... }:
+ let
+ trace = hostTrace den "nixos" den.hosts.x86_64-linux.igloo;
+ hostFold = lib.filter (
+ e:
+ e.mode == "merge"
+ && e.path == [ ]
+ && e.source ? collected
+ && e.source.collected.scope == "host:igloo"
+ && e.source.collected.class == "nixos"
+ && e.target ? root
+ && e.target.root == "host:igloo"
+ && e.target.class == "nixos"
+ ) trace;
+ in
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.aspects.igloo.nixos.networking.hostName = "igloo";
+ expr = builtins.length hostFold;
+ expected = 1;
+ }
+ );
+
+ # Isolation-as-edge-absence: an isolated child contributes NO content to the
+ # parent root's default fold. The host root fold COLLECTS from a subtree
+ # (annotations.collectedScopes); isolation means the guest scope is ABSENT
+ # from that set — the strongest available assertion (a default fold always
+ # SOURCES from the root scope name, so a source-scope filter alone is
+ # structurally vacuous; the collectedScopes membership has real teeth: if
+ # subtreeScopesOf stopped honoring isolation, the guest scope would appear
+ # here and the test goes red — verified by scratch-flipping the isolation
+ # gate in subtreeScopesOf).
+ test-corollary-isolation-edge-absence = denTest (
+ { den, lib, ... }:
+ let
+ guestEntity = {
+ name = "guest";
+ system = "x86_64-linux";
+ class = "nixos";
+ intoAttr = [ ];
+ users = { };
+ aspect = den.aspects.guest-aspect;
+ };
+ guestScope = "host=igloo,iso-kind=guest";
+ trace = hostTrace den "nixos" den.hosts.x86_64-linux.igloo;
+ # The host root's nixos default fold (merge, P=[], sources+targets the
+ # host root). Its collectedScopes is the isolation-aware subtree.
+ hostNixosFold = lib.filter (
+ e:
+ e.mode == "merge"
+ && e.path == [ ]
+ && e.target ? root
+ && e.target.root == "host:igloo"
+ && e.target.class == "nixos"
+ && e.source ? collected
+ && e.source.collected.scope == "host:igloo"
+ && e.source.collected.class == "nixos"
+ ) trace;
+ hostFoldScopes = (builtins.head hostNixosFold).annotations.collectedScopes;
+ # The isolated guest's OWN fold (own entity-root), and its collected set.
+ guestFold = lib.filter (
+ e:
+ e.mode == "merge"
+ && e.path == [ ]
+ && e.target ? root
+ && e.target.root == guestScope
+ && e.source ? collected
+ && e.source.collected.scope == guestScope
+ ) trace;
+ in
+ {
+ den.hosts.x86_64-linux.igloo.users = { };
+ den.schema.iso-kind = {
+ isEntity = true;
+ parent = "host";
+ isolated = true;
+ };
+ den.policies.resolve-iso-child =
+ { host, ... }:
+ lib.optionals (host.name == "igloo") [
+ (den.lib.policy.resolve.to "iso-kind" { iso-kind = guestEntity; })
+ ];
+ den.schema.host.includes = [ den.policies.resolve-iso-child ];
+ den.aspects.guest-aspect.nixos.boot.kernelModules = [ "g" ];
+ den.aspects.igloo.nixos.networking.hostName = "igloo";
+
+ expr = {
+ # Exactly one host nixos fold, and the guest scope is NOT in its
+ # collected subtree (the isolation edge-absence).
+ hostFoldExists = builtins.length hostNixosFold == 1;
+ guestInHostFold = builtins.elem guestScope hostFoldScopes;
+ # The guest's OWN fold exists and collects from ITSELF (it IS an
+ # entity-root, so its subtree contains its own scope).
+ guestOwnFold = builtins.length guestFold == 1;
+ guestInOwnFold = builtins.elem guestScope (builtins.head guestFold).annotations.collectedScopes;
+ };
+ expected = {
+ hostFoldExists = true;
+ guestInHostFold = false;
+ guestOwnFold = true;
+ guestInOwnFold = true;
+ };
+ }
+ );
+
+ # Verbatim mode on a reinstantiate route.
+ test-corollary-verbatim-mode = denTest (
+ { den, lib, ... }:
+ let
+ guestEntity = {
+ name = "guest";
+ system = "x86_64-linux";
+ class = "nixos";
+ intoAttr = [ ];
+ users = { };
+ aspect = den.aspects.guest-aspect;
+ };
+ deliverPolicy = den.lib.policy.mkPolicy "deliver-iso" (
+ { ... }@args:
+ lib.optionals (!(args ? user) && !(args ? home)) [
+ (den.lib.policy.route {
+ fromClass = "nixos";
+ intoClass = "nixos";
+ collectSubtree = true;
+ appendToParent = true;
+ reinstantiate = true;
+ path = [
+ "microvm"
+ "vms"
+ "guest"
+ ];
+ })
+ ]
+ );
+ trace = hostTrace den "nixos" den.hosts.x86_64-linux.igloo;
+ verbatim = lib.filter (e: e.mode == "nest-verbatim") trace;
+ in
+ {
+ den.hosts.x86_64-linux.igloo.users = { };
+ den.schema.iso-kind = {
+ isEntity = true;
+ parent = "host";
+ isolated = true;
+ };
+ den.policies.resolve-iso-child =
+ { host, ... }:
+ lib.optionals (host.name == "igloo") [
+ (den.lib.policy.resolve.to.withIncludes "iso-kind" [ deliverPolicy ] { iso-kind = guestEntity; })
+ ];
+ den.schema.host.includes = [ den.policies.resolve-iso-child ];
+ den.aspects.guest-aspect.nixos.boot.kernelModules = [ "g" ];
+ den.aspects.igloo.nixos.networking.hostName = "igloo";
+
+ expr = {
+ count = builtins.length verbatim;
+ path = (builtins.head verbatim).path;
+ };
+ expected = {
+ count = 1;
+ path = [
+ "microvm"
+ "vms"
+ "guest"
+ ];
+ };
+ }
+ );
+
+ # Suppression annotation ABSENT in the production object: the production edge
+ # object (Task 18.2) CAPTURES the edges its fold dispatched (kept routes only),
+ # so the legacy oracle's dedup-suppressed twin — which carried
+ # `suppressed = true` — is never present. The suppressed-twin edge lives in
+ # legacyEdgeTrace, asserted by the fx-oracle-production-differential suite.
+ test-corollary-suppression-annotation = denTest (
+ { den, lib, ... }:
+ let
+ trace = hostTrace den "nixos" den.hosts.x86_64-linux.igloo;
+ suppressed = lib.filter (e: e.annotations.suppressed or false) trace;
+ in
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.aspects.igloo.nixos.networking.hostName = "igloo";
+ expr = builtins.length suppressed;
+ expected = 0;
+ }
+ );
+
+ # ===== stability: identical trace across two resolve calls =========
+ test-stability-identical-across-runs = denTest (
+ { den, ... }:
+ let
+ a = hostTrace den "nixos" den.hosts.x86_64-linux.igloo;
+ b = hostTrace den "nixos" den.hosts.x86_64-linux.igloo;
+ in
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.aspects.igloo.nixos.networking.hostName = "igloo";
+ # Two independent resolves of the same config produce byte-identical
+ # traces (sort applied; extraction pure).
+ expr = a == b;
+ expected = true;
+ }
+ );
+ };
+}
diff --git a/templates/ci/modules/internal-api/entity-gen-schema.nix b/templates/ci/modules/internal-api/entity-gen-schema.nix
new file mode 100644
index 000000000..711e74523
--- /dev/null
+++ b/templates/ci/modules/internal-api/entity-gen-schema.nix
@@ -0,0 +1,112 @@
+{ denTest, ... }:
+{
+ flake.tests.entity-gen-schema = {
+ # gen-schema id_hash available on hosts
+ test-entity-host-id-hash = denTest (
+ { den, ... }:
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+
+ expr = {
+ hasIdHash = den.hosts.x86_64-linux.igloo ? id_hash;
+ isString = builtins.isString den.hosts.x86_64-linux.igloo.id_hash;
+ };
+ expected = {
+ hasIdHash = true;
+ isString = true;
+ };
+ }
+ );
+
+ # id_hash differs between different hosts
+ test-entity-id-hash-differs = denTest (
+ { den, ... }:
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.hosts.x86_64-linux.tundra.users.tux = { };
+
+ expr = den.hosts.x86_64-linux.igloo.id_hash != den.hosts.x86_64-linux.tundra.id_hash;
+ expected = true;
+ }
+ );
+
+ # Freeform: arbitrary attributes on hosts not rejected
+ test-entity-freeform = denTest (
+ { den, ... }:
+ {
+ den.hosts.x86_64-linux.igloo = {
+ users.tux = { };
+ customAttr = "hello";
+ };
+
+ expr = den.hosts.x86_64-linux.igloo.customAttr;
+ expected = "hello";
+ }
+ );
+
+ # gen-schema _meta.topology derived from parent collections
+ test-entity-topology = denTest (
+ { den, ... }:
+ {
+ expr = den.schema._topology.host.children;
+ expected = [
+ "home"
+ "user"
+ ];
+ }
+ );
+
+ # gen-schema _topology is available
+ test-entity-topology-available = denTest (
+ { den, ... }:
+ {
+ expr = den.schema ? _topology;
+ expected = true;
+ }
+ );
+
+ # _roots (parentless kinds) is the buildRoots root-detection contract:
+ # host is a root scope; user/home are spawned under host, not roots.
+ test-entity-roots = denTest (
+ { den, ... }:
+ {
+ expr = {
+ hostIsRoot = builtins.elem "host" den.schema._roots;
+ userNotRoot = builtins.elem "user" den.schema._roots;
+ homeNotRoot = builtins.elem "home" den.schema._roots;
+ };
+ expected = {
+ hostIsRoot = true;
+ userNotRoot = false;
+ homeNotRoot = false;
+ };
+ }
+ );
+
+ # Schema entry has isEntity computed correctly
+ test-entity-is-entity = denTest (
+ { den, ... }:
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+
+ expr = {
+ hostIsEntity = den.schema.host.isEntity;
+ flakeIsEntity = den.schema.flake.isEntity;
+ };
+ expected = {
+ hostIsEntity = true;
+ flakeIsEntity = false;
+ };
+ }
+ );
+
+ # Schema entry has includes sidecar
+ test-entity-schema-includes = denTest (
+ { den, ... }:
+ {
+ expr = builtins.isList den.schema.host.includes;
+ expected = true;
+ }
+ );
+ };
+}
diff --git a/templates/ci/modules/internal-api/fx-aspect.nix b/templates/ci/modules/internal-api/fx-aspect.nix
index 3f7041e56..b53792674 100644
--- a/templates/ci/modules/internal-api/fx-aspect.nix
+++ b/templates/ci/modules/internal-api/fx-aspect.nix
@@ -20,7 +20,6 @@ let
// handlers.checkDedupHandler
// handlers.constraintRegistryHandler
// handlers.chainHandler
- // den.lib.aspects.fx.identity.pathSetHandler
// den.lib.aspects.fx.identity.collectPathsHandler
// handlers.resolveHandler
// handlers.compileHandler
@@ -65,7 +64,6 @@ let
currentScope = "__test";
scopedIncludesChain = _: { };
scopedConstraintRegistry = _: { };
- scopedConstraintFilters = _: { };
paths = [ ];
};
in
diff --git a/templates/ci/modules/internal-api/fx-bind-subsystem.nix b/templates/ci/modules/internal-api/fx-bind-subsystem.nix
index 12e4d7e56..6a7934df6 100644
--- a/templates/ci/modules/internal-api/fx-bind-subsystem.nix
+++ b/templates/ci/modules/internal-api/fx-bind-subsystem.nix
@@ -45,21 +45,24 @@
}
);
- # bind: missing scope handlers for required args → defers, returns { deferred = true }.
+ # bind: missing scope handlers for a NON-entity required arg → defers,
+ # returns { deferred = true }. (Entity-kind args never defer — bind classifies
+ # them fan-out/inert/ctx; this test exercises the generic defer path for a
+ # plain (pipe/enrichment-style) arg.)
test-bind-defers-missing = denTest (
{ den, ... }:
let
fx = den.lib.fx;
handlers = den.lib.aspects.fx.handlers;
aspect = {
- name = "needs-host";
+ name = "needs-widget";
__fn =
- { host }:
+ { widget }:
{
- inherit host;
+ inherit widget;
};
__args = {
- host = false;
+ widget = false;
};
};
compileFn = _: fx.pure { compiled = true; };
@@ -157,10 +160,13 @@
};
};
};
+ # Non-entity arg: defer's loud guard throws on entity-kind args (those
+ # are classified in bind), so generic queue/stub mechanics are exercised
+ # with a plain (pipe/enrichment-style) key.
comp = fx.send "defer" {
inherit child;
- requiredKeys = [ "host" ];
- requiredArgs = [ "host" ];
+ requiredKeys = [ "widget" ];
+ requiredArgs = [ "widget" ];
};
result = fx.handle {
handlers = handlers.deferHandler // stubCapture;
diff --git a/templates/ci/modules/internal-api/fx-compile-conditional.nix b/templates/ci/modules/internal-api/fx-compile-conditional.nix
index 6441fb281..7c9cc3b86 100644
--- a/templates/ci/modules/internal-api/fx-compile-conditional.nix
+++ b/templates/ci/modules/internal-api/fx-compile-conditional.nix
@@ -29,9 +29,14 @@
identity = "cond-node";
ctx = { };
};
+ # Guards read membership from `pathSetByScope`, scoped to the current
+ # scope + ancestors (#613). defaultState's currentScope is "__unscoped",
+ # so seed dep-a there.
state = den.lib.aspects.fx.pipeline.defaultState // {
- pathSet = _: {
- ${identity.key { name = "dep-a"; }} = true;
+ pathSetByScope = _: {
+ "__unscoped" = {
+ ${identity.key { name = "dep-a"; }} = true;
+ };
};
};
# Stub downstream effects that emitIncludes triggers.
@@ -90,7 +95,6 @@
// handlers.resolveChildrenHandler
// handlers.checkDedupHandler
// handlers.chainHandler
- // identity.pathSetHandler
// identity.collectPathsHandler
// stubs
// fx.effects.state.handler;
@@ -147,7 +151,6 @@
handlers.compileConditionalHandler
// handlers.deferConditionalHandler
// handlers.drainConditionalsHandler
- // identity.pathSetHandler
// stubs
// fx.effects.state.handler;
inherit state;
@@ -204,7 +207,6 @@
handlers.compileConditionalHandler
// handlers.deferConditionalHandler
// handlers.drainConditionalsHandler
- // identity.pathSetHandler
// stubs
// fx.effects.state.handler;
inherit state;
@@ -248,11 +250,7 @@
captured = builtins.unsafeGetAttrPos "capture" {
capture = null;
};
- state = den.lib.aspects.fx.pipeline.defaultState // {
- pathSet = _: {
- "anything" = true;
- };
- };
+ state = den.lib.aspects.fx.pipeline.defaultState;
stubs = {
"get" =
{ param, state }:
@@ -309,7 +307,6 @@
// handlers.resolveChildrenHandler
// handlers.checkDedupHandler
// handlers.chainHandler
- // identity.pathSetHandler
// identity.collectPathsHandler
// stubs
// fx.effects.state.handler;
diff --git a/templates/ci/modules/internal-api/fx-compile-parametric.nix b/templates/ci/modules/internal-api/fx-compile-parametric.nix
index 0f2322990..5e90f6cc9 100644
--- a/templates/ci/modules/internal-api/fx-compile-parametric.nix
+++ b/templates/ci/modules/internal-api/fx-compile-parametric.nix
@@ -72,7 +72,6 @@
handlers.compileParametricHandler
// handlers.gateHandler
// handlers.bindHandler
- // identity.pathSetHandler
// identity.collectPathsHandler
// stubs;
inherit state;
@@ -149,7 +148,6 @@
handlers.compileParametricHandler
// handlers.gateHandler
// handlers.bindHandler
- // identity.pathSetHandler
// identity.collectPathsHandler
// stubs;
inherit state;
@@ -219,7 +217,6 @@
// handlers.gateHandler
// handlers.bindHandler
// handlers.deferHandler
- // identity.pathSetHandler
// identity.collectPathsHandler
// stubs;
inherit state;
diff --git a/templates/ci/modules/internal-api/fx-compile-static.nix b/templates/ci/modules/internal-api/fx-compile-static.nix
index 442d51df0..d22628058 100644
--- a/templates/ci/modules/internal-api/fx-compile-static.nix
+++ b/templates/ci/modules/internal-api/fx-compile-static.nix
@@ -79,11 +79,7 @@
comp = fx.send "compile-static" param;
result = fx.handle {
handlers =
- handlers.compileStaticHandler
- // handlers.gateHandler
- // identity.pathSetHandler
- // identity.collectPathsHandler
- // stubs;
+ handlers.compileStaticHandler // handlers.gateHandler // identity.collectPathsHandler // stubs;
inherit state;
} comp;
resolved = builtins.head result.value;
@@ -277,11 +273,7 @@
comp = fx.send "compile-static" param;
result = fx.handle {
handlers =
- handlers.compileStaticHandler
- // handlers.gateHandler
- // identity.pathSetHandler
- // identity.collectPathsHandler
- // stubs;
+ handlers.compileStaticHandler // handlers.gateHandler // identity.collectPathsHandler // stubs;
inherit state;
} comp;
resolved = builtins.head result.value;
@@ -374,11 +366,7 @@
comp = fx.send "compile-static" param;
result = fx.handle {
handlers =
- handlers.compileStaticHandler
- // handlers.gateHandler
- // identity.pathSetHandler
- // identity.collectPathsHandler
- // stubs;
+ handlers.compileStaticHandler // handlers.gateHandler // identity.collectPathsHandler // stubs;
inherit state;
} comp;
resolved = builtins.head result.value;
@@ -468,11 +456,7 @@
comp = fx.send "compile-static" param;
result = fx.handle {
handlers =
- handlers.compileStaticHandler
- // handlers.gateHandler
- // identity.pathSetHandler
- // identity.collectPathsHandler
- // stubs;
+ handlers.compileStaticHandler // handlers.gateHandler // identity.collectPathsHandler // stubs;
inherit state;
} comp;
resolved = builtins.head result.value;
diff --git a/templates/ci/modules/internal-api/fx-constraints.nix b/templates/ci/modules/internal-api/fx-constraints.nix
index 81006529f..c909a396f 100644
--- a/templates/ci/modules/internal-api/fx-constraints.nix
+++ b/templates/ci/modules/internal-api/fx-constraints.nix
@@ -83,7 +83,6 @@
state = {
currentScope = "__test";
scopedConstraintRegistry = _: { };
- scopedConstraintFilters = _: { };
scopedIncludesChain = _: { };
};
} comp;
@@ -104,7 +103,6 @@
state = {
currentScope = "__test";
scopedConstraintRegistry = _: { };
- scopedConstraintFilters = _: { };
scopedIncludesChain = _: { };
};
} comp;
@@ -141,7 +139,6 @@
state = {
currentScope = "__test";
scopedConstraintRegistry = _: { };
- scopedConstraintFilters = _: { };
scopedIncludesChain = _: { };
};
} comp;
@@ -255,8 +252,13 @@
} comp;
in
{
- # root + keep = 2 paths in pathSet, drop is tombstoned and excluded
- expr = builtins.length (builtins.attrNames ((result.state.pathSet) null));
+ # root + keep = 2 paths in the membership set (union of per-scope
+ # buckets); drop is tombstoned and excluded.
+ expr = builtins.length (
+ builtins.attrNames (
+ den.lib.aspects.fx.identity.flattenPathSetByScope ((result.state.pathSetByScope) null)
+ )
+ );
expected = 2;
}
);
@@ -390,7 +392,6 @@
currentScope = "__test";
scopedIncludesChain = _: { };
scopedConstraintRegistry = _: { };
- scopedConstraintFilters = _: { };
};
} comp;
in
@@ -432,7 +433,6 @@
currentScope = "__test";
scopedIncludesChain = _: { };
scopedConstraintRegistry = _: { };
- scopedConstraintFilters = _: { };
};
} comp;
in
@@ -474,7 +474,6 @@
currentScope = "__test";
scopedIncludesChain = _: { };
scopedConstraintRegistry = _: { };
- scopedConstraintFilters = _: { };
};
} comp;
in
@@ -510,7 +509,6 @@
currentScope = "__test";
scopedIncludesChain = _: { };
scopedConstraintRegistry = _: { };
- scopedConstraintFilters = _: { };
};
} comp;
in
@@ -552,7 +550,6 @@
currentScope = "__test";
scopedIncludesChain = _: { };
scopedConstraintRegistry = _: { };
- scopedConstraintFilters = _: { };
};
} comp;
in
diff --git a/templates/ci/modules/internal-api/fx-edge-parity.nix b/templates/ci/modules/internal-api/fx-edge-parity.nix
new file mode 100644
index 000000000..23538f96a
--- /dev/null
+++ b/templates/ci/modules/internal-api/fx-edge-parity.nix
@@ -0,0 +1,220 @@
+# fx-edge-parity — the Task 19 cross-pipeline parity gate. Exercises the
+# `assertEdgeParity` helper (nix/lib/aspects/fx/edges/parity.nix) over a corpus of
+# the parity-critical delivery-edge topologies (spawn, instantiate/fleet,
+# isolated-guest, plain host+user — the same topologies the unification gate and
+# the oracle-production differential cover).
+#
+# Two flavours of assertion:
+#
+# (1) IDENTITY GATE (per corpus topology) — diff a trace against ITSELF. A trace
+# is trivially parity-equal to itself, so this is NOT testing edge logic; it
+# proves three things at once:
+# - the harness is sound (a self-diff yields parity == true, empty deltas);
+# - the corpus topologies resolve (r.edgeTrace evaluates);
+# - each trace is NON-EMPTY (matched != [] → there is real content to diff,
+# so the gate is not vacuously green on an empty trace).
+#
+# (2) NEGATIVE CONTROL — on a spawn topology, diff the production `edgeTrace`
+# against the legacy `legacyEdgeTrace` (the rewalk + suppressed-twin
+# re-derivation). These genuinely diverge (the spawn rewalk arm vs the real
+# surfaced fold edges), so `parity == false`. This proves `assertEdgeParity`
+# actually DETECTS divergence — without it, the identity gate alone could pass
+# on a helper that always returns parity == true.
+#
+# `just ci fx-edge-parity` runs this suite.
+{ denTest, lib, ... }:
+let
+ # The fleet → hosts include policy shared by the spawn + instantiate topologies
+ # (verbatim from fx-edge-unification-gate.nix): a flake-level resolve that fans
+ # out to each host with an instantiate spec.
+ fleetSetup = den: lib: {
+ den.policies.to-fleet = _: [
+ (den.lib.policy.resolve.to "fleet" {
+ fleet = {
+ name = "fleet";
+ };
+ })
+ ];
+ den.policies.fleet-to-hosts =
+ { fleet, ... }:
+ lib.concatMap (
+ system:
+ lib.concatMap (
+ hostName:
+ let
+ host = den.hosts.${system}.${hostName};
+ in
+ [
+ (den.lib.policy.resolve.to "host" { inherit host; })
+ (den.lib.policy.instantiate host)
+ ]
+ ) (builtins.attrNames (den.hosts.${system} or { }))
+ ) (builtins.attrNames (den.hosts or { }));
+ den.schema.flake.includes = [ den.policies.to-fleet ];
+ den.schema.fleet.includes = [ den.policies.fleet-to-hosts ];
+ den.schema.flake-system.excludes = [
+ den.policies.system-to-os-outputs
+ den.policies.system-to-hm-outputs
+ ];
+ };
+
+ # The identity-gate assertion, shared by every corpus topology. `edges` is the
+ # trace under test; a trace is parity-equal to itself with empty asymmetric
+ # deltas, and matched must be non-empty (proving the trace carries content).
+ identityGate =
+ den: edges:
+ let
+ diff = den.lib.aspects.fx.edges.parity.assertEdgeParity {
+ expected = edges;
+ actual = edges;
+ };
+ in
+ {
+ parity = diff.parity;
+ matchedNonEmpty = diff.matched != [ ];
+ noMissing = diff.missingFromActual == [ ];
+ noExtra = diff.extraInActual == [ ];
+ };
+
+ identityExpected = {
+ parity = true;
+ matchedNonEmpty = true;
+ noMissing = true;
+ noExtra = true;
+ };
+in
+{
+ flake.tests.fx-edge-parity = {
+
+ # ===== IDENTITY GATE: SPAWN topology (flake-level, host-aspects battery) ==
+ test-identity-spawn = denTest (
+ { den, lib, ... }:
+ let
+ r = den.lib.aspects.resolveWithPaths "flake" (den.lib.resolveEntity "flake" { });
+ in
+ fleetSetup den lib
+ // {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.aspects.igloo.homeManager.home.sessionVariables.X = "y";
+ den.aspects.tux.includes = [ den.batteries.host-aspects ];
+ den.aspects.igloo.nixos.networking.hostName = "igloo";
+
+ expr = identityGate den r.edgeTrace;
+ expected = identityExpected;
+ }
+ );
+
+ # ===== IDENTITY GATE: INSTANTIATE / fleet topology (flake-level) ==========
+ test-identity-instantiate = denTest (
+ { den, lib, ... }:
+ let
+ r = den.lib.aspects.resolveWithPaths "flake" (den.lib.resolveEntity "flake" { });
+ in
+ fleetSetup den lib
+ // {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.aspects.igloo.nixos.networking.hostName = "igloo";
+
+ expr = identityGate den r.edgeTrace;
+ expected = identityExpected;
+ }
+ );
+
+ # ===== IDENTITY GATE: ISOLATED-GUEST topology (host-level route) ==========
+ test-identity-isolated-guest = denTest (
+ { den, lib, ... }:
+ let
+ guestEntity = {
+ name = "guest";
+ system = "x86_64-linux";
+ class = "nixos";
+ intoAttr = [ ];
+ users = { };
+ aspect = den.aspects.guest-aspect;
+ };
+ deliverPolicy = den.lib.policy.mkPolicy "deliver-iso" (
+ { ... }@args:
+ lib.optionals (!(args ? user) && !(args ? home)) [
+ (den.lib.policy.route {
+ fromClass = "nixos";
+ intoClass = "nixos";
+ collectSubtree = true;
+ appendToParent = true;
+ reinstantiate = true;
+ path = [
+ "microvm"
+ "vms"
+ "guest"
+ ];
+ })
+ ]
+ );
+ r = den.lib.aspects.resolveWithPaths "nixos" (
+ den.lib.resolveEntity "host" { host = den.hosts.x86_64-linux.igloo; }
+ );
+ in
+ {
+ den.hosts.x86_64-linux.igloo.users = { };
+ den.schema.iso-kind = {
+ isEntity = true;
+ parent = "host";
+ isolated = true;
+ };
+ den.policies.resolve-iso-child =
+ { host, ... }:
+ lib.optionals (host.name == "igloo") [
+ (den.lib.policy.resolve.to.withIncludes "iso-kind" [ deliverPolicy ] { iso-kind = guestEntity; })
+ ];
+ den.schema.host.includes = [ den.policies.resolve-iso-child ];
+ den.aspects.guest-aspect.nixos.boot.kernelModules = [ "g" ];
+ den.aspects.igloo.nixos.networking.hostName = "igloo";
+
+ expr = identityGate den r.edgeTrace;
+ expected = identityExpected;
+ }
+ );
+
+ # ===== IDENTITY GATE: PLAIN host+user (no spawn, host-level) ==============
+ test-identity-plain = denTest (
+ { den, ... }:
+ let
+ r = den.lib.aspects.resolveWithPaths "nixos" (
+ den.lib.resolveEntity "host" { host = den.hosts.x86_64-linux.igloo; }
+ );
+ in
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.aspects.igloo.nixos.networking.hostName = "igloo";
+
+ expr = identityGate den r.edgeTrace;
+ expected = identityExpected;
+ }
+ );
+
+ # ===== NEGATIVE CONTROL: spawn production vs legacy diverges ==============
+ # Diffing the production edgeTrace against the legacy legacyEdgeTrace on a spawn
+ # topology MUST yield parity == false (the rewalk arm + suppressed twins vs the
+ # real surfaced fold edges) — proving the helper detects divergence and the
+ # identity gate is not vacuous.
+ test-negative-control-spawn = denTest (
+ { den, lib, ... }:
+ let
+ r = den.lib.aspects.resolveWithPaths "flake" (den.lib.resolveEntity "flake" { });
+ diff = den.lib.aspects.fx.edges.parity.assertEdgeParity {
+ expected = r.edgeTrace;
+ actual = r.legacyEdgeTrace;
+ };
+ in
+ fleetSetup den lib
+ // {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.aspects.igloo.homeManager.home.sessionVariables.X = "y";
+ den.aspects.tux.includes = [ den.batteries.host-aspects ];
+ den.aspects.igloo.nixos.networking.hostName = "igloo";
+
+ expr = diff.parity;
+ expected = false;
+ }
+ );
+ };
+}
diff --git a/templates/ci/modules/internal-api/fx-edge-unification-gate.nix b/templates/ci/modules/internal-api/fx-edge-unification-gate.nix
new file mode 100644
index 000000000..9573b57d5
--- /dev/null
+++ b/templates/ci/modules/internal-api/fx-edge-unification-gate.nix
@@ -0,0 +1,410 @@
+# fx-edge-unification-gate — the lighter Task-16 gate. For the parity-critical
+# topologies it proves the unified delivery-edge set is BOTH complete and validly
+# orderable, and that the shared toposort entry is loud on a real cycle:
+#
+# (1) COMPLETENESS — unifiedEdges ⊇ (edgeTrace MINUS its rewalk-source edges),
+# i.e. every non-rewalk oracle edge survives, PLUS the newly-surfaced edges
+# (the spawn route/default-fold edges for spawn topologies; the per-host
+# default-fold + route edges for instantiate topologies).
+#
+# (2) VALID ORDER — `topoSortEdges unifiedEdges` SUCCEEDS (does not throw → the
+# unified set is acyclic and every dep is satisfiable) AND is a permutation
+# of unifiedEdges (identical edge multiset by the normalized sort key). Plus
+# a producer-before-merge spot-check: a route/provides producer edge appears
+# at a SMALLER index than the default-fold merge edge of the same class+root
+# that reads it.
+#
+# (3) CYCLE THROWS — a deliberately-cyclic edge set (a synthesize 2-cycle, as in
+# fx-toposort-edges.nix) passed through the SAME `topoSortEdges` entry the
+# unified set uses THROWS the loud cycle error.
+#
+# `unifiedEdges` is reached the way fx-unified-edges.nix reaches it: it sits beside
+# `edgeTrace` on the resolveWithPaths result. The spawn + instantiate topologies
+# resolve at FLAKE level (the drain-fold spawn + mkInstantiateEdges projections
+# only surface there — at host level the host is the ctx-seeded root, so those arms
+# are no-ops, spec 16.3).
+#
+# `just ci fx-edge-unification-gate` runs this suite.
+{ denTest, lib, ... }:
+let
+ # Stable sort key mirroring edges/edge.nix edgeSortKey (T, P, S, M), so two edge
+ # lists are compared as normalized MULTISETS regardless of construction order.
+ targetKey =
+ t: if t ? output then "out:${lib.concatStringsSep "." t.output}" else "root:${t.root}/${t.class}";
+ pathKey = p: lib.concatStringsSep "/" p;
+ sourceKey =
+ s:
+ if s ? collected then
+ "collected:${s.collected.scope}/${s.collected.class}"
+ else if s ? rewalk then
+ "rewalk:${s.rewalk.aspect}/${lib.concatStringsSep "+" s.rewalk.bindings}/${s.rewalk.class}"
+ else if s ? synthesize then
+ "synthesize:${s.synthesize.forwardId}/${s.synthesize.fromClass}>${s.synthesize.intoClass}"
+ else
+ "empty";
+ edgeKey =
+ e:
+ lib.concatStringsSep " | " [
+ (targetKey e.target)
+ (pathKey e.path)
+ (sourceKey e.source)
+ e.mode
+ ];
+
+ # Completeness: every edge in `sub` is present in `super` (by normalized key).
+ keySet = edges: lib.genAttrs (map edgeKey edges) (_: true);
+ isSubset = sub: super: lib.all (e: (keySet super) ? ${edgeKey e}) sub;
+
+ # Multiset equality by sorted key lists (a permutation has the same edges in any
+ # order). Counts duplicates correctly (sorted-list compare, not set compare).
+ sameMultiset =
+ a: b: lib.sort (x: y: x < y) (map edgeKey a) == lib.sort (x: y: x < y) (map edgeKey b);
+
+ # The fleet → hosts include policy shared by the spawn + instantiate topologies
+ # (verbatim from fx-unified-edges.nix / delivery-edges.nix): a flake-level resolve
+ # that fans out to each host with an instantiate spec.
+ fleetSetup = den: lib: {
+ den.policies.to-fleet = _: [
+ (den.lib.policy.resolve.to "fleet" {
+ fleet = {
+ name = "fleet";
+ };
+ })
+ ];
+ den.policies.fleet-to-hosts =
+ { fleet, ... }:
+ lib.concatMap (
+ system:
+ lib.concatMap (
+ hostName:
+ let
+ host = den.hosts.${system}.${hostName};
+ in
+ [
+ (den.lib.policy.resolve.to "host" { inherit host; })
+ (den.lib.policy.instantiate host)
+ ]
+ ) (builtins.attrNames (den.hosts.${system} or { }))
+ ) (builtins.attrNames (den.hosts or { }));
+ den.schema.flake.includes = [ den.policies.to-fleet ];
+ den.schema.fleet.includes = [ den.policies.fleet-to-hosts ];
+ den.schema.flake-system.excludes = [
+ den.policies.system-to-os-outputs
+ den.policies.system-to-hm-outputs
+ ];
+ };
+
+ # The valid-order assertion, shared by every topology. `unified` is the unified
+ # edge set under test. Returns the booleans the gate pins:
+ # sortSucceeds — topoSortEdges did not throw (acyclic + satisfiable).
+ # sortIsPermutation — the sorted output is the same multiset as the input.
+ # producerBeforeMerge — at least one producer (route/provides nest edge)
+ # precedes the default-fold merge of the same root+class
+ # that READS its cell, and none of those producer/merge
+ # pairs is mis-ordered.
+ validOrder =
+ den: unified:
+ let
+ inherit (den.lib.aspects.fx.edges) toposort;
+ ordered = toposort.topoSortEdges unified;
+ sortSucceeds = builtins.deepSeq ordered true;
+ indexByKey = lib.listToAttrs (lib.imap0 (i: e: lib.nameValuePair (edgeKey e) i) ordered);
+ # Producer = a nest/nest-verbatim edge whose target is a root (not a flake
+ # output): it WRITES the (root, class) cell.
+ producers = lib.filter (
+ e: (e.mode == "nest" || e.mode == "nest-verbatim") && e.target ? root
+ ) ordered;
+ # Reading default folds = merge edges with collectedScopes (the final
+ # extraction at a root reads every subtree-scope bucket at its class).
+ readingFolds = lib.filter (
+ e: e.mode == "merge" && e.target ? root && e.annotations ? collectedScopes
+ ) ordered;
+ # A (producer, fold) pair where the fold READS the producer's cell: the
+ # producer's target root is among the fold's collectedScopes AND the classes
+ # match. The producer must come strictly before the fold.
+ pairs = builtins.concatLists (
+ map (
+ p:
+ lib.filter (f: f != null) (
+ map (
+ f:
+ if
+ f.target.class == p.target.class
+ && builtins.elem p.target.root (f.annotations.collectedScopes or [ ])
+ then
+ {
+ producer = p;
+ fold = f;
+ }
+ else
+ null
+ ) readingFolds
+ )
+ ) producers
+ );
+ pairOk = pr: indexByKey.${edgeKey pr.producer} < indexByKey.${edgeKey pr.fold};
+ in
+ {
+ inherit sortSucceeds;
+ sortIsPermutation = sameMultiset ordered unified;
+ # At least one real producer→reading-fold pair, and EVERY such pair correctly
+ # ordered (producer strictly before the merge that reads it).
+ producerBeforeMerge = pairs != [ ] && lib.all pairOk pairs;
+ };
+in
+{
+ flake.tests.fx-edge-unification-gate = {
+
+ # ===== SPAWN topology (flake-level, host-aspects battery) ============
+ # A user under a host re-applies a host-schema homeManager projection; the
+ # host-aspects battery emits a spawn marker. The oracle renders ONE rewalk edge;
+ # the unified set drops it and surfaces the spawn's real delivered edges. We
+ # prove: oracle-minus-rewalk ⊆ unified, the surfaced spawn HM fold is present,
+ # the unified set has no rewalk arm, AND the unified set sorts to a valid
+ # permutation.
+ test-spawn-complete-and-ordered = denTest (
+ { den, lib, ... }:
+ let
+ r = den.lib.aspects.resolveWithPaths "flake" (den.lib.resolveEntity "flake" { });
+ oracle = r.legacyEdgeTrace;
+ unified = r.unifiedEdges;
+ oracleNoRewalk = lib.filter (e: !(e.source ? rewalk)) oracle;
+ order = validOrder den unified;
+ in
+ fleetSetup den lib
+ // {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.aspects.igloo.homeManager.home.sessionVariables.X = "y";
+ den.aspects.tux.includes = [ den.batteries.host-aspects ];
+ den.aspects.igloo.nixos.networking.hostName = "igloo";
+
+ expr = {
+ # (1) completeness: every non-rewalk oracle edge survives in unified.
+ completenessOracleMinusRewalk = isSubset oracleNoRewalk unified;
+ # the unified set has surfaced the spawn arm (no rewalk left).
+ unifiedHasNoRewalk = lib.all (e: !(e.source ? rewalk)) unified;
+ # the surfaced spawn delivers a homeManager default fold into the user
+ # root — a concrete edge the oracle's single rewalk arm collapsed away.
+ surfacedSpawnHmFold = lib.any (
+ e:
+ e.mode == "merge"
+ && e.path == [ ]
+ && e.source ? collected
+ && e.source.collected.class == "homeManager"
+ && e.target ? root
+ && lib.hasInfix "user" e.target.root
+ && e.target.class == "homeManager"
+ ) unified;
+ # (2) valid order.
+ inherit (order) sortSucceeds sortIsPermutation producerBeforeMerge;
+ };
+ expected = {
+ completenessOracleMinusRewalk = true;
+ unifiedHasNoRewalk = true;
+ surfacedSpawnHmFold = true;
+ sortSucceeds = true;
+ sortIsPermutation = true;
+ producerBeforeMerge = true;
+ };
+ }
+ );
+
+ # ===== PLAIN host+user (no spawn) ====================================
+ # No spawn marker → the oracle has no rewalk arm, so the WHOLE oracle set must
+ # survive in unified (completeness on the full set). Resolved at host level
+ # (the per-host/instantiate arms are flake-level only, so this is the pure
+ # top-level mechanism set).
+ test-plain-complete-and-ordered = denTest (
+ { den, lib, ... }:
+ let
+ r = den.lib.aspects.resolveWithPaths "nixos" (
+ den.lib.resolveEntity "host" { host = den.hosts.x86_64-linux.igloo; }
+ );
+ oracle = r.legacyEdgeTrace;
+ unified = r.unifiedEdges;
+ order = validOrder den unified;
+ in
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.aspects.igloo.nixos.networking.hostName = "igloo";
+
+ expr = {
+ # No spawn → oracle has no rewalk edge to drop.
+ oracleHasNoRewalk = lib.all (e: !(e.source ? rewalk)) oracle;
+ # (1) completeness: the full oracle set survives in unified.
+ completenessFullOracle = isSubset oracle unified;
+ # (2) valid order.
+ inherit (order) sortSucceeds sortIsPermutation producerBeforeMerge;
+ };
+ expected = {
+ oracleHasNoRewalk = true;
+ completenessFullOracle = true;
+ sortSucceeds = true;
+ sortIsPermutation = true;
+ producerBeforeMerge = true;
+ };
+ }
+ );
+
+ # ===== INSTANTIATE / multi-host (flake-level fleet) ==================
+ # A flake-level fleet resolve with an instantiate spec: unified carries the
+ # per-host default-fold + route edges (the mkInstantiateEdges projection) that
+ # the top-level oracle does not derive. We prove: oracle-minus-rewalk ⊆ unified,
+ # at least one host-rooted default fold is present (the per-host surface), AND
+ # the unified set sorts to a valid permutation.
+ test-instantiate-complete-and-ordered = denTest (
+ { den, lib, ... }:
+ let
+ r = den.lib.aspects.resolveWithPaths "flake" (den.lib.resolveEntity "flake" { });
+ oracle = r.legacyEdgeTrace;
+ unified = r.unifiedEdges;
+ oracleNoRewalk = lib.filter (e: !(e.source ? rewalk)) oracle;
+ order = validOrder den unified;
+ # A per-host default-fold edge: merge, P=[], targeting a host root's class.
+ # The oracle's top-level folds target the flake/system roots, so a
+ # host-rooted merge fold is the per-host projection's signature.
+ hostRootedFolds = lib.filter (
+ e:
+ e.mode == "merge"
+ && e.path == [ ]
+ && e.source ? collected
+ && e.target ? root
+ && lib.hasInfix "host" e.target.root
+ ) unified;
+ in
+ fleetSetup den lib
+ // {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.aspects.igloo.nixos.networking.hostName = "igloo";
+
+ expr = {
+ # (1) completeness: every non-rewalk oracle edge survives.
+ completenessOracleMinusRewalk = isSubset oracleNoRewalk unified;
+ # the per-host projection surfaced at least one host-rooted default fold.
+ perHostFoldPresent = hostRootedFolds != [ ];
+ # (2) valid order.
+ inherit (order) sortSucceeds sortIsPermutation producerBeforeMerge;
+ };
+ expected = {
+ completenessOracleMinusRewalk = true;
+ perHostFoldPresent = true;
+ sortSucceeds = true;
+ sortIsPermutation = true;
+ producerBeforeMerge = true;
+ };
+ }
+ );
+
+ # ===== ISOLATED-GUEST topology (host-level, appendToParent route) ====
+ # An isolated guest kind under the host: the guest gets its OWN default fold
+ # (isolation = it is its own entity-root) and a nest-verbatim delivery route
+ # (appendToParent, reinstantiate) into the host root. No spawn → full oracle
+ # set survives. We prove completeness on the full oracle set AND valid order
+ # (the verbatim route producer, an appendToParent edge writing the host cell,
+ # is among the producer→fold pairs the ordering spot-check covers).
+ test-isolated-guest-complete-and-ordered = denTest (
+ { den, lib, ... }:
+ let
+ guestEntity = {
+ name = "guest";
+ system = "x86_64-linux";
+ class = "nixos";
+ intoAttr = [ ];
+ users = { };
+ aspect = den.aspects.guest-aspect;
+ };
+ deliverPolicy = den.lib.policy.mkPolicy "deliver-iso" (
+ { ... }@args:
+ lib.optionals (!(args ? user) && !(args ? home)) [
+ (den.lib.policy.route {
+ fromClass = "nixos";
+ intoClass = "nixos";
+ collectSubtree = true;
+ appendToParent = true;
+ reinstantiate = true;
+ path = [
+ "microvm"
+ "vms"
+ "guest"
+ ];
+ })
+ ]
+ );
+ r = den.lib.aspects.resolveWithPaths "nixos" (
+ den.lib.resolveEntity "host" { host = den.hosts.x86_64-linux.igloo; }
+ );
+ oracle = r.legacyEdgeTrace;
+ unified = r.unifiedEdges;
+ order = validOrder den unified;
+ in
+ {
+ den.hosts.x86_64-linux.igloo.users = { };
+ den.schema.iso-kind = {
+ isEntity = true;
+ parent = "host";
+ isolated = true;
+ };
+ den.policies.resolve-iso-child =
+ { host, ... }:
+ lib.optionals (host.name == "igloo") [
+ (den.lib.policy.resolve.to.withIncludes "iso-kind" [ deliverPolicy ] { iso-kind = guestEntity; })
+ ];
+ den.schema.host.includes = [ den.policies.resolve-iso-child ];
+ den.aspects.guest-aspect.nixos.boot.kernelModules = [ "g" ];
+ den.aspects.igloo.nixos.networking.hostName = "igloo";
+
+ expr = {
+ # No spawn → oracle has no rewalk edge.
+ oracleHasNoRewalk = lib.all (e: !(e.source ? rewalk)) oracle;
+ # (1) completeness: the full oracle set survives in unified.
+ completenessFullOracle = isSubset oracle unified;
+ # The verbatim delivery route (the isolation producer) is in the set.
+ verbatimRoutePresent = lib.any (e: e.mode == "nest-verbatim") unified;
+ # (2) valid order.
+ inherit (order) sortSucceeds sortIsPermutation producerBeforeMerge;
+ };
+ expected = {
+ oracleHasNoRewalk = true;
+ completenessFullOracle = true;
+ verbatimRoutePresent = true;
+ sortSucceeds = true;
+ sortIsPermutation = true;
+ producerBeforeMerge = true;
+ };
+ }
+ );
+
+ # ===== CYCLE throws ==================================================
+ # A synthesize 2-cycle (F1 a→b writes (s,b) reads all "a"; F2 b→a writes (s,a)
+ # reads all "b") through the SAME `topoSortEdges` entry the unified set uses
+ # must THROW the loud cycle error (mutual dependency → no Kahn-ready edge).
+ test-cycle-throws = denTest (
+ { den, ... }:
+ let
+ inherit (den.lib.aspects.fx.edges) toposort edge;
+ f1 = edge.mkEdge {
+ source = edge.synthesize "F1" "a" "b";
+ target = edge.rootTarget "s" "b";
+ path = [ ];
+ mode = "nest";
+ };
+ f2 = edge.mkEdge {
+ source = edge.synthesize "F2" "b" "a";
+ target = edge.rootTarget "s" "a";
+ path = [ ];
+ mode = "nest";
+ };
+ result = builtins.tryEval (
+ builtins.deepSeq (toposort.topoSortEdges [
+ f1
+ f2
+ ]) "no-throw"
+ );
+ in
+ {
+ expr = result.success;
+ expected = false;
+ }
+ );
+ };
+}
diff --git a/templates/ci/modules/internal-api/fx-edges-pi.nix b/templates/ci/modules/internal-api/fx-edges-pi.nix
new file mode 100644
index 000000000..c48315f9f
--- /dev/null
+++ b/templates/ci/modules/internal-api/fx-edges-pi.nix
@@ -0,0 +1,75 @@
+{ denTest, ... }:
+{
+ flake.tests.fx-edges-pi = {
+
+ # aware/dedup defaults: dedupMode defaults, allScopeIds is OMITTED (so the
+ # materializer's derive-from-perScope default applies — the per-host site).
+ test-static-pi-aware-defaults = denTest (
+ { den, ... }:
+ let
+ pi = den.lib.aspects.fx.edges.pi.mkStaticPi {
+ rootScopeId = "host=igloo";
+ scopeContexts = {
+ "host=igloo" = { };
+ };
+ scopeParent = { };
+ scopeIsolated = { };
+ isolationMode = "aware";
+ };
+ in
+ {
+ expr = {
+ inherit (pi)
+ rootScopeId
+ isolationMode
+ dedupMode
+ contextsAreAugmented
+ classInject
+ ;
+ hasAllScopeIds = pi ? allScopeIds;
+ };
+ expected = {
+ rootScopeId = "host=igloo";
+ isolationMode = "aware";
+ dedupMode = "dedup";
+ contextsAreAugmented = true;
+ classInject = null;
+ hasAllScopeIds = false;
+ };
+ }
+ );
+
+ # blind/raw spawn dials: explicit dedupMode + allScopeIds carried through.
+ test-static-pi-blind-raw = denTest (
+ { den, ... }:
+ let
+ pi = den.lib.aspects.fx.edges.pi.mkStaticPi {
+ rootScopeId = "spawn";
+ scopeContexts = { };
+ scopeParent = { };
+ scopeIsolated = { };
+ isolationMode = "blind";
+ dedupMode = "raw";
+ allScopeIds = [
+ "spawn"
+ "a"
+ ];
+ };
+ in
+ {
+ expr = {
+ inherit (pi) isolationMode dedupMode allScopeIds;
+ };
+ expected = {
+ isolationMode = "blind";
+ dedupMode = "raw";
+ allScopeIds = [
+ "spawn"
+ "a"
+ ];
+ };
+ }
+ );
+
+ };
+}
diff --git a/templates/ci/modules/internal-api/fx-effectful-resolve.nix b/templates/ci/modules/internal-api/fx-effectful-resolve.nix
index a0eb64967..a46956673 100644
--- a/templates/ci/modules/internal-api/fx-effectful-resolve.nix
+++ b/templates/ci/modules/internal-api/fx-effectful-resolve.nix
@@ -46,7 +46,6 @@ let
};
}
// den.lib.aspects.fx.handlers.chainHandler
- // den.lib.aspects.fx.identity.pathSetHandler
// den.lib.aspects.fx.identity.collectPathsHandler
// {
"emit-class" =
@@ -81,7 +80,6 @@ let
currentScope = "__test";
scopedIncludesChain = _: { };
scopedConstraintRegistry = _: { };
- scopedConstraintFilters = _: { };
paths = [ ];
};
in
diff --git a/templates/ci/modules/internal-api/fx-gate.nix b/templates/ci/modules/internal-api/fx-gate.nix
index 98083d180..762945e59 100644
--- a/templates/ci/modules/internal-api/fx-gate.nix
+++ b/templates/ci/modules/internal-api/fx-gate.nix
@@ -191,7 +191,6 @@
// handlers.classifyHandler
// handlers.emitClassesHandler
// handlers.resolveChildrenHandler
- // den.lib.aspects.fx.identity.pathSetHandler
// den.lib.aspects.fx.identity.collectPathsHandler;
state = pipeline.defaultState;
} comp;
diff --git a/templates/ci/modules/internal-api/fx-handlers.nix b/templates/ci/modules/internal-api/fx-handlers.nix
index f326e1770..59369307c 100644
--- a/templates/ci/modules/internal-api/fx-handlers.nix
+++ b/templates/ci/modules/internal-api/fx-handlers.nix
@@ -353,6 +353,9 @@
let
fx = den.lib.fx;
handlers = den.lib.aspects.fx.handlers;
+ # Non-entity args: defer's loud guard throws on entity-kind args (those
+ # are classified in bind, never deferred). Accumulation mechanics are
+ # class-agnostic, so exercise with plain (pipe/enrichment-style) keys.
comp =
fx.bind
(fx.send "defer" {
@@ -360,11 +363,11 @@
name = "a";
__fn = _: { };
__args = {
- host = false;
+ widget = false;
};
};
- requiredKeys = [ "host" ];
- requiredArgs = [ "host" ];
+ requiredKeys = [ "widget" ];
+ requiredArgs = [ "widget" ];
})
(
_:
@@ -373,11 +376,11 @@
name = "b";
__fn = _: { };
__args = {
- user = false;
+ gizmo = false;
};
};
- requiredKeys = [ "user" ];
- requiredArgs = [ "user" ];
+ requiredKeys = [ "gizmo" ];
+ requiredArgs = [ "gizmo" ];
}
);
result = fx.handle {
diff --git a/templates/ci/modules/internal-api/fx-identity.nix b/templates/ci/modules/internal-api/fx-identity.nix
index 55cf30282..12385f3f0 100644
--- a/templates/ci/modules/internal-api/fx-identity.nix
+++ b/templates/ci/modules/internal-api/fx-identity.nix
@@ -51,34 +51,6 @@
}
);
- test-toPathSet = denTest (
- { den, lib, ... }:
- let
- inherit (den.lib.aspects.fx.identity) pathKey;
- toPathSet =
- paths:
- builtins.listToAttrs (
- map (p: {
- name = pathKey p;
- value = true;
- }) paths
- );
- in
- {
- expr = toPathSet [
- [ "a" ]
- [
- "b"
- "c"
- ]
- ];
- expected = {
- "a" = true;
- "b/c" = true;
- };
- }
- );
-
test-tombstone-shape = denTest (
{ den, ... }:
let
diff --git a/templates/ci/modules/internal-api/fx-instantiate-edges.nix b/templates/ci/modules/internal-api/fx-instantiate-edges.nix
new file mode 100644
index 000000000..666d05f4e
--- /dev/null
+++ b/templates/ci/modules/internal-api/fx-instantiate-edges.nix
@@ -0,0 +1,132 @@
+# fx-instantiate-edges suite — the per-host re-walk's surfaced edge set
+# (nix/lib/aspects/fx/edges/instantiate-edges.nix mkInstantiateEdges). The
+# default-fold merge edge @ hostScopeId is constructor-built; the provides+routes
+# edges are the CAPTURE from the per-host materializeUnified fold (Task 18.2),
+# passed in as `capturedEdges` (no longer re-derived from subtreeProvides /
+# subtreeRoutes). resolve.nix collects this output into the production edge object.
+#
+# `just ci fx-instantiate-edges` runs this suite.
+{ denTest, lib, ... }:
+{
+ flake.tests.fx-instantiate-edges = {
+
+ # A host(scope "host=igloo") + user(scope "host=igloo/user=tux") topology.
+ # The host carries a nixos bucket (perScope), and a user→host route nests the
+ # user's homeManager content into the host's nixos at a path. The surfaced
+ # edge set must contain BOTH the host nixos default-fold merge edge (target
+ # root = host, mode "merge", path [], CONSTRUCTOR-built) AND the user→host
+ # route edge (target class "nixos", mode "nest"), the latter coming from the
+ # CAPTURED edge list passed through unchanged.
+ test-host-user-edge-set = denTest (
+ { den, lib, ... }:
+ let
+ edges = den.lib.aspects.fx.edges.instantiateSubtree;
+
+ hostSid = "host=igloo";
+ userSid = "host=igloo/user=tux";
+
+ # id_hash-bearing entity records so scopeName renders ":"
+ # (the unified normalization the real per-host walk uses).
+ scopeContexts = {
+ ${hostSid} = {
+ host = {
+ name = "igloo";
+ id_hash = "hh";
+ };
+ };
+ ${userSid} = {
+ host = {
+ name = "igloo";
+ id_hash = "hh";
+ };
+ user = {
+ name = "tux";
+ id_hash = "uu";
+ };
+ };
+ };
+ scopeEntityKind = {
+ ${hostSid} = "host";
+ ${userSid} = "user";
+ };
+ edgeMod = import ../../../../nix/lib/aspects/fx/edges/edge.nix { inherit lib; };
+ scopeName = edgeMod.scopeName { inherit scopeEntityKind scopeContexts; };
+
+ # The CAPTURED provides+routes edge list the per-host fold would dispatch:
+ # here a single user→host route edge (homeManager nested into nixos at a
+ # path). In production this list is materializeUnified{exposeEdges}.edges;
+ # the suite builds an equivalent record directly via the edge constructor.
+ capturedEdges = [
+ (edgeMod.mkEdge {
+ source = edgeMod.collected (scopeName userSid) "homeManager";
+ target = edgeMod.rootTarget (scopeName hostSid) "nixos";
+ path = [
+ "home-manager"
+ "users"
+ "tux"
+ ];
+ mode = "nest";
+ annotations = { };
+ })
+ ];
+
+ result = edges.mkInstantiateEdges {
+ name = scopeName;
+ scopeParent = {
+ ${userSid} = hostSid;
+ };
+ scopeIsolated = { };
+ hostScopeId = hostSid;
+ subtreeScopeIds = [
+ hostSid
+ userSid
+ ];
+ # The host carries a nixos bucket → a nixos default-fold merge edge.
+ perScope = {
+ ${hostSid} = {
+ nixos = [ { config = { }; } ];
+ };
+ };
+ inherit capturedEdges;
+ };
+
+ hostFold = lib.filter (
+ e:
+ e.mode == "merge"
+ && e.path == [ ]
+ && e.source ? collected
+ && e.source.collected.scope == scopeName hostSid
+ && e.source.collected.class == "nixos"
+ && e.target ? root
+ && e.target.root == scopeName hostSid
+ && e.target.class == "nixos"
+ ) result;
+
+ userRoute = lib.filter (
+ e:
+ e.mode == "nest"
+ && e.target.class == "nixos"
+ &&
+ e.path == [
+ "home-manager"
+ "users"
+ "tux"
+ ]
+ ) result;
+ in
+ {
+ expr = {
+ hostFoldCount = builtins.length hostFold;
+ userRouteCount = builtins.length userRoute;
+ };
+ expected = {
+ # The default fold is constructor-built; the route edge passed through
+ # from capturedEdges unchanged.
+ hostFoldCount = 1;
+ userRouteCount = 1;
+ };
+ }
+ );
+
+ };
+}
diff --git a/templates/ci/modules/internal-api/fx-materialize-unified.nix b/templates/ci/modules/internal-api/fx-materialize-unified.nix
new file mode 100644
index 000000000..de195e567
--- /dev/null
+++ b/templates/ci/modules/internal-api/fx-materialize-unified.nix
@@ -0,0 +1,381 @@
+# fx-materialize-unified — the Task-17 byte-equivalence proof. The ordered-
+# dispatch engine (nix/lib/aspects/fx/edges/materialize-unified.nix) interleaves
+# provides + routes in topoSortEdges order, reusing the EXISTING per-spec
+# materializers; this suite proves it is byte-equivalent to the current
+# phase2∘phase3 phase folds over the SAME live seed.
+#
+# Reached via the `materializeEquiv` surface on the resolveWithPaths result (a lazy
+# thunk beside edgeTrace / unifiedEdges):
+# - materializeEquiv.phaseFold — phase2∘phase3 (production order).
+# - materializeEquiv.unified — materializeUnified { doFinalMerge = false }.
+# - materializeEquiv.unifiedMerged / .phaseFoldMerged — the doFinalMerge = true
+# pair (materializeUnified vs phaseFold-then-assembleSubtree).
+#
+# Class modules carry FUNCTIONS (`{ config, ... }: …` modules + the route nesting
+# closures), which Nix cannot compare with `==` unless the references are identical
+# — and the route closures are RE-CONSTRUCTED per fold, so `phaseFold == unified`
+# would throw on a content list even when the delivery is identical. Fully
+# EVALUATING the modules is also unsound here (a standalone freeform evalModules of
+# a host class bucket hits undefined `nixpkgs` options).
+#
+# The proof is therefore TWO-PART, exactly matching Design B (order-only, reuse the
+# same materializers):
+# (1) DISPATCH ORDER — the sequence of (kind, spec-identity) the unified engine
+# folds is IDENTICAL to phase2∘phase3 (all provides, in dedup order, then all
+# routes, in orderedKeptRoutes order). Since both paths run the SAME per-spec
+# materializers on the SAME seed, identical order ⇒ identical output by
+# construction. This is the load-bearing equivalence.
+# (2) STRUCTURAL FINGERPRINT — a function-tolerant deep walk of both
+# `{ classImports; perScope }` accumulators agrees: same attr keys, same list
+# lengths, same scalars, functions treated as opaque-equal leaves. This
+# guards (1) against a materializer that branches on fold position (it does
+# not — but the fingerprint catches any structural divergence the order proof
+# alone would miss).
+# Together: identical dispatch order + identical structure over the same reused
+# materializers == byte-equivalent delivery.
+#
+# `just ci fx-materialize-unified` runs this suite.
+{ denTest, lib, ... }:
+let
+ # Function-tolerant structural fingerprint. Attrsets → sorted key list + per-key
+ # fingerprint; lists → length + per-elem fingerprint; functions → opaque ""
+ # (uninspectable, treated equal); scalars → their toString. NOT a content proof
+ # on functions — paired with the dispatch-order proof which IS conclusive.
+ fingerprint =
+ v:
+ if builtins.isFunction v then
+ ""
+ else if builtins.isList v then
+ {
+ __list = builtins.length v;
+ items = map fingerprint v;
+ }
+ else if builtins.isAttrs v && !(lib.isDerivation v) then
+ lib.mapAttrs (_: fingerprint) v
+ else if lib.isDerivation v then
+ ""
+ else
+ builtins.toString v;
+
+ # The two accumulators agree iff their structural fingerprints are deep-equal.
+ equivalent =
+ e:
+ let
+ pf = e.phaseFold;
+ un = e.unified;
+ in
+ {
+ # (1) dispatch order — the identity sequence is identical.
+ dispatchOrderEqual = e.phaseFoldDispatch == e.unifiedDispatch;
+ # (2) structural fingerprint — classImports + perScope agree.
+ classImportsEqual = fingerprint pf.classImports == fingerprint un.classImports;
+ perScopeEqual = fingerprint pf.perScope == fingerprint un.perScope;
+ };
+ equivExpected = {
+ dispatchOrderEqual = true;
+ classImportsEqual = true;
+ perScopeEqual = true;
+ };
+in
+{
+ flake.tests.fx-materialize-unified = {
+
+ # ===== PLAIN host+user (default fold only) ===========================
+ # No provides, no routes → the unified fold is the identity over the seed
+ # (empty pair list); equivalence is the trivial-but-meaningful base case.
+ test-plain-equivalent = denTest (
+ { den, ... }:
+ let
+ r = den.lib.aspects.resolveWithPaths "nixos" (
+ den.lib.resolveEntity "host" { host = den.hosts.x86_64-linux.igloo; }
+ );
+ in
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.aspects.igloo.nixos.networking.hostName = "igloo";
+
+ expr = equivalent r.materializeEquiv;
+ expected = equivExpected;
+ }
+ );
+
+ # ===== PROVIDES topology =============================================
+ # A policy.provide injects a module into the host's nixos class (P=[]) AND a
+ # second provide nests at a path. Exercises applyOneProvide in the interleaved
+ # fold vs the phase2 fold — both deduped, both into the source bucket.
+ test-provides-equivalent = denTest (
+ { den, ... }:
+ let
+ r = den.lib.aspects.resolveWithPaths "nixos" (
+ den.lib.resolveEntity "host" { host = den.hosts.x86_64-linux.igloo; }
+ );
+ in
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.policies.provide-direct =
+ { host, ... }:
+ [
+ (den.lib.policy.provide {
+ class = host.class;
+ module.networking.hostName = "provided";
+ })
+ (den.lib.policy.provide {
+ class = host.class;
+ module.value = "boxed";
+ path = [ "provide-box" ];
+ })
+ ];
+ den.default.includes = [ den.policies.provide-direct ];
+ den.aspects.igloo.nixos.networking.domain = "local";
+
+ expr = equivalent r.materializeEquiv;
+ expected = equivExpected;
+ }
+ );
+
+ # ===== ROUTE topology ================================================
+ # A class route (path=[] merge) and a nested route (path≠[] nest) deliver a
+ # custom source class into nixos. Exercises applySimpleRouteEdge in the
+ # interleaved fold vs phase3, with simple routes reading the FROZEN seed
+ # perScope in BOTH paths.
+ test-routes-equivalent = denTest (
+ { den, ... }:
+ let
+ r = den.lib.aspects.resolveWithPaths "nixos" (
+ den.lib.resolveEntity "host" { host = den.hosts.x86_64-linux.igloo; }
+ );
+ in
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.classes.custom.description = "custom source class";
+ den.classes.src.description = "nested source class";
+ den.policies.route-both =
+ { host, ... }:
+ [
+ (den.lib.policy.route {
+ fromClass = "custom";
+ intoClass = host.class;
+ path = [ ];
+ })
+ (den.lib.policy.route {
+ fromClass = "src";
+ intoClass = host.class;
+ path = [ "route-box" ];
+ })
+ ];
+ den.default.includes = [ den.policies.route-both ];
+ den.aspects.igloo = {
+ nixos.networking.hostName = "igloo";
+ custom.networking.domain = "routed";
+ src.value = "nested";
+ };
+
+ expr = equivalent r.materializeEquiv;
+ expected = equivExpected;
+ }
+ );
+
+ # ===== PROVIDES + ROUTES interleaved =================================
+ # Both mechanisms active: the interleaving (provides-before-routes among
+ # independents) is the load-bearing case for byte-equivalence. The unified fold
+ # must keep provides ahead of routes exactly as phase2∘phase3 does.
+ test-provides-and-routes-equivalent = denTest (
+ { den, ... }:
+ let
+ r = den.lib.aspects.resolveWithPaths "nixos" (
+ den.lib.resolveEntity "host" { host = den.hosts.x86_64-linux.igloo; }
+ );
+ in
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.classes.custom.description = "custom source class";
+ den.policies.provide-and-route =
+ { host, ... }:
+ [
+ (den.lib.policy.provide {
+ class = host.class;
+ module.networking.hostName = "provided";
+ })
+ (den.lib.policy.route {
+ fromClass = "custom";
+ intoClass = host.class;
+ path = [ ];
+ })
+ ];
+ den.default.includes = [ den.policies.provide-and-route ];
+ den.aspects.igloo = {
+ custom.networking.domain = "routed";
+ };
+
+ expr = equivalent r.materializeEquiv;
+ expected = equivExpected;
+ }
+ );
+
+ # ===== ISOLATED-GUEST topology (appendToParent, reinstantiate) =======
+ # An isolated guest kind with a nest-verbatim appendToParent route into the
+ # host root (the gate's isolation canary). Exercises applySimpleRouteEdge's
+ # nest-verbatim arm + appendToParent target scope in the interleaved fold.
+ test-isolated-guest-equivalent = denTest (
+ { den, lib, ... }:
+ let
+ guestEntity = {
+ name = "guest";
+ system = "x86_64-linux";
+ class = "nixos";
+ intoAttr = [ ];
+ users = { };
+ aspect = den.aspects.guest-aspect;
+ };
+ deliverPolicy = den.lib.policy.mkPolicy "deliver-iso" (
+ { ... }@args:
+ lib.optionals (!(args ? user) && !(args ? home)) [
+ (den.lib.policy.route {
+ fromClass = "nixos";
+ intoClass = "nixos";
+ collectSubtree = true;
+ appendToParent = true;
+ reinstantiate = true;
+ path = [
+ "microvm"
+ "vms"
+ "guest"
+ ];
+ })
+ ]
+ );
+ r = den.lib.aspects.resolveWithPaths "nixos" (
+ den.lib.resolveEntity "host" { host = den.hosts.x86_64-linux.igloo; }
+ );
+ in
+ {
+ den.hosts.x86_64-linux.igloo.users = { };
+ den.schema.iso-kind = {
+ isEntity = true;
+ parent = "host";
+ isolated = true;
+ };
+ den.policies.resolve-iso-child =
+ { host, ... }:
+ lib.optionals (host.name == "igloo") [
+ (den.lib.policy.resolve.to.withIncludes "iso-kind" [ deliverPolicy ] { iso-kind = guestEntity; })
+ ];
+ den.schema.host.includes = [ den.policies.resolve-iso-child ];
+ den.aspects.guest-aspect.nixos.boot.kernelModules = [ "g" ];
+ den.aspects.igloo.nixos.networking.hostName = "igloo";
+
+ expr = equivalent r.materializeEquiv;
+ expected = equivExpected;
+ }
+ );
+
+ # ===== doFinalMerge = true ==========================================
+ # materializeUnified { doFinalMerge = true } must equal phaseFold-then-
+ # assembleSubtree (the final-extraction merge step, unchanged). Compared on the
+ # provides+routes topology so the merge sees real content.
+ test-final-merge-equivalent = denTest (
+ { den, ... }:
+ let
+ r = den.lib.aspects.resolveWithPaths "nixos" (
+ den.lib.resolveEntity "host" { host = den.hosts.x86_64-linux.igloo; }
+ );
+ e = r.materializeEquiv;
+ in
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.classes.custom.description = "custom source class";
+ den.policies.provide-and-route =
+ { host, ... }:
+ [
+ (den.lib.policy.provide {
+ class = host.class;
+ module.networking.hostName = "provided";
+ })
+ (den.lib.policy.route {
+ fromClass = "custom";
+ intoClass = host.class;
+ path = [ ];
+ })
+ ];
+ den.default.includes = [ den.policies.provide-and-route ];
+ den.aspects.igloo = {
+ custom.networking.domain = "routed";
+ };
+
+ expr = {
+ # assembleSubtree returns { class → [ modules ] }; the function-tolerant
+ # fingerprint compares the two merged results structurally.
+ mergeEqual = fingerprint e.unifiedMerged == fingerprint e.phaseFoldMerged;
+ };
+ expected = {
+ mergeEqual = true;
+ };
+ }
+ );
+
+ # ===== exposeEdges = true (Task 18 capture) ==========================
+ # materializeUnified { exposeEdges = true } ALSO carries the folded edge
+ # records (`map (p: p.edge) orderedPairs`). Capture fidelity: the captured
+ # `.edges` are the SAME SET as the constructor-built provides+route edges
+ # over the same inputs — proven by sorting both via the edge sort key and
+ # deep-comparing. Run on the provides+routes topology so both edge kinds
+ # are present.
+ #
+ # ALSO proves the existing-mode invariant: with exposeEdges the accumulator
+ # (everything but `.edges`) is byte-identical to the plain { doFinalMerge =
+ # false } return — exposeEdges only ADDS the capture key.
+ test-expose-edges-capture = denTest (
+ { den, lib, ... }:
+ let
+ r = den.lib.aspects.resolveWithPaths "nixos" (
+ den.lib.resolveEntity "host" { host = den.hosts.x86_64-linux.igloo; }
+ );
+ e = r.materializeEquiv;
+ edgeMod = den.lib.aspects.fx.edges.edge;
+ # Edges are pure data (target/source/path/mode/annotations) — compare the
+ # captured fold edges to the constructor-built oracle as a SET by sorting
+ # both via the edge sort key, then deep-comparing the sorted lists.
+ sorted = edges: edgeMod.sortEdges edges;
+ capturedSorted = sorted e.unifiedWithEdges.edges;
+ oracleSorted = sorted e.oracleEdges;
+ in
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.classes.custom.description = "custom source class";
+ den.policies.provide-and-route =
+ { host, ... }:
+ [
+ (den.lib.policy.provide {
+ class = host.class;
+ module.networking.hostName = "provided";
+ })
+ (den.lib.policy.route {
+ fromClass = "custom";
+ intoClass = host.class;
+ path = [ ];
+ })
+ ];
+ den.default.includes = [ den.policies.provide-and-route ];
+ den.aspects.igloo = {
+ custom.networking.domain = "routed";
+ };
+
+ expr = {
+ # Capture fidelity: folded edges == constructor edges (as a sorted set).
+ edgesMatchOracle = fingerprint capturedSorted == fingerprint oracleSorted;
+ # The capture is non-empty here (one provide edge + one route edge).
+ edgesNonEmpty = builtins.length e.unifiedWithEdges.edges > 0;
+ # Existing-mode invariant: the accumulator (sans the added `edges` key)
+ # is byte-identical to the plain no-exposeEdges return.
+ accUnchanged =
+ fingerprint (builtins.removeAttrs e.unifiedWithEdges [ "edges" ]) == fingerprint e.unified;
+ };
+ expected = {
+ edgesMatchOracle = true;
+ edgesNonEmpty = true;
+ accUnchanged = true;
+ };
+ }
+ );
+ };
+}
diff --git a/templates/ci/modules/internal-api/fx-oracle-production-differential.nix b/templates/ci/modules/internal-api/fx-oracle-production-differential.nix
new file mode 100644
index 000000000..fb431f37a
--- /dev/null
+++ b/templates/ci/modules/internal-api/fx-oracle-production-differential.nix
@@ -0,0 +1,231 @@
+# fx-oracle-production-differential suite — the Task 18.3 differential gate.
+#
+# As of Task 18.2 the resolveWithPaths result carries TWO edge objects side by
+# side:
+# - `edgeTrace` — the PRODUCTION edge object. Its fold-ordered
+# provides+routes portion is CAPTURED from the production
+# materializeUnified folds (the top-level fold, the spawn's
+# surfaced `.edges`, the per-host `.edges`); its default-fold
+# + instantiate edges are constructor-built. This is
+# drift-proof for the captured part.
+# - `legacyEdgeTrace` — the LEGACY end-state RE-DERIVATION (edge-trace.nix
+# extractEdgeTrace), WITH its spawn `rewalk` arm (the
+# spawn UNDERCOUNT) and the dedup-suppressed route twins.
+#
+# This suite diffs the two on a SPAWN topology and an INSTANTIATE topology and
+# pins the load-bearing relationship:
+#
+# (A) PRODUCTION ⊇ (LEGACY minus rewalk-source AND dedup-suppressed edges) —
+# every legacy edge that is neither a spawn rewalk nor a suppressed route
+# twin survives (by normalized key) in the production object. Production drops
+# the rewalk arm (replaced by the spawn's real surfaced edges) AND the
+# suppressed twins (it folds `orderedKeptRoutes` only). Today every CI
+# suppressed twin key-aliases its kept sibling so it would survive the key
+# check anyway, but the gate strips suppressed from the legacy arm
+# (`legacyDelivered`) so it stays sound for a future distinct-key suppression.
+#
+# (B) the production-only delta (edges in production NOT in legacy, by key) on
+# the spawn topology is NON-EMPTY and CONTAINS the spawn's surfaced route /
+# default-fold edge — the concrete homeManager fold into the user root that
+# the legacy oracle's single rewalk edge collapsed away.
+#
+# This is a PRODUCTION-vs-LEGACY differential (NOT production-vs-self): `oracle`
+# binds `legacyEdgeTrace`, `production` binds `edgeTrace`.
+#
+# `just ci fx-oracle-production-differential` runs this suite.
+{ denTest, lib, ... }:
+let
+ # Stable sort key mirroring edges/edge.nix edgeSortKey (T, P, S, M), so the two
+ # edge lists are compared as normalized SETS regardless of construction order.
+ targetKey =
+ t: if t ? output then "out:${lib.concatStringsSep "." t.output}" else "root:${t.root}/${t.class}";
+ pathKey = p: lib.concatStringsSep "/" p;
+ sourceKey =
+ s:
+ if s ? collected then
+ "collected:${s.collected.scope}/${s.collected.class}"
+ else if s ? rewalk then
+ "rewalk:${s.rewalk.aspect}/${lib.concatStringsSep "+" s.rewalk.bindings}/${s.rewalk.class}"
+ else if s ? synthesize then
+ "synthesize:${s.synthesize.forwardId}/${s.synthesize.fromClass}>${s.synthesize.intoClass}"
+ else
+ "empty";
+ edgeKey =
+ e:
+ lib.concatStringsSep " | " [
+ (targetKey e.target)
+ (pathKey e.path)
+ (sourceKey e.source)
+ e.mode
+ ];
+
+ keySet = edges: lib.genAttrs (map edgeKey edges) (_: true);
+ isSubset = sub: super: lib.all (e: (keySet super) ? ${edgeKey e}) sub;
+
+ # The legacy edges production is EXPECTED to still deliver: everything except
+ # (a) the spawn `rewalk` arm (the undercount production replaces with real
+ # surfaced edges) and (b) the dedup-`suppressed` route twins (production folds
+ # `orderedKeptRoutes` only, so a suppressed route is never materialized — its
+ # absence from production is faithful, not a drop). The correct subset relation
+ # is therefore `production ⊇ legacy \ rewalk \ suppressed`. Today every CI
+ # suppressed twin is a rule-1 same-identity forward duplicate that key-aliases
+ # its kept sibling (so it would survive the subset check anyway), but stripping
+ # it here makes the gate sound for a future DISTINCT-key suppression (rule-2
+ # redundant-root, or an adapterKey route with differing path/intoClass) without
+ # weakening it.
+ legacyDelivered = lib.filter (e: !(e.source ? rewalk) && !(e.annotations.suppressed or false));
+
+ # The fleet → hosts include policy shared by the spawn + instantiate topologies
+ # (a flake-level resolve that fans out to each host with an instantiate spec).
+ fleetSetup = den: lib: {
+ den.policies.to-fleet = _: [
+ (den.lib.policy.resolve.to "fleet" {
+ fleet = {
+ name = "fleet";
+ };
+ })
+ ];
+ den.policies.fleet-to-hosts =
+ { fleet, ... }:
+ lib.concatMap (
+ system:
+ lib.concatMap (
+ hostName:
+ let
+ host = den.hosts.${system}.${hostName};
+ in
+ [
+ (den.lib.policy.resolve.to "host" { inherit host; })
+ (den.lib.policy.instantiate host)
+ ]
+ ) (builtins.attrNames (den.hosts.${system} or { }))
+ ) (builtins.attrNames (den.hosts or { }));
+ den.schema.flake.includes = [ den.policies.to-fleet ];
+ den.schema.fleet.includes = [ den.policies.fleet-to-hosts ];
+ den.schema.flake-system.excludes = [
+ den.policies.system-to-os-outputs
+ den.policies.system-to-hm-outputs
+ ];
+ };
+in
+{
+ flake.tests.fx-oracle-production-differential = {
+
+ # ===== SPAWN topology (flake-level, host-aspects battery) ============
+ # A user under a host runs the host-aspects battery → a policy.spawn marker.
+ # The LEGACY object renders ONE rewalk edge for it; the PRODUCTION object drops
+ # the rewalk arm and surfaces the spawn's real delivered edges (its homeManager
+ # default fold into the user root). We diff the two:
+ # (A) production ⊇ (legacy minus its rewalk-source edges);
+ # (B) the production-only delta is non-empty AND contains the surfaced spawn
+ # homeManager fold into the user root.
+ test-spawn-production-superset-of-oracle = denTest (
+ { den, lib, ... }:
+ let
+ r = den.lib.aspects.resolveWithPaths "flake" (den.lib.resolveEntity "flake" { });
+ oracle = r.legacyEdgeTrace;
+ production = r.edgeTrace;
+ # Legacy minus rewalk AND suppressed twins — the set production must retain
+ # (see legacyDelivered).
+ oracleNoRewalk = legacyDelivered oracle;
+ # The legacy object DID carry a rewalk edge (the undercount we correct).
+ oracleRewalk = lib.filter (e: e.source ? rewalk) oracle;
+ # Production-only edges (the surfaced spawn's real delivered edges, which
+ # the single legacy rewalk edge collapsed away).
+ oracleKeys = keySet oracle;
+ productionOnly = lib.filter (e: !(oracleKeys ? ${edgeKey e})) production;
+ # The surfaced spawn delivers a homeManager default fold into the user
+ # root — the concrete edge that replaces the legacy rewalk edge.
+ surfacedSpawnHmFold = lib.any (
+ e:
+ e.mode == "merge"
+ && e.path == [ ]
+ && e.source ? collected
+ && e.source.collected.class == "homeManager"
+ && e.target ? root
+ && lib.hasInfix "user" e.target.root
+ && e.target.class == "homeManager"
+ ) productionOnly;
+ in
+ fleetSetup den lib
+ // {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.aspects.igloo.homeManager.home.sessionVariables.X = "y";
+ den.aspects.tux.includes = [ den.batteries.host-aspects ];
+ den.aspects.igloo.nixos.networking.hostName = "igloo";
+
+ expr = {
+ # The legacy object has a rewalk arm (the spawn undercount).
+ oracleHasRewalk = oracleRewalk != [ ];
+ # (A) production ⊇ (legacy minus rewalk).
+ productionSupersetOfOracleMinusRewalk = isSubset oracleNoRewalk production;
+ # The production object dropped the rewalk arm entirely.
+ productionHasNoRewalk = lib.all (e: !(e.source ? rewalk)) production;
+ # (B) production-only delta non-empty AND carries the surfaced spawn fold.
+ productionDeltaNonEmpty = productionOnly != [ ];
+ inherit surfacedSpawnHmFold;
+ };
+ expected = {
+ oracleHasRewalk = true;
+ productionSupersetOfOracleMinusRewalk = true;
+ productionHasNoRewalk = true;
+ productionDeltaNonEmpty = true;
+ surfacedSpawnHmFold = true;
+ };
+ }
+ );
+
+ # ===== INSTANTIATE topology (flake-level fleet, no spawn) ============
+ # A flake-level fleet resolve with an instantiate spec but NO spawn marker. The
+ # legacy object has no rewalk arm, so the FULL legacy set survives in the
+ # production object; production ADDS the per-host surfaced fold edges the
+ # instantiate projection derives (host-rooted default folds the legacy top-level
+ # set does not). We diff the two:
+ # (A) production ⊇ legacy (the full set — no rewalk to drop);
+ # (B) the production-only delta is non-empty AND contains a host-rooted
+ # default fold (the per-host projection's signature).
+ test-instantiate-production-superset-of-oracle = denTest (
+ { den, lib, ... }:
+ let
+ r = den.lib.aspects.resolveWithPaths "flake" (den.lib.resolveEntity "flake" { });
+ oracle = r.legacyEdgeTrace;
+ production = r.edgeTrace;
+ oracleHasRewalk = lib.any (e: e.source ? rewalk) oracle;
+ oracleKeys = keySet oracle;
+ productionOnly = lib.filter (e: !(oracleKeys ? ${edgeKey e})) production;
+ # A per-host default-fold edge: merge, P=[], targeting a host root's class.
+ hostRootedFoldInDelta = lib.any (
+ e:
+ e.mode == "merge"
+ && e.path == [ ]
+ && e.source ? collected
+ && e.target ? root
+ && lib.hasInfix "host" e.target.root
+ ) productionOnly;
+ in
+ fleetSetup den lib
+ // {
+ den.hosts.x86_64-linux.igloo.users = { };
+ den.aspects.igloo.nixos.networking.hostName = "igloo";
+
+ expr = {
+ # No spawn → the legacy object has no rewalk edge.
+ oracleHasNoRewalk = !oracleHasRewalk;
+ # (A) the legacy delivered set (minus rewalk/suppressed) survives in the
+ # production object. No spawn here, so this is the full legacy set sans
+ # any suppressed twins (see legacyDelivered).
+ productionSupersetOfOracle = isSubset (legacyDelivered oracle) production;
+ # (B) production-only delta non-empty AND carries a host-rooted fold.
+ productionDeltaNonEmpty = productionOnly != [ ];
+ inherit hostRootedFoldInDelta;
+ };
+ expected = {
+ oracleHasNoRewalk = true;
+ productionSupersetOfOracle = true;
+ productionDeltaNonEmpty = true;
+ hostRootedFoldInDelta = true;
+ };
+ }
+ );
+ };
+}
diff --git a/templates/ci/modules/internal-api/fx-scope-effects.nix b/templates/ci/modules/internal-api/fx-scope-effects.nix
index fead3a3c3..604ad0ee9 100644
--- a/templates/ci/modules/internal-api/fx-scope-effects.nix
+++ b/templates/ci/modules/internal-api/fx-scope-effects.nix
@@ -65,7 +65,9 @@
}
);
- # push-scope: re-entry appends parent deferred to child (fan-out on every entry).
+ # push-scope: deferred includes do NOT propagate to child scopes. Entity-arg
+ # deferral is gone; deferred entries stay where they were queued. Parent keeps
+ # its entry; the child gets none from inheritance.
test-push-scope-reentry-deferred = denTest (
{ den, ... }:
let
@@ -85,19 +87,14 @@
};
}
];
- # State where child scope already exists (re-entry scenario).
baseState = pipeline.defaultState // {
currentScope = parentScope;
scopeContexts = _: {
${parentScope} = {
host = "alpha";
};
- ${childScope} = scopedCtx;
- };
- scopedDeferredIncludes = _: {
- ${parentScope} = deferred;
- ${childScope} = deferred;
};
+ scopedDeferredIncludes = _: { ${parentScope} = deferred; };
};
comp = fx.send "push-scope" {
inherit scopedCtx parentScope;
@@ -107,15 +104,23 @@
handlers = handlers.pushScopeHandler;
state = baseState;
} comp;
+ allDeferred = result.state.scopedDeferredIncludes null;
in
{
- # On re-entry, parent deferred items are appended (matching old copyDeferredToScope behavior).
- expr = builtins.length ((result.state.scopedDeferredIncludes null).${childScope} or [ ]);
- expected = 2;
+ # Parent retains its 1 deferred entry; child inherits 0.
+ expr = [
+ (builtins.length (allDeferred.${parentScope} or [ ]))
+ (builtins.length (allDeferred.${childScope} or [ ]))
+ ];
+ expected = [
+ 1
+ 0
+ ];
}
);
- # push-scope: fans out parent deferred to new child scope.
+ # push-scope: parent deferred entries are NOT fanned out to the new child
+ # scope (carrier removed). The child scope sees an empty deferred list.
test-push-scope-deferred-fanout = denTest (
{ den, ... }:
let
@@ -160,7 +165,7 @@
in
{
expr = builtins.length ((result.state.scopedDeferredIncludes null).${childScope} or [ ]);
- expected = 2;
+ expected = 0;
}
);
diff --git a/templates/ci/modules/internal-api/fx-spawn-edges.nix b/templates/ci/modules/internal-api/fx-spawn-edges.nix
new file mode 100644
index 000000000..8fb70beaf
--- /dev/null
+++ b/templates/ci/modules/internal-api/fx-spawn-edges.nix
@@ -0,0 +1,162 @@
+# fx-spawn-edges suite — the spawn materializer (assembleSpawnSubtree) now ALSO
+# surfaces its constructed delivery-edge set (Task 16). The spawn fold returns
+# `{ imports; edges; }`: `imports` is byte-identical to before (consumers read
+# only it), and `edges` is the spawn's default-fold merge edge(s) ++ its OWN
+# provides edges ++ the RE-APPLIED mergedSpawnRoutes route edges (simple +
+# complex), built via the same fold-independent constructors the read-only
+# oracle consumes.
+#
+# Two checks:
+# 1. a FOCUSED unit assertion driving assembleSpawnSubtree directly with stub
+# phase primitives + a hand-built mergedSpawnRoutes — asserts the surfaced
+# `.edges` carries the re-applied route edge (the kind the OLD spawn arm,
+# a single rewalk edge, omitted) AND that `.imports` is preserved.
+# 2. a topology sanity check — a real spawn-bearing config (a host with a
+# home-manager user) resolves and delivers content, proving the surfacing
+# did not break spawn delivery.
+{ denTest, ... }:
+let
+ mat = den: den.lib.aspects.fx.edges.materialize;
+
+ # Stub phase-1 wrap: the surfaced route/provides edges are built from the spawn's
+ # INPUTS (mergedSpawnRoutes / ownProvides) directly, independent of the fold, so a
+ # trivial stub returning a well-formed phase-1 accumulator suffices. The
+ # accumulator's perScope carries one nixos module at the spawn root, so the
+ # default-fold merge edge has content to surface and `.imports` is non-empty.
+ # (provides + routes fold through materializeUnified inside assembleSpawnSubtree;
+ # ownProvides + mergedSpawnRoutes here deliver no extra nixos content, so the
+ # merge over this seed stays the single seeded nixos module.)
+ spawnRoot = "spawn=root";
+ stubAcc = {
+ classImports = {
+ nixos = [ { config = { }; } ];
+ };
+ perScope = {
+ ${spawnRoot} = {
+ nixos = [ { config = { }; } ];
+ };
+ };
+ };
+ wrapPerScope =
+ _ctx: _aug: _imports:
+ stubAcc;
+
+ # A simple user-schema-style route re-applied at the spawn root (the §B simple
+ # route: fromClass→intoClass at a nested path). This is the edge a spawn's
+ # mergedSpawnRoutes carries and re-applies; the old spawn drain-fold rendered
+ # only a single rewalk edge and omitted it.
+ reappliedRoute = {
+ sourceScopeId = spawnRoot;
+ fromClass = "homeManager";
+ intoClass = "nixos";
+ path = [
+ "home-manager"
+ "users"
+ "tux"
+ ];
+ };
+
+ spawnResult =
+ den:
+ (mat den).assembleSpawnSubtree {
+ class = "nixos";
+ inherit spawnRoot;
+ ctx = { };
+ augmented = {
+ ${spawnRoot} = { };
+ };
+ scopeEntityKind = { };
+ mergedClassImports = { };
+ mergedScopeParent = {
+ ${spawnRoot} = "host=igloo";
+ };
+ mergedScopeIsolated = { };
+ ownProvides = { };
+ mergedSpawnRoutes = {
+ ${spawnRoot} = [ reappliedRoute ];
+ };
+ allScopeIds = [ spawnRoot ];
+ selfRef = null;
+ inherit wrapPerScope;
+ };
+in
+{
+ flake.tests.fx-spawn-edges = {
+
+ # The surfaced edge set carries the re-applied mergedSpawnRoutes route edge
+ # (a homeManager→nixos simple route at the spawn root) — the edge the old
+ # spawn arm omitted.
+ test-spawn-surfaces-route-edge = denTest (
+ { den, lib, ... }:
+ let
+ r = spawnResult den;
+ routeEdges = lib.filter (
+ e:
+ e.source ? collected
+ && e.source.collected.class == "homeManager"
+ && e.target.class or null == "nixos"
+ &&
+ e.path == [
+ "home-manager"
+ "users"
+ "tux"
+ ]
+ ) r.edges;
+ in
+ {
+ expr = {
+ edgesNonEmpty = r.edges != [ ];
+ routeEdgeCount = builtins.length routeEdges;
+ routeEdgeMode = (builtins.head routeEdges).mode;
+ };
+ expected = {
+ edgesNonEmpty = true;
+ routeEdgeCount = 1;
+ routeEdgeMode = "nest";
+ };
+ }
+ );
+
+ # The surfaced edge set also carries the spawn's OWN default-fold merge edge
+ # (collected(spawnRoot, nixos) → (spawnRoot, nixos), P=[], merge), and
+ # `.imports` is preserved (the stub accumulator's one module).
+ test-spawn-surfaces-default-fold-and-imports = denTest (
+ { den, lib, ... }:
+ let
+ r = spawnResult den;
+ foldEdges = lib.filter (
+ e:
+ e.mode == "merge"
+ && e.path == [ ]
+ && e.source ? collected
+ && e.source.collected.class == "nixos"
+ && e.target.class or null == "nixos"
+ ) r.edges;
+ in
+ {
+ expr = {
+ foldEdgeCount = builtins.length foldEdges;
+ importsLength = builtins.length r.imports;
+ };
+ expected = {
+ foldEdgeCount = 1;
+ importsLength = 1;
+ };
+ }
+ );
+
+ # Topology sanity: a real spawn-bearing config (a host with a home-manager
+ # user spawns a home node) resolves and delivers content — the surfacing did
+ # not break spawn delivery.
+ test-spawn-topology-delivers = denTest (
+ { den, igloo, ... }:
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.aspects.igloo.nixos.networking.hostName = "igloo";
+
+ expr = igloo.networking.hostName;
+ expected = "igloo";
+ }
+ );
+ };
+}
diff --git a/templates/ci/modules/internal-api/fx-toposort-edges.nix b/templates/ci/modules/internal-api/fx-toposort-edges.nix
new file mode 100644
index 000000000..c0b695ff0
--- /dev/null
+++ b/templates/ci/modules/internal-api/fx-toposort-edges.nix
@@ -0,0 +1,160 @@
+# fx-toposort-edges — record-level delivery-edge toposort (Task 16). Hand-built
+# edge records exercise the cell model directly: producer→consumer ordering across
+# kinds (route producer before its root's final-extraction merge; appendToParent
+# producer before the parent merge) and the loud cycle throw on a synthesize 2-cycle.
+{ denTest, ... }:
+{
+ flake.tests.fx-toposort-edges = {
+
+ # A simple route writing (s,"nixos") must precede the final-extraction merge
+ # edge whose collectedScopes=["s"] reads (s,"nixos").
+ test-route-before-merge = denTest (
+ { den, ... }:
+ let
+ inherit (den.lib.aspects.fx.edges) toposort edge;
+ # route producer: writes (s,nixos), reads nothing (no collectedScopes).
+ route = edge.mkEdge {
+ source = edge.collected "s" "nixos";
+ target = edge.rootTarget "s" "nixos";
+ path = [ "x" ];
+ mode = "nest";
+ };
+ # final-extraction merge: reads (s,nixos) via collectedScopes.
+ merge = edge.mkEdge {
+ source = edge.collected "s" "nixos";
+ target = edge.rootTarget "s" "nixos";
+ path = [ ];
+ mode = "merge";
+ annotations.collectedScopes = [ "s" ];
+ };
+ ordered = toposort.topoSortEdges [
+ merge
+ route
+ ];
+ indexOf =
+ pred:
+ builtins.head (
+ builtins.filter (i: pred (builtins.elemAt ordered i)) (
+ builtins.genList (i: i) (builtins.length ordered)
+ )
+ );
+ routeIdx = indexOf (e: e.mode == "nest");
+ mergeIdx = indexOf (e: e.mode == "merge");
+ in
+ {
+ expr = routeIdx < mergeIdx;
+ expected = true;
+ }
+ );
+
+ # A 2-cycle: synthesize F1 (a→b) writes (s,b) reads all "a" producers; F2
+ # (b→a) writes (s,a) reads all "b" producers. F1 writes a's-reader's-input and
+ # vice versa → mutual dependency → loud cycle throw.
+ test-synthesize-cycle-throws = denTest (
+ { den, ... }:
+ let
+ inherit (den.lib.aspects.fx.edges) toposort edge;
+ f1 = edge.mkEdge {
+ source = edge.synthesize "F1" "a" "b";
+ target = edge.rootTarget "s" "b";
+ path = [ ];
+ mode = "nest";
+ };
+ f2 = edge.mkEdge {
+ source = edge.synthesize "F2" "b" "a";
+ target = edge.rootTarget "s" "a";
+ path = [ ];
+ mode = "nest";
+ };
+ result = builtins.tryEval (
+ builtins.deepSeq (toposort.topoSortEdges [
+ f1
+ f2
+ ]) "no-throw"
+ );
+ in
+ {
+ expr = result.success;
+ expected = false;
+ }
+ );
+
+ # STABLE order: two INDEPENDENT edges (neither reads the other's cell) keep
+ # their INPUT order through topoSortEdges. Load-bearing for Task 17 strict-byte
+ # (materializeUnified relies on independents preserving the provides-then-routes
+ # construction order so the unified fold matches phase2∘phase3 byte-exact).
+ test-independent-edges-stable = denTest (
+ { den, ... }:
+ let
+ inherit (den.lib.aspects.fx.edges) toposort edge;
+ # Two pure producers writing DISTINCT cells, reading nothing — independent.
+ a = edge.mkEdge {
+ source = edge.collected "s" "nixos";
+ target = edge.rootTarget "s" "nixos";
+ path = [ "a" ];
+ mode = "nest";
+ };
+ b = edge.mkEdge {
+ source = edge.collected "s" "homeManager";
+ target = edge.rootTarget "s" "homeManager";
+ path = [ "b" ];
+ mode = "nest";
+ };
+ ordered = toposort.topoSortEdges [
+ a
+ b
+ ];
+ in
+ {
+ # Input order [a b] is preserved (a's path stays first).
+ expr = map (e: e.path) ordered;
+ expected = [
+ [ "a" ]
+ [ "b" ]
+ ];
+ }
+ );
+
+ # An appendToParent producer (target.root = parent) writes (parent,nixos); the
+ # parent's final-extraction merge (collectedScopes=[parent]) reads it → producer
+ # precedes parent merge.
+ test-append-to-parent-before-merge = denTest (
+ { den, ... }:
+ let
+ inherit (den.lib.aspects.fx.edges) toposort edge;
+ producer = edge.mkEdge {
+ source = edge.collected "child" "nixos";
+ target = edge.rootTarget "parent" "nixos";
+ path = [ "y" ];
+ mode = "nest";
+ annotations.appendToParent = true;
+ };
+ parentMerge = edge.mkEdge {
+ source = edge.collected "parent" "nixos";
+ target = edge.rootTarget "parent" "nixos";
+ path = [ ];
+ mode = "merge";
+ annotations.collectedScopes = [ "parent" ];
+ };
+ ordered = toposort.topoSortEdges [
+ parentMerge
+ producer
+ ];
+ indexOf =
+ pred:
+ builtins.head (
+ builtins.filter (i: pred (builtins.elemAt ordered i)) (
+ builtins.genList (i: i) (builtins.length ordered)
+ )
+ );
+ producerIdx = indexOf (e: e.mode == "nest");
+ mergeIdx = indexOf (e: e.mode == "merge");
+ in
+ {
+ expr = producerIdx < mergeIdx;
+ expected = true;
+ }
+ );
+
+ };
+}
diff --git a/templates/ci/modules/internal-api/fx-trace.nix b/templates/ci/modules/internal-api/fx-trace.nix
index dab1da348..1ae2e91f3 100644
--- a/templates/ci/modules/internal-api/fx-trace.nix
+++ b/templates/ci/modules/internal-api/fx-trace.nix
@@ -351,7 +351,7 @@
{
expr = {
hasEntries = result.state.entries != [ ];
- hasPaths = (result.state.pathSet) null != { };
+ hasPaths = (result.state.pathSetByScope) null != { };
hasImports =
((builtins.foldl' (
acc: sd:
diff --git a/templates/ci/modules/internal-api/fx-unified-edges.nix b/templates/ci/modules/internal-api/fx-unified-edges.nix
new file mode 100644
index 000000000..2443f702b
--- /dev/null
+++ b/templates/ci/modules/internal-api/fx-unified-edges.nix
@@ -0,0 +1,237 @@
+# fx-unified-edges suite — the unifiedEdges(root) collector
+# (nix/lib/aspects/fx/resolve.nix), the union edge set that CORRECTS the oracle's
+# (edge-trace.nix) spawn UNDERCOUNT: it is the oracle's top-level mechanism set
+# MINUS the single `rewalk` arm, PLUS the SURFACED spawn edges (the spawn node's
+# real default-fold + provides + route edges) and the per-host / B′ instantiate
+# edges.
+#
+# `unifiedEdges` sits beside `edgeTrace` on the resolveWithPaths result, reached
+# the same way the delivery-edges suite reaches `edgeTrace`.
+#
+# `just ci fx-unified-edges` runs this suite.
+{ denTest, lib, ... }:
+let
+ # Stable sort key mirroring edges/edge.nix edgeSortKey (T, P, S, M), so the two
+ # edge lists are compared as normalized SETS regardless of construction order.
+ targetKey =
+ t: if t ? output then "out:${lib.concatStringsSep "." t.output}" else "root:${t.root}/${t.class}";
+ pathKey = p: lib.concatStringsSep "/" p;
+ sourceKey =
+ s:
+ if s ? collected then
+ "collected:${s.collected.scope}/${s.collected.class}"
+ else if s ? rewalk then
+ "rewalk:${s.rewalk.aspect}/${lib.concatStringsSep "+" s.rewalk.bindings}/${s.rewalk.class}"
+ else if s ? synthesize then
+ "synthesize:${s.synthesize.forwardId}/${s.synthesize.fromClass}>${s.synthesize.intoClass}"
+ else
+ "empty";
+ edgeKey =
+ e:
+ lib.concatStringsSep " | " [
+ (targetKey e.target)
+ (pathKey e.path)
+ (sourceKey e.source)
+ e.mode
+ ];
+
+ keySet = edges: lib.genAttrs (map edgeKey edges) (_: true);
+ isSubset = sub: super: lib.all (e: (keySet super) ? ${edgeKey e}) sub;
+
+ # The resolve result (carries edgeTrace + unifiedEdges side by side).
+ hostResult =
+ den: cls: host:
+ den.lib.aspects.resolveWithPaths cls (den.lib.resolveEntity "host" { inherit host; });
+in
+{
+ flake.tests.fx-unified-edges = {
+
+ # ===== (1) spawn topology: unifiedEdges fixes the rewalk undercount =====
+ # The host-aspects battery on a user emits a policy.spawn marker; the oracle
+ # renders ONE rewalk edge for it, but the spawn actually delivers a full edge
+ # set (its homeManager default fold + the re-applied mergedSpawnRoutes route
+ # edges). unifiedEdges drops the oracle's rewalk arm and adds the surfaced
+ # spawn edges, so:
+ # - unifiedEdges is a superset of (edgeTrace MINUS its rewalk edges);
+ # - unifiedEdges contains at least one edge the oracle OMITTED (the surfaced
+ # spawn delivered a route/default-fold edge the rewalk arm collapsed away).
+ test-spawn-superset-of-oracle-minus-rewalk = denTest (
+ { den, lib, ... }:
+ let
+ # FLAKE-level resolve: the drain-fold spawn (mkDrained) fires only when
+ # the spawn's parent scope is a resolve.to-created entity scope (so it is
+ # in scopeEntityKind). At HOST level the host is the ctx-seeded root, not
+ # in scopeEntityKind, so the drain-fold spawn arm is a no-op there — the
+ # surfaced spawn edges only exist at flake level.
+ r = den.lib.aspects.resolveWithPaths "flake" (den.lib.resolveEntity "flake" { });
+ oracle = r.legacyEdgeTrace;
+ unified = r.unifiedEdges;
+ oracleNoRewalk = lib.filter (e: !(e.source ? rewalk)) oracle;
+ # Edges the unified set has that the oracle did NOT (the surfaced spawn's
+ # real delivered edges, which the single rewalk edge collapsed away).
+ oracleKeys = keySet oracle;
+ novelInUnified = lib.filter (e: !(oracleKeys ? ${edgeKey e})) unified;
+ # The oracle DID carry a rewalk edge (the undercount we are correcting).
+ oracleRewalk = lib.filter (e: e.source ? rewalk) oracle;
+ in
+ {
+ den.policies.to-fleet = _: [
+ (den.lib.policy.resolve.to "fleet" {
+ fleet = {
+ name = "fleet";
+ };
+ })
+ ];
+ den.policies.fleet-to-hosts =
+ { fleet, ... }:
+ lib.concatMap (
+ system:
+ lib.concatMap (
+ hostName:
+ let
+ host = den.hosts.${system}.${hostName};
+ in
+ [
+ (den.lib.policy.resolve.to "host" { inherit host; })
+ (den.lib.policy.instantiate host)
+ ]
+ ) (builtins.attrNames (den.hosts.${system} or { }))
+ ) (builtins.attrNames (den.hosts or { }));
+ den.schema.flake.includes = [ den.policies.to-fleet ];
+ den.schema.fleet.includes = [ den.policies.fleet-to-hosts ];
+ den.schema.flake-system.excludes = [
+ den.policies.system-to-os-outputs
+ den.policies.system-to-hm-outputs
+ ];
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.aspects.igloo.homeManager.home.sessionVariables.X = "y";
+ den.aspects.tux.includes = [ den.batteries.host-aspects ];
+ den.aspects.igloo.nixos.networking.hostName = "igloo";
+
+ expr = {
+ oracleHasRewalk = oracleRewalk != [ ];
+ unifiedHasNoRewalk = lib.all (e: !(e.source ? rewalk)) unified;
+ unifiedSupersetOfOracleMinusRewalk = isSubset oracleNoRewalk unified;
+ unifiedHasNovelEdges = novelInUnified != [ ];
+ # The surfaced spawn delivers a homeManager default fold into the user
+ # root — a concrete edge the oracle's single rewalk edge collapsed away.
+ unifiedHasUserHmFold = lib.any (
+ e:
+ e.mode == "merge"
+ && e.path == [ ]
+ && e.source ? collected
+ && e.source.collected.class == "homeManager"
+ && e.target ? root
+ && lib.hasInfix "user" e.target.root
+ && e.target.class == "homeManager"
+ ) unified;
+ };
+ expected = {
+ oracleHasRewalk = true;
+ unifiedHasNoRewalk = true;
+ unifiedSupersetOfOracleMinusRewalk = true;
+ unifiedHasNovelEdges = true;
+ unifiedHasUserHmFold = true;
+ };
+ }
+ );
+
+ # ===== (2) plain host+user (no spawn): unifiedEdges ⊇ oracle =============
+ # With NO spawn marker the legacy oracle has no rewalk arm, so the production
+ # edge object contains the SAME top-level mechanism edges (default folds +
+ # os routes + the user forward) AND augments them with the per-host instantiate
+ # edges. We assert the full legacy oracle set is a subset of the production set
+ # (nothing top-level dropped). NOTE: the production object CAPTURES the edges its
+ # fold dispatched (kept routes only), so it omits the legacy oracle's
+ # suppressed-twin DUPLICATES (same edge KEY) — hence we compare DISTINCT-key
+ # counts, not raw list lengths.
+ test-plain-superset-of-oracle = denTest (
+ { den, lib, ... }:
+ let
+ r = hostResult den "nixos" den.hosts.x86_64-linux.igloo;
+ oracle = r.legacyEdgeTrace;
+ unified = r.unifiedEdges;
+ oracleHasRewalk = lib.any (e: e.source ? rewalk) oracle;
+ distinctKeyCount = edges: builtins.length (builtins.attrNames (keySet edges));
+ in
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.aspects.igloo.nixos.networking.hostName = "igloo";
+
+ expr = {
+ # No spawn in this topology → oracle has no rewalk edge.
+ oracleHasNoRewalk = !oracleHasRewalk;
+ # The whole legacy oracle set survives in the production set (by key).
+ unifiedSupersetOfOracle = isSubset oracle unified;
+ # And the production set has at least the oracle's DISTINCT-key count.
+ unifiedAtLeastOracleCount = distinctKeyCount unified >= distinctKeyCount oracle;
+ };
+ expected = {
+ oracleHasNoRewalk = true;
+ unifiedSupersetOfOracle = true;
+ unifiedAtLeastOracleCount = true;
+ };
+ }
+ );
+
+ # ===== (3) per-host edges present (instantiate-style topology) ===========
+ # A flake-level resolve with an instantiate spec: unifiedEdges carries the
+ # per-host default-fold + route edges (the mkInstantiateEdges projection) that
+ # the top-level oracle set does not derive (the oracle has the flake-output
+ # instantiate edge; the per-host fold edges are the NEW additive surface).
+ test-perhost-edges-present = denTest (
+ { den, lib, ... }:
+ let
+ flakeResult = den.lib.aspects.resolveWithPaths "flake" (den.lib.resolveEntity "flake" { });
+ unified = flakeResult.unifiedEdges;
+ # A per-host default-fold edge: merge, P=[], targeting the host root's
+ # nixos. The oracle's top-level folds target the flake/system roots, so a
+ # host-rooted merge fold is the per-host projection's signature.
+ hostRootedFolds = lib.filter (
+ e:
+ e.mode == "merge"
+ && e.path == [ ]
+ && e.source ? collected
+ && e.target ? root
+ && lib.hasInfix "host" e.target.root
+ ) unified;
+ in
+ {
+ den.policies.to-fleet = _: [
+ (den.lib.policy.resolve.to "fleet" {
+ fleet = {
+ name = "fleet";
+ };
+ })
+ ];
+ den.policies.fleet-to-hosts =
+ { fleet, ... }:
+ lib.concatMap (
+ system:
+ lib.concatMap (
+ hostName:
+ let
+ host = den.hosts.${system}.${hostName};
+ in
+ [
+ (den.lib.policy.resolve.to "host" { inherit host; })
+ (den.lib.policy.instantiate host)
+ ]
+ ) (builtins.attrNames (den.hosts.${system} or { }))
+ ) (builtins.attrNames (den.hosts or { }));
+ den.schema.flake.includes = [ den.policies.to-fleet ];
+ den.schema.fleet.includes = [ den.policies.fleet-to-hosts ];
+ den.schema.flake-system.excludes = [
+ den.policies.system-to-os-outputs
+ den.policies.system-to-hm-outputs
+ ];
+ den.hosts.x86_64-linux.igloo.users = { };
+ den.aspects.igloo.nixos.networking.hostName = "igloo";
+
+ # The per-host projection surfaced at least one host-rooted default fold.
+ expr = hostRootedFolds != [ ];
+ expected = true;
+ }
+ );
+ };
+}
diff --git a/templates/ci/modules/internal-api/has-aspect.nix b/templates/ci/modules/internal-api/has-aspect.nix
index 5623dcbed..fd58495db 100644
--- a/templates/ci/modules/internal-api/has-aspect.nix
+++ b/templates/ci/modules/internal-api/has-aspect.nix
@@ -740,5 +740,171 @@
}
);
+ # ─── Group K: entity.aspects (resolved node list) ─────────────────
+
+ # .aspects returns the flat set of resolved nodes (every depth), each
+ # augmented with .identity / .identityKey / .isNamed and retaining the
+ # raw .name. Subset checks (elem) tolerate schema-injected siblings.
+ test-K-aspects-contains-resolved = denTest (
+ { den, ... }:
+ let
+ aspects = den.hosts.x86_64-linux.igloo.aspects;
+ ids = map (a: a.identity) aspects;
+ in
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+
+ den.aspects.igloo.includes = [ den.aspects.feature ];
+ den.aspects.feature.nixos = { };
+
+ expr = {
+ hasIgloo = builtins.elem "igloo" ids;
+ hasFeature = builtins.elem "feature" ids;
+ allAugmented = builtins.all (
+ a: (a ? identity) && (a ? identityKey) && (a ? isNamed) && (a ? name)
+ ) aspects;
+ };
+ expected = {
+ hasIgloo = true;
+ hasFeature = true;
+ allAugmented = true;
+ };
+ }
+ );
+
+ # The entity root (carries __entityKind) is not one of its own aspects.
+ test-K-aspects-excludes-entity-root = denTest (
+ { den, ... }:
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.aspects.igloo.nixos = { };
+
+ expr = builtins.any (a: a ? __entityKind) den.hosts.x86_64-linux.igloo.aspects;
+ expected = false;
+ }
+ );
+
+ # Exclude-aware: tombstoned aspects are absent (same gate as pathSet).
+ test-K-aspects-respects-tombstone = denTest (
+ { den, ... }:
+ let
+ ids = map (a: a.identity) den.hosts.x86_64-linux.igloo.aspects;
+ in
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+
+ den.aspects.igloo.includes = [
+ den.aspects.keep
+ den.aspects.drop
+ ];
+ den.aspects.igloo.meta.handleWith = den.lib.aspects.fx.constraints.exclude den.aspects.drop;
+ den.aspects.keep.nixos = { };
+ den.aspects.drop.nixos = { };
+
+ expr = {
+ keep = builtins.elem "keep" ids;
+ drop = builtins.elem "drop" ids;
+ };
+ expected = {
+ keep = true;
+ drop = false;
+ };
+ }
+ );
+
+ # Flat across depths: deeply nested aspects appear as their own entries.
+ test-K-aspects-deep-present = denTest (
+ { den, ... }:
+ let
+ ids = map (a: a.identity) den.hosts.x86_64-linux.igloo.aspects;
+ in
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+
+ den.aspects.igloo.includes = [ den.aspects.level1 ];
+ den.aspects.level1.includes = [ den.aspects.level2 ];
+ den.aspects.level2.includes = [ den.aspects.level3 ];
+ den.aspects.level3.nixos = { };
+
+ expr = {
+ l1 = builtins.elem "level1" ids;
+ l2 = builtins.elem "level2" ids;
+ l3 = builtins.elem "level3" ids;
+ };
+ expected = {
+ l1 = true;
+ l2 = true;
+ l3 = true;
+ };
+ }
+ );
+
+ # Provenance: nested freeform refs keep distinct identities (a/sub ≠ b/sub).
+ test-K-aspects-nested-identity-distinct = denTest (
+ { den, ... }:
+ let
+ ids = map (a: a.identity) den.hosts.x86_64-linux.igloo.aspects;
+ in
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+
+ den.aspects.igloo.includes = [ den.aspects.a.sub ];
+ den.aspects.a.sub.nixos = { };
+ den.aspects.b.sub.nixos = { };
+
+ expr = {
+ aSub = builtins.elem "a/sub" ids;
+ bSub = builtins.elem "b/sub" ids;
+ };
+ expected = {
+ aSub = true;
+ bSub = false;
+ };
+ }
+ );
+
+ # Anonymous aspects are exposed (not filtered) — an inline-invoked factory
+ # resolves to an anonymous sibling, present with .isNamed == false.
+ test-K-aspects-includes-anonymous = denTest (
+ { den, ... }:
+ let
+ aspects = den.hosts.x86_64-linux.igloo.aspects;
+ in
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+
+ den.aspects.facter = report: {
+ nixos.environment.variables.FACTER_REPORT = report;
+ };
+ den.aspects.igloo.includes = [ (den.aspects.facter "report") ];
+
+ expr = builtins.any (a: !a.isNamed) aspects;
+ expected = true;
+ }
+ );
+
+ # Regression: anonymous *instances* (loc-named ":N") nest under named
+ # providers (e.g. "roles/dev/:3"). Their name slips past
+ # isMeaningfulName, so .isNamed must also inspect the identity. Invariant
+ # consumers (colmena tags) rely on: every isNamed node has a clean identity.
+ test-K-aspects-named-have-clean-identity = denTest (
+ { den, lib, ... }:
+ let
+ aspects = den.hosts.x86_64-linux.igloo.aspects;
+ in
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+
+ den.aspects.facter = report: {
+ nixos.environment.variables.FACTER_REPORT = report;
+ };
+ # Inline-invoked factory resolves to an anonymous sibling node.
+ den.aspects.igloo.includes = [ (den.aspects.facter "report") ];
+
+ expr = builtins.all (a: !a.isNamed || !(lib.hasInfix "" a.identity)) aspects;
+ expected = true;
+ }
+ );
+
};
}
diff --git a/templates/ci/modules/internal-api/hasaspect-ancestor-scope.nix b/templates/ci/modules/internal-api/hasaspect-ancestor-scope.nix
new file mode 100644
index 000000000..8c5be6e72
--- /dev/null
+++ b/templates/ci/modules/internal-api/hasaspect-ancestor-scope.nix
@@ -0,0 +1,145 @@
+# Regression: projected (in-context) hasAspect under an ANCESTOR entity-kind
+# scope.
+#
+# A host resolved under a fleet-style topology (flake → tier → host, where
+# `tier` is an entity kind like an environment) inherits the `tier` binding in
+# its production ctx. The projected hasAspect must still resolve against the
+# owning host's bucket — which is produced by the host's OWN standalone run and
+# knows nothing of the ancestor `tier`. Buckets are keyed by entity identity
+# (`id_hash`), which is context-free (kind+name, not ancestry), so the consuming
+# host looks itself up by its own id_hash regardless of any ancestor scopes —
+# no scope-string reconstruction, no ancestor to strip. The historical bug
+# (keying by a reconstructed "tier=prod,host=igloo" scope-string vs a bucket
+# keyed "host=igloo") read false here and dropped agenix's /persist prefix
+# (identityPaths → /etc/ssh). den's default flake→system→host walk hides it
+# because `system` is a plain string, not an entity kind.
+{ denTest, lib, ... }:
+{
+ flake.tests.hasaspect-ancestor-scope = {
+
+ test-projected-hasaspect-under-ancestor-tier = denTest (
+ { den, igloo, ... }:
+ let
+ inherit (den.lib.policy) resolve instantiate;
+ in
+ {
+ # Insert an entity-kind `tier` between flake-system and host:
+ # flake-system → tier → host. host.parent = tier makes tier an ANCESTOR.
+ den.schema.tier.isEntity = true;
+ den.schema.host.parent = "tier";
+ den.schema.flake-system.excludes = [ den.policies.system-to-os-outputs ];
+ den.policies.test-tier-walk = { system, ... }: [ (resolve.to "tier" { tier.name = "prod"; }) ];
+ den.policies.test-tier-to-host =
+ { system, ... }:
+ lib.concatMap (
+ host:
+ lib.optionals (host.intoAttr != [ ]) [
+ (resolve.to "host" { inherit host; })
+ (instantiate host)
+ ]
+ ) (builtins.attrValues (den.hosts.${system} or { }));
+ den.schema.flake-system.includes = [ den.policies.test-tier-walk ];
+ den.schema.tier.includes = [ den.policies.test-tier-to-host ];
+
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ # transitively-included, settings-bearing, namespaced aspect (the
+ # core.impermanence shape).
+ den.aspects.svc.feature = {
+ settings.opt = lib.mkOption {
+ type = lib.types.bool;
+ default = true;
+ };
+ nixos = { };
+ };
+ den.aspects.role.includes = [ den.aspects.svc.feature ];
+ den.aspects.igloo.includes = [ den.aspects.role ];
+ den.hosts.x86_64-linux.igloo.settings.svc.feature.opt = true;
+
+ # agenix-shape host-include that reads the PROJECTED host.hasAspect.
+ den.schema.host.includes = [
+ (
+ { host, ... }:
+ {
+ nixos.environment.etc."has-feature".text = lib.boolToString (
+ host.hasAspect den.aspects.svc.feature
+ );
+ }
+ )
+ ];
+
+ expr = igloo.environment.etc."has-feature".text;
+ expected = "true";
+ }
+ );
+
+ # NON-host owner: the bucket-producing entity is a standalone `home` (NOT a
+ # host — `host` is ABSENT from ctx), and the consumer is a child entity kind
+ # `crew` (parent = home) the home fans out to. The home's standalone run
+ # buckets the crew scope, re-keyed by crew's id_hash, with `svc/feature`
+ # delivered into it. The crew include reads the PROJECTED crew.hasAspect,
+ # which must resolve against the OWNING home's bucket — reached by walking
+ # crew's parent chain (crew → home).
+ #
+ # The old hardcoded-`host`-literal lookup reads `rawScopedCtx.host`
+ # (ABSENT) → crew's own binding (no `__pathSetByScope`: crew has no entity
+ # submodule) → empty bucket → FALSE. The generic chain walk skips the
+ # bucket-less `host` literal, ascends crew → home, finds the home's bucket
+ # and the crew-keyed `svc/feature` → TRUE. This case is RED at HEAD before
+ # the refactor and GREEN after.
+ test-projected-hasaspect-non-host-owner = denTest (
+ { den, config, ... }:
+ let
+ inherit (den.lib.policy) resolve;
+ in
+ {
+ den.default.homeManager.home.stateVersion = "25.11";
+ den.default.includes = [ den.provides.define-user ];
+ den.homes.x86_64-linux.tux = { };
+
+ # crew nests inside home: home → crew. home is an ANCESTOR of crew.
+ den.schema.crew.isEntity = true;
+ den.schema.crew.parent = "home";
+
+ # settings-bearing, namespaced aspect delivered to the CREW scope, so it
+ # lands in the home's production bucket keyed by crew's id_hash.
+ den.aspects.svc.feature = {
+ settings.opt = lib.mkOption {
+ type = lib.types.bool;
+ default = true;
+ };
+ homeManager = { };
+ };
+ den.schema.crew.includes = [
+ den.aspects.svc.feature
+ (
+ { crew, ... }:
+ {
+ homeManager.home.sessionVariables.HAS_FEATURE = lib.boolToString (
+ crew.hasAspect den.aspects.svc.feature
+ );
+ }
+ )
+ ];
+
+ # Home fans out to a crew child. crew carries an explicit id_hash so the
+ # bucket re-key (entities/_types.nix:pathSetByScopeOption) keys the crew
+ # scope's delivered set by crew identity — what the projected lookup
+ # reads — instead of a raw scope-string (crew has no instance registry).
+ den.policies.test-home-to-crew =
+ { home, ... }:
+ [
+ (resolve.to "crew" {
+ crew = {
+ name = home.name or "tux";
+ id_hash = "crew-tux";
+ };
+ })
+ ];
+ den.schema.home.includes = [ den.policies.test-home-to-crew ];
+
+ expr = config.flake.homeConfigurations.tux.config.home.sessionVariables.HAS_FEATURE or "MISSING";
+ expected = "true";
+ }
+ );
+ };
+}
diff --git a/templates/ci/modules/internal-api/identity-preservation.nix b/templates/ci/modules/internal-api/identity-preservation.nix
deleted file mode 100644
index 01a937722..000000000
--- a/templates/ci/modules/internal-api/identity-preservation.nix
+++ /dev/null
@@ -1,7 +0,0 @@
-# Tests for identity preservation through the resolve pipeline.
-# Legacy resolve.withAdapter tests removed — the API no longer exists.
-# TODO: rewrite using fx pipeline introspection if needed.
-{ denTest, lib, ... }:
-{
- flake.tests.identity-preservation = { };
-}
diff --git a/templates/ci/modules/internal-api/narrow-effects.nix b/templates/ci/modules/internal-api/narrow-effects.nix
index 2bd9f5345..2dcd119b7 100644
--- a/templates/ci/modules/internal-api/narrow-effects.nix
+++ b/templates/ci/modules/internal-api/narrow-effects.nix
@@ -17,7 +17,6 @@ let
// handlers.checkDedupHandler
// handlers.constraintRegistryHandler
// handlers.chainHandler
- // den.lib.aspects.fx.identity.pathSetHandler
// den.lib.aspects.fx.identity.collectPathsHandler
// handlers.resolveHandler
// handlers.compileHandler
@@ -66,7 +65,6 @@ let
rootScopeId = "__test";
scopedIncludesChain = _: { };
scopedConstraintRegistry = _: { };
- scopedConstraintFilters = _: { };
paths = [ ];
};
in
diff --git a/templates/ci/modules/internal-api/path-set-by-scope.nix b/templates/ci/modules/internal-api/path-set-by-scope.nix
index 280a71f15..2dafef236 100644
--- a/templates/ci/modules/internal-api/path-set-by-scope.nix
+++ b/templates/ci/modules/internal-api/path-set-by-scope.nix
@@ -54,11 +54,11 @@
mk = den.lib.aspects.mkProjectedHasAspect;
h = mk {
pathSetByScope = {
- "host=x,user=y" = {
+ "id:abc" = {
"foo" = true;
};
};
- scopeId = "host=x,user=y";
+ key = "id:abc";
};
in
{
diff --git a/templates/ci/modules/internal-api/provider-provenance.nix b/templates/ci/modules/internal-api/provider-provenance.nix
deleted file mode 100644
index a13948977..000000000
--- a/templates/ci/modules/internal-api/provider-provenance.nix
+++ /dev/null
@@ -1,7 +0,0 @@
-# Tests for provider provenance metadata.
-# Legacy resolve.withAdapter tests removed — the API no longer exists.
-# TODO: rewrite using fx pipeline introspection if needed.
-{ denTest, lib, ... }:
-{
- flake.tests.provider-provenance = { };
-}
diff --git a/templates/ci/modules/internal-api/self-guard.nix b/templates/ci/modules/internal-api/self-guard.nix
new file mode 100644
index 000000000..ae0a6ba3d
--- /dev/null
+++ b/templates/ci/modules/internal-api/self-guard.nix
@@ -0,0 +1,74 @@
+# `{ self, ... }:` — a policy guard meaning "fire once at my own registration
+# scope; never fan to descendant scopes". Distinct from `{ , ... }:`,
+# which fans across every entity of that kind.
+#
+# `self` is always bound in the dispatch ctx (to the scope's context), so a
+# policy can use it even when the scope's own kind isn't a ctx binding — this
+# is what lets a flake-scope resolution policy fire, where `{ flake, ... }:`
+# could not (flake is not bound in its own ctx).
+{ denTest, ... }:
+{
+ flake.tests.self-guard = {
+ # A self-guarded policy is dispatchable and fires at its registration scope.
+ test-self-fires-at-registration-scope = denTest (
+ {
+ den,
+ igloo,
+ ...
+ }:
+ let
+ inherit (den.lib.policy) include;
+ in
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.aspects.igloo.policies.self-fire =
+ { self, ... }: [ (include { nixos.environment.variables.DEN_SELF = "fired"; }) ];
+ den.aspects.igloo.includes = [ den.aspects.igloo.policies.self-fire ];
+ expr = igloo.environment.variables.DEN_SELF or "no";
+ expected = "fired";
+ }
+ );
+
+ # Registered at the host, `{ self, ... }:` fires once at the host and does
+ # NOT fan to the user children — whereas `{ user, ... }:` fans to each user.
+ test-self-does-not-fan-to-children = denTest (
+ {
+ den,
+ tuxHm,
+ pinguHm,
+ ...
+ }:
+ let
+ inherit (den.lib.policy) include;
+ in
+ {
+ den.hosts.x86_64-linux.igloo.users = {
+ tux = { };
+ pingu = { };
+ };
+ # fans: fires once per user, configuring each.
+ den.aspects.igloo.policies.fan =
+ { user, ... }: [ (include { homeManager.programs.vim.enable = true; }) ];
+ # self: fires once at the host scope; its user-targeted bit never lands.
+ den.aspects.igloo.policies.self-only =
+ { self, ... }: [ (include { homeManager.programs.emacs.enable = true; }) ];
+ den.aspects.igloo.includes = [
+ den.aspects.igloo.policies.fan
+ den.aspects.igloo.policies.self-only
+ ];
+ expr = {
+ tuxVim = tuxHm.programs.vim.enable;
+ pinguVim = pinguHm.programs.vim.enable;
+ tuxEmacs = tuxHm.programs.emacs.enable or false;
+ pinguEmacs = pinguHm.programs.emacs.enable or false;
+ };
+ expected = {
+ tuxVim = true;
+ pinguVim = true;
+ tuxEmacs = false;
+ pinguEmacs = false;
+ };
+ }
+ );
+ };
+}
diff --git a/templates/ci/modules/public-api/deliver.nix b/templates/ci/modules/public-api/deliver.nix
new file mode 100644
index 000000000..9a3889ac1
--- /dev/null
+++ b/templates/ci/modules/public-api/deliver.nix
@@ -0,0 +1,446 @@
+# Tests for policy.deliver — the user-facing delivery-edge primitive (spec §4).
+#
+# `deliver { from; to; at?; mode?; }` declares ONE delivery edge (S, T, P, M).
+# `route` and `provides` are PERMANENT shims over it. These tests cover:
+# - each mode (merge / nest / verbatim) end-to-end;
+# - `at`-path nesting;
+# - module-source delivery (the provides case);
+# - SHIM-EQUIVALENCE: a `route {...}` / `provide {...}` and the `deliver {...}`
+# they desugar to produce IDENTICAL edge traces (the strongest faithfulness
+# check — uses the edge-trace oracle, spec §3a / §5.3).
+{ denTest, lib, ... }:
+let
+ # Submodule option helper: declares an option at `name` with a listOf str type.
+ mkListSubmodule =
+ name:
+ { lib, ... }:
+ {
+ options.${name} = lib.mkOption {
+ type = lib.types.submoduleWith {
+ modules = [
+ {
+ options.items = lib.mkOption {
+ type = lib.types.listOf lib.types.str;
+ default = [ ];
+ };
+ }
+ ];
+ };
+ default = { };
+ };
+ };
+
+ # Resolve a host entity to its raw edge trace (the migration oracle). Used by
+ # the shim-equivalence tests: a route/provide config and its deliver desugaring
+ # are byte-identical except for the constructor, so their RAW traces (same
+ # scopeContexts) must be equal — no name-normalization needed.
+ hostTrace =
+ den: cls: host:
+ (den.lib.aspects.resolveWithPaths cls (den.lib.resolveEntity "host" { inherit host; })).edgeTrace;
+in
+{
+ flake.tests.deliver = {
+
+ # ===== mode = merge (default), class source, at = [] ================
+ # Equivalent to route { fromClass; intoClass; path = []; }.
+ test-deliver-merge = denTest (
+ { den, igloo, ... }:
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+
+ den.classes.custom.description = "Custom source class";
+
+ den.policies.deliver-merge =
+ { host, ... }:
+ [
+ (den.lib.policy.deliver {
+ from = "custom";
+ to = host.class;
+ })
+ ];
+
+ den.default.includes = [ den.policies.deliver-merge ];
+
+ den.aspects.igloo = {
+ custom.networking.hostName = "delivered-merge";
+ };
+
+ expr = igloo.networking.hostName;
+ expected = "delivered-merge";
+ }
+ );
+
+ # ===== mode = nest, class source, at = [ ... ] ======================
+ # Equivalent to route { ...; path = [ "box" ]; }.
+ test-deliver-nest = denTest (
+ { den, igloo, ... }:
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+
+ den.classes.src.description = "Source class for nest deliver";
+
+ den.policies.deliver-nest =
+ { host, ... }:
+ [
+ (den.lib.policy.deliver {
+ from = "src";
+ to = host.class;
+ at = [ "deliver-box" ];
+ mode = "nest";
+ })
+ ];
+
+ den.default.includes = [ den.policies.deliver-nest ];
+
+ den.aspects.igloo = {
+ nixos.imports = [ (mkListSubmodule "deliver-box") ];
+ nixos.deliver-box.items = [ "from-nixos-owned" ];
+ src.items = [ "from-src-class" ];
+ };
+
+ expr = lib.sort (a: b: a < b) igloo.deliver-box.items;
+ expected = [
+ "from-nixos-owned"
+ "from-src-class"
+ ];
+ }
+ );
+
+ # ===== module source (the provides case) ============================
+ # `from = { module = ...; }` injects a NEW module into the target class.
+ test-deliver-module-source = denTest (
+ { den, igloo, ... }:
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+
+ den.policies.deliver-module =
+ { host, ... }:
+ [
+ (den.lib.policy.deliver {
+ from = {
+ module = {
+ networking.hostName = "delivered-module";
+ };
+ };
+ to = host.class;
+ })
+ ];
+
+ den.default.includes = [ den.policies.deliver-module ];
+
+ den.aspects.igloo = { };
+
+ expr = igloo.networking.hostName;
+ expected = "delivered-module";
+ }
+ );
+
+ # ===== module source nested at a path ===============================
+ test-deliver-module-source-at = denTest (
+ { den, igloo, ... }:
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+
+ den.policies.deliver-module-at =
+ { host, ... }:
+ [
+ (den.lib.policy.deliver {
+ from = {
+ module.items = [ "from-deliver" ];
+ };
+ to = host.class;
+ at = [ "deliver-box" ];
+ mode = "nest";
+ })
+ ];
+
+ den.default.includes = [ den.policies.deliver-module-at ];
+
+ den.aspects.igloo = {
+ nixos.imports = [ (mkListSubmodule "deliver-box") ];
+ nixos.deliver-box.items = [ "from-aspect" ];
+ };
+
+ expr = lib.sort (a: b: a < b) igloo.deliver-box.items;
+ expected = [
+ "from-aspect"
+ "from-deliver"
+ ];
+ }
+ );
+
+ # ===== mode rejects unknown values ==================================
+ # tryEval can't capture the throw message, so pair the failing call with a
+ # positive control differing ONLY in `mode` — proves the throw is mode
+ # validation, not an unrelated eval error giving a false `success = false`.
+ test-deliver-bad-mode-throws = denTest (
+ { den, ... }:
+ {
+ expr = {
+ bogus =
+ (builtins.tryEval (
+ den.lib.policy.deliver {
+ from = "x";
+ to = "y";
+ mode = "bogus";
+ }
+ )).success;
+ control =
+ (builtins.tryEval (
+ den.lib.policy.deliver {
+ from = "x";
+ to = "y";
+ mode = "merge";
+ }
+ )).success;
+ };
+ expected = {
+ bogus = false;
+ control = true;
+ };
+ }
+ );
+
+ # ===== verbatim rejected for module sources =========================
+ # Positive control: the SAME module source with mode = "nest" succeeds, so
+ # the failure is the verbatim×module rule, not the module shape.
+ test-deliver-verbatim-module-throws = denTest (
+ { den, ... }:
+ {
+ expr = {
+ verbatim =
+ (builtins.tryEval (
+ den.lib.policy.deliver {
+ from = {
+ module = { };
+ };
+ to = "y";
+ mode = "verbatim";
+ }
+ )).success;
+ control =
+ (builtins.tryEval (
+ den.lib.policy.deliver {
+ from = {
+ module = { };
+ };
+ to = "y";
+ mode = "nest";
+ }
+ )).success;
+ };
+ expected = {
+ verbatim = false;
+ control = true;
+ };
+ }
+ );
+
+ # ===== mode = verbatim (class source) ===============================
+ # nest-verbatim: collected wrappers placed BY REFERENCE so the target's own
+ # `merge` re-instantiates them together with its base modules (microvm-style
+ # slot). The collected `guestcfg` module reads a default declared by a BASE
+ # module of the target slot — only possible if the module ships LIVE (re-
+ # evaluated with the base), proving verbatim, not a pre-frozen attrset.
+ # `deliver { mode = "verbatim"; }` is the explicit-mode replacement for the
+ # legacy `reinstantiate` flag (no flag on the surface).
+ test-deliver-verbatim = denTest (
+ { den, igloo, ... }:
+ let
+ reinstantiatingBase =
+ { lib, ... }:
+ {
+ options.fromBase = lib.mkOption {
+ type = lib.types.str;
+ default = "BASE-DEFAULT";
+ };
+ config._module.freeformType = lib.types.lazyAttrsOf lib.types.anything;
+ };
+ # A slot whose `merge` re-runs evalModules over the delivered defs + the
+ # base module-list (exactly as microvm's eval-config does).
+ reinstantiatingSlot =
+ { lib, ... }:
+ {
+ options.guestSlot = lib.mkOption {
+ default = null;
+ type = lib.types.nullOr (
+ lib.mkOptionType {
+ name = "reinstantiated config";
+ merge =
+ _loc: defs:
+ lib.evalModules {
+ modules = [ reinstantiatingBase ] ++ map (d: d.value) defs;
+ };
+ }
+ );
+ };
+ };
+ in
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.classes.guestcfg.description = "verbatim-delivered guest config";
+
+ den.policies.deliver-verbatim =
+ { host, ... }:
+ [
+ (den.lib.policy.deliver {
+ from = "guestcfg";
+ to = host.class;
+ mode = "verbatim";
+ at = [ "guestSlot" ];
+ })
+ ];
+ den.default.includes = [ den.policies.deliver-verbatim ];
+
+ den.aspects.igloo = {
+ nixos.imports = [ reinstantiatingSlot ];
+ # The guestcfg-class content delivered verbatim into guestSlot; it reads
+ # `fromBase`, a default only present once re-instantiated WITH the base.
+ guestcfg =
+ { config, ... }:
+ {
+ networking.hostName = "guest-vm";
+ echoed = config.fromBase;
+ };
+ };
+
+ # `.guestSlot.config` — the re-instantiated evalModules result's config.
+ expr = {
+ hn = igloo.guestSlot.config.networking.hostName;
+ echoed = igloo.guestSlot.config.echoed;
+ };
+ expected = {
+ hn = "guest-vm";
+ echoed = "BASE-DEFAULT";
+ };
+ }
+ );
+
+ # ===== SHIM-EQUIVALENCE: route ≡ deliver (class source) =============
+ # A `route { fromClass; intoClass; path; }` and the `deliver` it desugars to
+ # produce the SAME effect descriptor — construction-level faithfulness. Equal
+ # descriptors ⇒ identical downstream edges (the edge constructors consume the
+ # descriptor, not the surface call).
+ test-shim-route-eq-deliver = denTest (
+ { den, ... }:
+ {
+ expr =
+ let
+ r = den.lib.policy.route {
+ fromClass = "shimsrc";
+ intoClass = "nixos";
+ path = [ "shim-box" ];
+ };
+ d = den.lib.policy.deliver {
+ from = "shimsrc";
+ to = "nixos";
+ at = [ "shim-box" ];
+ mode = "nest";
+ };
+ in
+ r == d;
+ expected = true;
+ }
+ );
+
+ # route reinstantiate ≡ deliver verbatim — the mode→flag mapping.
+ test-shim-route-reinstantiate-eq-deliver-verbatim = denTest (
+ { den, ... }:
+ {
+ expr =
+ let
+ r = den.lib.policy.route {
+ fromClass = "c";
+ intoClass = "nixos";
+ path = [ "p" ];
+ reinstantiate = true;
+ };
+ d = den.lib.policy.deliver {
+ from = "c";
+ to = "nixos";
+ at = [ "p" ];
+ mode = "verbatim";
+ };
+ in
+ r == d;
+ expected = true;
+ }
+ );
+
+ # ===== SHIM-EQUIVALENCE: provide ≡ deliver (module source) ==========
+ test-shim-provide-eq-deliver = denTest (
+ { den, ... }:
+ {
+ expr =
+ let
+ p = den.lib.policy.provide {
+ class = "nixos";
+ module.items = [ "m" ];
+ path = [ "box" ];
+ };
+ d = den.lib.policy.deliver {
+ from = {
+ module.items = [ "m" ];
+ };
+ to = "nixos";
+ at = [ "box" ];
+ mode = "nest";
+ };
+ in
+ p == d;
+ expected = true;
+ }
+ );
+
+ # ===== SHIM-EQUIVALENCE via the edge oracle: deliver edge trace =====
+ # A `deliver`-built topology produces the SAME delivery edge a `route` does.
+ # The construction-level equivalence above (route descriptor == deliver
+ # descriptor) guarantees the full traces match; here we confirm the deliver
+ # constructor lands the expected nest edge in the live edge trace (oracle).
+ test-shim-deliver-edgetrace = denTest (
+ { den, ... }:
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.classes.shimsrc.description = "shim source class";
+ den.policies.deliver-p =
+ { host, ... }:
+ [
+ (den.lib.policy.deliver {
+ from = "shimsrc";
+ to = host.class;
+ at = [ "shim-box" ];
+ mode = "nest";
+ })
+ ];
+ den.default.includes = [ den.policies.deliver-p ];
+ den.aspects.igloo = {
+ nixos.imports = [ (mkListSubmodule "shim-box") ];
+ shimsrc.items = [ "x" ];
+ };
+
+ # The deliver-built trace contains the shimsrc>nixos nest edge at
+ # shim-box (the policy fires per scope; each registers the edge — the
+ # faithful trace records each, exactly as the route shim would).
+ expr =
+ let
+ trace = hostTrace den "nixos" den.hosts.x86_64-linux.igloo;
+ shimEdges = builtins.filter (
+ e: (e.source.collected.class or null) == "shimsrc" && (e.target.class or null) == "nixos"
+ ) trace;
+ modesPaths = map (e: {
+ inherit (e) mode path;
+ }) shimEdges;
+ in
+ {
+ allNest = builtins.all (e: e.mode == "nest" && e.path == [ "shim-box" ]) modesPaths;
+ atLeastOne = modesPaths != [ ];
+ };
+ expected = {
+ allNest = true;
+ atLeastOne = true;
+ };
+ }
+ );
+
+ };
+}
diff --git a/templates/ci/modules/public-api/flat-homes.nix b/templates/ci/modules/public-api/flat-homes.nix
new file mode 100644
index 000000000..af894e579
--- /dev/null
+++ b/templates/ci/modules/public-api/flat-homes.nix
@@ -0,0 +1,92 @@
+{ denTest, ... }:
+{
+ flake.tests.flat-homes = {
+ test-flat-home-two-level-shape = denTest (
+ { den, ... }:
+ {
+ den.homes."tux@igloo" = {
+ system = "x86_64-linux";
+ };
+
+ expr = builtins.attrNames den.homes;
+ expected = [ "x86_64-linux" ];
+ }
+ );
+
+ test-flat-home-name-parsing = denTest (
+ { den, ... }:
+ {
+ den.homes."tux@igloo" = {
+ system = "x86_64-linux";
+ };
+
+ expr = {
+ inherit (den.homes.x86_64-linux."tux@igloo")
+ name
+ userName
+ hostName
+ system
+ ;
+ };
+ expected = {
+ name = "tux";
+ userName = "tux";
+ hostName = "igloo";
+ system = "x86_64-linux";
+ };
+ }
+ );
+
+ test-flat-home-coexists-with-legacy = denTest (
+ { den, ... }:
+ {
+ den.homes.x86_64-linux.legacy-home = { };
+ den.homes."flat-home" = {
+ system = "x86_64-linux";
+ };
+
+ expr = builtins.sort (a: b: a < b) (builtins.attrNames den.homes.x86_64-linux);
+ expected = [
+ "flat-home"
+ "legacy-home"
+ ];
+ }
+ );
+
+ test-flat-home-cross-entity-host-lookup = denTest (
+ { den, config, ... }:
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.homes."tux@igloo" = {
+ system = "x86_64-linux";
+ };
+
+ den.aspects.igloo.nixos.networking.hostName = "igloo";
+ den.aspects.tux.includes = [ den.provides.define-user ];
+ den.aspects.tux.homeManager =
+ { osConfig, ... }:
+ {
+ home.keyboard.model = osConfig.networking.hostName;
+ };
+
+ expr = config.flake.homeConfigurations."tux@igloo".config.home.keyboard.model;
+ expected = "igloo";
+ }
+ );
+
+ test-flat-home-output = denTest (
+ { den, config, ... }:
+ {
+ den.homes."tux" = {
+ system = "x86_64-linux";
+ };
+ den.default.homeManager.home.stateVersion = "25.11";
+ den.default.includes = [ den.provides.define-user ];
+ den.aspects.tux.homeManager.programs.fish.enable = true;
+
+ expr = config.flake.homeConfigurations.tux.config.programs.fish.enable;
+ expected = true;
+ }
+ );
+ };
+}
diff --git a/templates/ci/modules/public-api/flat-hosts.nix b/templates/ci/modules/public-api/flat-hosts.nix
new file mode 100644
index 000000000..3df7c456d
--- /dev/null
+++ b/templates/ci/modules/public-api/flat-hosts.nix
@@ -0,0 +1,87 @@
+{ denTest, ... }:
+{
+ flake.tests.flat-hosts = {
+ test-flat-host-two-level-shape = denTest (
+ { den, ... }:
+ {
+ den.hosts.igloo = {
+ system = "x86_64-linux";
+ users.tux = { };
+ };
+
+ expr = builtins.attrNames den.hosts;
+ expected = [ "x86_64-linux" ];
+ }
+ );
+
+ test-flat-host-name = denTest (
+ { den, ... }:
+ {
+ den.hosts.igloo = {
+ system = "x86_64-linux";
+ users.tux = { };
+ };
+
+ expr = den.hosts.x86_64-linux.igloo.name;
+ expected = "igloo";
+ }
+ );
+
+ test-flat-host-system = denTest (
+ { den, ... }:
+ {
+ den.hosts.igloo = {
+ system = "x86_64-linux";
+ users.tux = { };
+ };
+
+ expr = den.hosts.x86_64-linux.igloo.system;
+ expected = "x86_64-linux";
+ }
+ );
+
+ test-flat-host-coexists-with-legacy = denTest (
+ { den, ... }:
+ {
+ den.hosts.x86_64-linux.legacy-host.users.tux = { };
+ den.hosts.flat-host = {
+ system = "x86_64-linux";
+ users.tux = { };
+ };
+
+ expr = builtins.sort (a: b: a < b) (builtins.attrNames den.hosts.x86_64-linux);
+ expected = [
+ "flat-host"
+ "legacy-host"
+ ];
+ }
+ );
+
+ test-flat-host-users-with-module-args = denTest (
+ { den, ... }:
+ {
+ den.hosts.igloo = {
+ system = "x86_64-linux";
+ users.tux = { };
+ };
+
+ expr = den.hosts.x86_64-linux.igloo.users.tux.host.name;
+ expected = "igloo";
+ }
+ );
+
+ test-flat-host-nixos-output = denTest (
+ { den, igloo, ... }:
+ {
+ den.hosts.igloo = {
+ system = "x86_64-linux";
+ users.tux = { };
+ };
+ den.aspects.igloo.nixos.networking.hostName = "flat-test";
+
+ expr = igloo.networking.hostName;
+ expected = "flat-test";
+ }
+ );
+ };
+}
diff --git a/templates/ci/modules/public-api/hasaspect-guard-cross-host.nix b/templates/ci/modules/public-api/hasaspect-guard-cross-host.nix
new file mode 100644
index 000000000..d86e095c5
--- /dev/null
+++ b/templates/ci/modules/public-api/hasaspect-guard-cross-host.nix
@@ -0,0 +1,123 @@
+# Regression for denful/den#613: `host.hasAspect` inside a `policy.when` guard
+# must reflect ONLY the guarded host's own (subtree + inherited) membership —
+# NOT aspects another host included earlier in the fleet walk. The bug was that
+# guards consulted the flat fleet-wide in-flight pathSet, so membership leaked
+# across sibling hosts in an eval-order-dependent way.
+{ denTest, ... }:
+{
+ flake.tests.hasaspect-guard-cross-host = {
+
+ # The reported case: iceberg includes `test`; igloo does NOT, but guards on
+ # `host.hasAspect test`. igloo's guard must be FALSE (→ hostName stays the
+ # default "nixos"), even though iceberg was walked first and included test.
+ test-sibling-include-does-not-leak = denTest (
+ {
+ den,
+ igloo,
+ ...
+ }:
+ let
+ inherit (den.lib) policy;
+ in
+ {
+ den.hosts.x86_64-linux.iceberg.users.tux = { };
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+
+ den.aspects.test.nixos = { };
+ den.aspects.iceberg.includes = [ den.aspects.test ];
+ den.aspects.igloo.includes = [
+ (policy.when ({ host, ... }: host.hasAspect den.aspects.test) {
+ nixos.networking.hostName = "wrong";
+ })
+ ];
+
+ expr = igloo.networking.hostName;
+ expected = "nixos";
+ }
+ );
+
+ # Order-independence: same topology, but the guarded host is checked while
+ # the OTHER host (igloo) includes test. iceberg's guard must still be FALSE.
+ test-sibling-include-does-not-leak-reversed = denTest (
+ {
+ den,
+ iceberg,
+ ...
+ }:
+ let
+ inherit (den.lib) policy;
+ in
+ {
+ den.hosts.x86_64-linux.iceberg.users.tux = { };
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+
+ den.aspects.test.nixos = { };
+ den.aspects.igloo.includes = [ den.aspects.test ];
+ den.aspects.iceberg.includes = [
+ (policy.when ({ host, ... }: host.hasAspect den.aspects.test) {
+ nixos.networking.hostName = "wrong";
+ })
+ ];
+
+ expr = iceberg.networking.hostName;
+ expected = "nixos";
+ }
+ );
+
+ # The host's OWN include must still be seen by its guard (true positive).
+ test-own-include-fires = denTest (
+ {
+ den,
+ igloo,
+ ...
+ }:
+ let
+ inherit (den.lib) policy;
+ in
+ {
+ den.hosts.x86_64-linux.iceberg.users.tux = { };
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+
+ den.aspects.test.nixos = { };
+ den.aspects.igloo.includes = [
+ den.aspects.test
+ (policy.when ({ host, ... }: host.hasAspect den.aspects.test) {
+ nixos.networking.hostName = "fired";
+ })
+ ];
+
+ expr = igloo.networking.hostName;
+ expected = "fired";
+ }
+ );
+
+ # Ancestor inheritance must still be seen: an aspect delivered to ALL hosts
+ # via `den.default` is in every host's inherited membership, so the guard
+ # fires (the scope walk includes ancestors, not just the host's own scope).
+ test-default-include-inherited-fires = denTest (
+ {
+ den,
+ igloo,
+ ...
+ }:
+ let
+ inherit (den.lib) policy;
+ in
+ {
+ den.hosts.x86_64-linux.iceberg.users.tux = { };
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+
+ den.aspects.test.nixos = { };
+ den.default.includes = [ den.aspects.test ];
+ den.aspects.igloo.includes = [
+ (policy.when ({ host, ... }: host.hasAspect den.aspects.test) {
+ nixos.networking.hostName = "fired";
+ })
+ ];
+
+ expr = igloo.networking.hostName;
+ expected = "fired";
+ }
+ );
+ };
+}
diff --git a/templates/ci/modules/public-api/reserved-keys.nix b/templates/ci/modules/public-api/reserved-keys.nix
new file mode 100644
index 000000000..7fc3bc484
--- /dev/null
+++ b/templates/ci/modules/public-api/reserved-keys.nix
@@ -0,0 +1,36 @@
+# den.reservedKeys lets a config mark extra aspect keys as structural. The
+# pipeline skips them (no class/nested/pipe dispatch) and the aspect type
+# leaves their values untouched, so consumers can use them for free-form
+# metadata and read them back exactly as declared.
+{ denTest, ... }:
+{
+ flake.tests.reserved-keys = {
+ # A reserved key carries metadata: the rest of the aspect resolves
+ # normally, and the key's value passes through unwrapped. Without
+ # reservation `settings` would be dispatched and its value content-wrapped.
+ test-reserved-key-is-metadata = denTest (
+ { den, igloo, ... }:
+ {
+ den.reservedKeys = [ "settings" ];
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.aspects.igloo = {
+ settings = {
+ theme = "dark";
+ };
+ nixos.networking.hostName = "reserved-test";
+ };
+
+ expr = {
+ resolves = igloo.networking.hostName;
+ metadata = den.aspects.igloo.settings;
+ };
+ expected = {
+ resolves = "reserved-test";
+ metadata = {
+ theme = "dark";
+ };
+ };
+ }
+ );
+ };
+}
diff --git a/templates/ci/modules/public-api/route.nix b/templates/ci/modules/public-api/route.nix
index 5097c228b..ea78c4b4d 100644
--- a/templates/ci/modules/public-api/route.nix
+++ b/templates/ci/modules/public-api/route.nix
@@ -195,5 +195,127 @@ in
}
);
+ # route `reinstantiate`: deliver collected modules VERBATIM into a target
+ # whose option `merge` RE-INSTANTIATES them as their own module set together
+ # with base modules (e.g. microvm.nix's `microvm.vms..config`, whose
+ # merge re-runs eval-config). The flag keeps each collected wrapper's keyed
+ # module intact instead of pre-evaluating it in an isolated freeform
+ # evalModules. A guest module that READS a base-module default must see it —
+ # proving the route shipped live MODULES re-evaluated WITH the base, not a
+ # pre-frozen resolved attrset. Pre-flag (nestPlain) the read has no
+ # `fromBase` default in scope and the delivery THROWS. Hand-rolled (no
+ # delivered-child policy): a bare resolve into an isolated guest scope + a
+ # delivery route carrying reinstantiate, into a faithfully re-instantiating
+ # slot. A freeform stub slot (test-isolated-delivery-exactly-once) cannot
+ # exercise this — it stores defs without re-evaluating them.
+ test-route-reinstantiate-base-context = denTest (
+ { den, igloo, ... }:
+ let
+ guestEntity = {
+ name = "guest";
+ system = "x86_64-linux";
+ class = "nixos";
+ intoAttr = [ ];
+ users = { };
+ aspect = den.aspects.guest-aspect;
+ };
+ # Delivery route registered INSIDE the guest scope (collection root),
+ # gated against re-fire in nested sub-scopes. `reinstantiate = true`
+ # ships the keyed wrappers verbatim.
+ deliverPolicy = den.lib.policy.mkPolicy "deliver-reinst" (
+ { ... }@args:
+ lib.optionals (!(args ? user) && !(args ? home)) [
+ (den.lib.policy.route {
+ fromClass = "nixos";
+ intoClass = "nixos";
+ collectSubtree = true;
+ appendToParent = true;
+ reinstantiate = true;
+ path = [
+ "microvm"
+ "vms"
+ "guest"
+ "config"
+ ];
+ })
+ ]
+ );
+ # Base module of the target system: declares `fromBase` WITH A DEFAULT
+ # (the analogue of NixOS boot.* defaults) and is freeform so authored
+ # guest content lands.
+ reinstantiatingBase =
+ { lib, ... }:
+ {
+ options.fromBase = lib.mkOption {
+ type = lib.types.str;
+ default = "BASE-DEFAULT";
+ };
+ config._module.freeformType = lib.types.lazyAttrsOf lib.types.anything;
+ };
+ # A target slot whose `merge` re-runs evalModules over the delivered
+ # defs together with the base module-list — exactly as microvm's
+ # eval-config does. Consumer reads `.config.config.*`.
+ reinstantiatingSlot =
+ { lib, ... }:
+ {
+ options.microvm.vms = lib.mkOption {
+ default = { };
+ type = lib.types.attrsOf (
+ lib.types.submodule {
+ options.config = lib.mkOption {
+ default = null;
+ type = lib.types.nullOr (
+ lib.mkOptionType {
+ name = "reinstantiated NixOS config";
+ merge =
+ _loc: defs:
+ lib.evalModules {
+ modules = [ reinstantiatingBase ] ++ map (d: d.value) defs;
+ };
+ }
+ );
+ };
+ }
+ );
+ };
+ };
+ in
+ {
+ den.hosts.x86_64-linux.igloo.users.tux = { };
+ den.schema.iso-kind = {
+ isEntity = true;
+ parent = "host";
+ isolated = true;
+ };
+ den.policies.resolve-reinst-child =
+ { host, ... }:
+ lib.optionals (host.name == "igloo") [
+ (den.lib.policy.resolve.to.withIncludes "iso-kind" [ deliverPolicy ] {
+ iso-kind = guestEntity;
+ })
+ ];
+ den.schema.host.includes = [ den.policies.resolve-reinst-child ];
+ den.aspects.igloo.nixos.imports = [ reinstantiatingSlot ];
+ den.aspects.guest-aspect.nixos =
+ { config, ... }:
+ {
+ networking.hostName = "guest-vm";
+ # Reads a default declared by a BASE module of the target system.
+ echoed = config.fromBase;
+ };
+
+ # `.config.config` — first `.config` is the slot option, second is the
+ # re-instantiated evalModules result's config.
+ expr = {
+ hn = igloo.microvm.vms.guest.config.config.networking.hostName;
+ echoed = igloo.microvm.vms.guest.config.config.echoed;
+ };
+ expected = {
+ hn = "guest-vm";
+ echoed = "BASE-DEFAULT";
+ };
+ }
+ );
+
};
}
diff --git a/templates/ci/modules/public-api/user-host-mutual-config.nix b/templates/ci/modules/public-api/user-host-mutual-config.nix
index 1a43ca677..e2de124cc 100644
--- a/templates/ci/modules/public-api/user-host-mutual-config.nix
+++ b/templates/ci/modules/public-api/user-host-mutual-config.nix
@@ -134,11 +134,14 @@
tuxHm.programs.direnv.enable
pinguHm.programs.direnv.enable
];
- # { host, user } parametric includes resolve once per user
- # when both args are available (fan-out via deferred drain).
+ # §6 rule-correct: { host, user } at host scope fans out over users, but
+ # the content is homeManager — a class the HOST scope does not resolve.
+ # It is class-local to the host (inert), so users' HM never receives it.
+ # homeManager from a host-scope aspect must reach users via to-users
+ # policy (test-host-parametric-mutual below), not a bare include.
expected = [
- true
- true
+ false
+ false
];
}
);
diff --git a/templates/ci/provider/flake.lock b/templates/ci/provider/flake.lock
index ae33b4cd2..9e7451297 100644
--- a/templates/ci/provider/flake.lock
+++ b/templates/ci/provider/flake.lock
@@ -2,26 +2,46 @@
"nodes": {
"den": {
"locked": {
- "lastModified": 1774498900,
- "narHash": "sha256-THw/ly8KvXGQ0EI+Nhu/Eo9w8w7wtgHhAeWpteNiz/Q=",
- "owner": "vic",
+ "lastModified": 1779919306,
+ "narHash": "sha256-eo2Snm2hoYAq8MA7bfjnn8p36riLcpJDA74Fzx9gcsY=",
+ "owner": "denful",
"repo": "den",
- "rev": "eb92bbfdefd22b76fa5781e8adbeff42c4fe429e",
+ "rev": "fba67817bd16955e10ff158c9758874031af089c",
"type": "github"
},
"original": {
- "owner": "vic",
+ "owner": "denful",
"repo": "den",
"type": "github"
}
},
+ "gen-schema": {
+ "inputs": {
+ "nixpkgs": [
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1779986641,
+ "narHash": "sha256-KcZuS+hpaloICFcepNXNLpbehh6XoPjWPBteYpTqMRw=",
+ "owner": "sini",
+ "repo": "gen-schema",
+ "rev": "4bd0f6eb1799bf3c38eb3707419157b1f70eb1f5",
+ "type": "github"
+ },
+ "original": {
+ "owner": "sini",
+ "repo": "gen-schema",
+ "type": "github"
+ }
+ },
"import-tree": {
"locked": {
- "lastModified": 1773693634,
- "narHash": "sha256-BtZ2dtkBdSUnFPPFc+n0kcMbgaTxzFNPv2iaO326Ffg=",
+ "lastModified": 1778781969,
+ "narHash": "sha256-Jjuz5CmSkur8KvLDoGa+vylEp+RkQtv4mt/qcMznpH0=",
"owner": "vic",
"repo": "import-tree",
- "rev": "c41e7d58045f9057880b0d85e1152d6a4430dbf1",
+ "rev": "d321337efd0f23a9eb14a42adb7b2c29313ab274",
"type": "github"
},
"original": {
@@ -32,11 +52,11 @@
},
"nixpkgs": {
"locked": {
- "lastModified": 1775710090,
- "narHash": "sha256-WGjBfvXv/mcg5yBg+AtK1Q3FHyXfjAAeJROmg7DLYfM=",
- "rev": "4c1018dae018162ec878d42fec712642d214fdfa",
+ "lastModified": 1779560665,
+ "narHash": "sha256-NpH8iEQ5JHv/BtUuzTEXUMDxPLetCDzIv4OxL8H7Kps=",
+ "rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
"type": "tarball",
- "url": "https://releases.nixos.org/nixos/unstable/nixos-26.05pre977467.4c1018dae018/nixexprs.tar.xz"
+ "url": "https://releases.nixos.org/nixos/unstable/nixos-26.05pre1004030.64c08a7ca051/nixexprs.tar.xz?lastModified=1779560665&rev=64c08a7ca051951c8eae34e3e3cb1e202fe36786"
},
"original": {
"type": "tarball",
@@ -46,6 +66,7 @@
"root": {
"inputs": {
"den": "den",
+ "gen-schema": "gen-schema",
"import-tree": "import-tree",
"nixpkgs": "nixpkgs"
}
diff --git a/templates/ci/provider/flake.nix b/templates/ci/provider/flake.nix
index 2380d7309..5c682e574 100644
--- a/templates/ci/provider/flake.nix
+++ b/templates/ci/provider/flake.nix
@@ -10,5 +10,7 @@
nixpkgs.url = "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz";
import-tree.url = "github:vic/import-tree";
den.url = "github:denful/den";
+ gen-schema.url = "github:sini/gen-schema";
+ gen-schema.inputs.nixpkgs.follows = "nixpkgs";
};
}
diff --git a/templates/default/flake.lock b/templates/default/flake.lock
index 0cb37d10c..f1ec07ebe 100644
--- a/templates/default/flake.lock
+++ b/templates/default/flake.lock
@@ -2,11 +2,11 @@
"nodes": {
"den": {
"locked": {
- "lastModified": 1776710169,
- "narHash": "sha256-q4WXIX2E3w9Ld3MZ1Pl8Lh5SgrEFdEuzvY1Lj/Wo2kY=",
+ "lastModified": 1779919306,
+ "narHash": "sha256-eo2Snm2hoYAq8MA7bfjnn8p36riLcpJDA74Fzx9gcsY=",
"owner": "denful",
"repo": "den",
- "rev": "0af82e24be89b9fd400bd0b58b0fed5ea0f269ad",
+ "rev": "fba67817bd16955e10ff158c9758874031af089c",
"type": "github"
},
"original": {
diff --git a/templates/diagram-demo/modules/aspects/features/backup.nix b/templates/diagram-demo/modules/aspects/features/backup.nix
index 11b733d80..877a73df2 100644
--- a/templates/diagram-demo/modules/aspects/features/backup.nix
+++ b/templates/diagram-demo/modules/aspects/features/backup.nix
@@ -1,12 +1,12 @@
# Parametric aspect: backup configuration adapts to the host it runs on.
#
-# den.lib.perHost wraps a function that receives { host } from the
-# pipeline context. The host entity is provided by the host-to-users
-# policy chain — this aspect consumes it to derive per-host paths.
+# A plain function receiving { host } from the pipeline context. The host
+# entity is bound at the host scope by handler-based resolution — this aspect
+# consumes it to derive per-host paths.
{ den, ... }:
{
- den.aspects.backup = den.lib.perHost (
- { host }:
+ den.aspects.backup =
+ { host, ... }:
{
nixos.services.restic.backups.system = {
repository = "s3:backup.example.com/${host.hostName}";
@@ -17,6 +17,5 @@
"/home"
];
};
- }
- );
+ };
}
diff --git a/templates/diagram-demo/modules/aspects/features/mail.nix b/templates/diagram-demo/modules/aspects/features/mail.nix
index d7f0a9b94..9d940b771 100644
--- a/templates/diagram-demo/modules/aspects/features/mail.nix
+++ b/templates/diagram-demo/modules/aspects/features/mail.nix
@@ -1,14 +1,14 @@
# Parametric aspect: mail relay configured per-host.
-# den.lib.perHost provides { host } from the pipeline context.
+# A plain function receiving { host } from the pipeline context; bound at the
+# host scope by handler-based resolution.
{ den, ... }:
{
- den.aspects.mail = den.lib.perHost (
- { host }:
+ den.aspects.mail =
+ { host, ... }:
{
nixos.services.postfix = {
enable = true;
hostname = host.hostName;
};
- }
- );
+ };
}
diff --git a/templates/diagram-demo/modules/aspects/users/alice.nix b/templates/diagram-demo/modules/aspects/users/alice.nix
index cdbb151d6..c31d714dc 100644
--- a/templates/diagram-demo/modules/aspects/users/alice.nix
+++ b/templates/diagram-demo/modules/aspects/users/alice.nix
@@ -74,10 +74,11 @@
den.aspects.demo-shell
den.aspects.hyprland
den.aspects.dev-tools
- # Home-level roles: only materialize in home contexts.
- (den.lib.perHome den.aspects.home-dev)
- (den.lib.perHome den.aspects.home-productivity)
- (den.lib.perHome den.aspects.alice-dotfiles)
+ # Home-level roles: these aspects carry homeManager-class content;
+ # inert at host/user scopes, they materialize in the home class.
+ den.aspects.home-dev
+ den.aspects.home-productivity
+ den.aspects.alice-dotfiles
];
nixos =
{ ... }:
diff --git a/templates/example/flake.lock b/templates/example/flake.lock
index 3dbcb6556..9c52a348f 100644
--- a/templates/example/flake.lock
+++ b/templates/example/flake.lock
@@ -22,11 +22,11 @@
},
"den": {
"locked": {
- "lastModified": 1776710169,
- "narHash": "sha256-q4WXIX2E3w9Ld3MZ1Pl8Lh5SgrEFdEuzvY1Lj/Wo2kY=",
+ "lastModified": 1779919306,
+ "narHash": "sha256-eo2Snm2hoYAq8MA7bfjnn8p36riLcpJDA74Fzx9gcsY=",
"owner": "denful",
"repo": "den",
- "rev": "0af82e24be89b9fd400bd0b58b0fed5ea0f269ad",
+ "rev": "fba67817bd16955e10ff158c9758874031af089c",
"type": "github"
},
"original": {
diff --git a/templates/example/modules/aspects/defaults.nix b/templates/example/modules/aspects/defaults.nix
index 9e32343b2..2a0b2f004 100644
--- a/templates/example/modules/aspects/defaults.nix
+++ b/templates/example/modules/aspects/defaults.nix
@@ -32,11 +32,15 @@
# # This will append 42 into foo option for the {host} and for EVERY {host,user}
# ({ host, ... }: { nixos.foo = [ 42 ]; }) # DO-NOT-DO-THIS.
#
- # # Instead try to be explicit if a function is intended for ONLY { host }
- # den.lib.perHost ({ host }: { nixos.foo = [ 42 ]; })
- # # Or for { host, user } ONLY:
- # den.lib.perUser ({ host, user }: { nixos.foo = [ 42 ]; })
- # # Or for standalone homes ({ home }) ONLY:
- # den.lib.perHome ({ home }: { homeManager.foo = [ 42 ]; })
+ # # A plain function destructuring { host } binds host once at the host
+ # # scope (nixos-class content emits there):
+ # ({ host, ... }: { nixos.foo = [ 42 ]; })
+ # # Destructuring { host, user } fans out over the host's users and emits
+ # # on the host (one nixos contribution per user); the bound user is the
+ # # arg source, not the output target. At user scope both args are in-ctx
+ # # and it binds once:
+ # ({ host, user, ... }: { nixos.foo = [ 42 ]; })
+ # # Destructuring { home } binds home at standalone-home scope:
+ # ({ home, ... }: { homeManager.foo = [ 42 ]; })
];
}
diff --git a/templates/flake-parts-modules/flake.lock b/templates/flake-parts-modules/flake.lock
index 707659154..d4da16a77 100644
--- a/templates/flake-parts-modules/flake.lock
+++ b/templates/flake-parts-modules/flake.lock
@@ -2,14 +2,17 @@
"nodes": {
"den": {
"locked": {
- "lastModified": 0,
- "narHash": "sha256-pY26dcZqdakXHdioTjb3Ae+ELyGZn9SGYXbkGzNp4h4=",
- "path": "../..",
- "type": "path"
+ "lastModified": 1781376156,
+ "narHash": "sha256-/GXybz+d8f15E4kBkUWGFZnrbK8PAqXiLmEaDF/nQjU=",
+ "owner": "denful",
+ "repo": "den",
+ "rev": "dfc4617c22e326892bf47a1a1a72a13d46aabb3e",
+ "type": "github"
},
"original": {
- "path": "../..",
- "type": "path"
+ "owner": "denful",
+ "repo": "den",
+ "type": "github"
}
},
"devshell": {
diff --git a/templates/flake-parts-modules/flake.nix b/templates/flake-parts-modules/flake.nix
index 22dc9ce20..bc80754fb 100644
--- a/templates/flake-parts-modules/flake.nix
+++ b/templates/flake-parts-modules/flake.nix
@@ -4,7 +4,7 @@
outputs = inputs: inputs.flake-parts.lib.mkFlake { inherit inputs; } (inputs.import-tree ./modules);
inputs = {
- den.url = "path:../..";
+ den.url = "github:denful/den";
import-tree.url = "github:vic/import-tree";
nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
flake-parts.url = "github:hercules-ci/flake-parts";
diff --git a/templates/microvm/flake.lock b/templates/microvm/flake.lock
index a3c43a621..7509118d2 100644
--- a/templates/microvm/flake.lock
+++ b/templates/microvm/flake.lock
@@ -2,11 +2,11 @@
"nodes": {
"den": {
"locked": {
- "lastModified": 1776710169,
- "narHash": "sha256-q4WXIX2E3w9Ld3MZ1Pl8Lh5SgrEFdEuzvY1Lj/Wo2kY=",
+ "lastModified": 1781376156,
+ "narHash": "sha256-/GXybz+d8f15E4kBkUWGFZnrbK8PAqXiLmEaDF/nQjU=",
"owner": "denful",
"repo": "den",
- "rev": "0af82e24be89b9fd400bd0b58b0fed5ea0f269ad",
+ "rev": "dfc4617c22e326892bf47a1a1a72a13d46aabb3e",
"type": "github"
},
"original": {
diff --git a/templates/minimal/flake.lock b/templates/minimal/flake.lock
index f41ba0f42..46a486632 100644
--- a/templates/minimal/flake.lock
+++ b/templates/minimal/flake.lock
@@ -2,11 +2,11 @@
"nodes": {
"den": {
"locked": {
- "lastModified": 1776710169,
- "narHash": "sha256-q4WXIX2E3w9Ld3MZ1Pl8Lh5SgrEFdEuzvY1Lj/Wo2kY=",
+ "lastModified": 1779919306,
+ "narHash": "sha256-eo2Snm2hoYAq8MA7bfjnn8p36riLcpJDA74Fzx9gcsY=",
"owner": "denful",
"repo": "den",
- "rev": "0af82e24be89b9fd400bd0b58b0fed5ea0f269ad",
+ "rev": "fba67817bd16955e10ff158c9758874031af089c",
"type": "github"
},
"original": {
diff --git a/templates/noflake/npins/sources.json b/templates/noflake/npins/sources.json
index 5e2ef5668..d7c056b84 100644
--- a/templates/noflake/npins/sources.json
+++ b/templates/noflake/npins/sources.json
@@ -4,14 +4,14 @@
"type": "Git",
"repository": {
"type": "GitHub",
- "owner": "vic",
+ "owner": "denful",
"repo": "den"
},
"branch": "main",
"submodules": false,
- "revision": "0af82e24be89b9fd400bd0b58b0fed5ea0f269ad",
- "url": "https://github.com/vic/den/archive/0af82e24be89b9fd400bd0b58b0fed5ea0f269ad.tar.gz",
- "hash": "sha256-q4WXIX2E3w9Ld3MZ1Pl8Lh5SgrEFdEuzvY1Lj/Wo2kY="
+ "revision": "fba67817bd16955e10ff158c9758874031af089c",
+ "url": "https://github.com/denful/den/archive/fba67817bd16955e10ff158c9758874031af089c.tar.gz",
+ "hash": "sha256-eo2Snm2hoYAq8MA7bfjnn8p36riLcpJDA74Fzx9gcsY="
},
"hjem": {
"type": "Git",
diff --git a/templates/nvf-standalone/flake.lock b/templates/nvf-standalone/flake.lock
index 288b80655..ae52570cd 100644
--- a/templates/nvf-standalone/flake.lock
+++ b/templates/nvf-standalone/flake.lock
@@ -2,11 +2,11 @@
"nodes": {
"den": {
"locked": {
- "lastModified": 1776710169,
- "narHash": "sha256-q4WXIX2E3w9Ld3MZ1Pl8Lh5SgrEFdEuzvY1Lj/Wo2kY=",
+ "lastModified": 1781376156,
+ "narHash": "sha256-/GXybz+d8f15E4kBkUWGFZnrbK8PAqXiLmEaDF/nQjU=",
"owner": "denful",
"repo": "den",
- "rev": "0af82e24be89b9fd400bd0b58b0fed5ea0f269ad",
+ "rev": "dfc4617c22e326892bf47a1a1a72a13d46aabb3e",
"type": "github"
},
"original": {