Date: 2026-05-22
Status: Design approved
Location: scope-engine/templates/nest-traits/
Prerequisite: Builds on the existing nest-traits template (feat/nest-traits-template branch)
Replace the convention-based trait model (__traitName, magic key checks, raw attrsets) with gen-schema-native traits. Traits become instances of a trait kind in a gen-schema registry. The engine works with trait instances directly, never checking for magic keys on raw attrsets.
The current template ports nest's raw-attrset conventions into a system that has typed registries. This creates redundancy:
__traitNameduplicates gen-schema'sname(auto from attrset key)needs/neededByuse raw attrset references with no validation — gen-schema refs catch typosinjectNames/flattenTraitTreehand-walk trait trees —builtins.attrNames registrydoes thisfirstMatch (t: t ? class)checks magic keys — gen-schema instance fields are typed options- No validation of trait definitions — gen-schema strict mode catches undeclared fields
trait is a gen-schema kind with typed options for all trait fields:
config.schema.trait = {
options.needs = lib.mkOption {
type = lib.types.listOf (schemaLib.ref "trait");
default = [];
description = "Forward trait dependencies (BFS expanded).";
};
options.neededBy = lib.mkOption {
type = lib.types.listOf lib.types.raw;
default = [];
description = "Reverse injection selectors. Each entry: trait instance (name match), CSS string, or selector attrset.";
# NOT ref-typed: neededBy accepts mixed types (instances, CSS strings, selector attrsets).
# Typed refs would coerce all strings to trait lookups, breaking CSS selectors.
# The engine dispatches by shape at runtime instead.
};
options.synth = lib.mkOption {
type = lib.types.listOf lib.types.raw;
default = [];
description = "Synthesis functions. Folded in order, results deep-merged.";
};
options.class = lib.mkOption {
type = lib.types.attrsOf lib.types.raw;
default = {};
description = "Output class builders. { className = select: modules: value; }";
};
};Traits are instances in a global registry with self-referencing refs for needs:
options.traits = schemaLib.mkInstanceRegistry config.schema "trait" {
refs = { trait = config.traits; };
};config.traits = {
host = { class.nixos = select: modules: nixosSystem { inherit modules; }; };
server = { needs = [ "nginx" "firewall" ]; }; # string refs, coerced to instances
nginx = { };
firewall = { };
monitoring = { neededBy = [ "server" ]; }; # trait name → instance match
};Gen-schema provides each instance: name (from key), id_hash, strict validation, ref validation for needs.
needs ref coercion note: gen-schema's mkRefBindingModules applies coercion at the field level, not per list element. listOf (ref "trait") may not automatically coerce string elements inside the list. The engine includes an explicit coercion step for needs alongside is resolution:
resolveNeeds = t: t // {
needs = map (x: if builtins.isString x then traits.${x} else x) (t.needs or []);
};If gen-schema's ref system handles listOf coercion natively (to be verified during implementation), this fallback can be removed. Either way, the engine produces correct results.
Nodes reference traits by name (string refs coerced to instances) or directly:
igloo = { is = [ "host" "server" ]; }; # string refs
igloo = { is = [ traits.host traits.server ]; }; # direct instance refsAfter evaluation, node.is always contains trait instances. The engine resolves string refs in Phase 1 before trait expansion:
# In evalNest, after DOM walk, before trait expansion:
resolveIs = node: node // {
is = map (x: if builtins.isString x then traits.${x} else x) node.is;
};
expandedNodes = map resolveIs rawNodes;This coercion step bridges DOM input (where users write string names) to the engine internals (which work with instances). Invalid trait names throw immediately: traits.${x} fails if the name doesn't exist in the registry.
# Before
evalNest { trait = { host = { __traitName = "host"; ... }; }; rules = [...]; ...dom }
# After
evalNest { traits = traitRegistry; rules = [...]; ...dom }traits is the evaluated registry (attrset of trait instances).
BFS over .needs (which are already resolved instances). Dedup by .name.
expandTraits = traitList:
let
go = seen: queue:
if queue == [] then seen
else
let t = builtins.head queue; rest = builtins.tail queue;
in if builtins.any (s: s.name == t.name) seen then go seen rest
else go (seen ++ [t]) (rest ++ (t.needs or []));
in go [] traitList;No allNodes parameter — trait expansion is purely about the trait graph, independent of DOM. No registry lookup needed — instances carry their own data.
Each neededBy entry is dispatched by shape:
- Trait instance (has
.nameand is in registry): match bybuiltins.any (t: t.name == entry.name) node.is - String that's a trait name (exists in traits): same as above after lookup
- String (other): parse as CSS selector via
css.parseCssSel - Attrset with
__sel: selector attrset, match viamatchesSel
matchesNeededByEntry = traits: entry: node: ctx:
if entry ? name && traits ? ${entry.name} then
builtins.any (t: t.name == entry.name) node.is
else if builtins.isString entry then
if traits ? ${entry} then builtins.any (t: t.name == entry) node.is
else matchesOne node (css.parseCssSel entry) ctx
else matchesOne node entry ctx;# Before
entityT = firstMatch (t: t ? class) node.is;
# After
entityTrait = firstMatch (t: (t.class or {}) != {}) node.is;No magic key check — class is a typed option on every trait instance (defaults to {}).
# Before: scan node.is for first trait with class, check if it has synth
entityT = firstMatch (t: t ? class) node.is;
synthFns = if entityT != null && entityT ? synth then entityT.synth else [];
# After: entity trait's synth is a typed option (defaults to [])
entityTrait = firstMatch (t: (t.class or {}) != {}) node.is;
synthFns = if entityTrait != null then entityTrait.synth else [];Root-only output with collectChildFrags (already implemented in the fix commit). No changes needed beyond the trait access pattern updates.
matchesOne = node: sel: ctx:
if builtins.isList sel then
builtins.all (s: matchesOne node s ctx) sel
else if sel ? name && sel ? needs then
# Trait instance — identified by having `needs` (a typed option present on all trait instances).
# This is reliable: selector attrsets use `__sel`, CSS strings are caught above,
# and no other attrset naturally has a `needs` field.
builtins.any (t: t.name == sel.name) node.is
else if builtins.isString sel then
# CSS string — parse and re-dispatch
matchesOne node (css.parseCssSel sel) ctx
else if sel ? __sel then
matchesSel node sel ctx
else
false;The traits parameter is no longer needed on matchesOne — trait matching uses instance identity (.name comparison), not registry lookup.
entityTrait = firstMatch (t: (t.class or {}) != {}) (node.is or []);
entityArgs = if entityTrait != null then { ${entityTrait.name} = node; } else { };class =
let entityTrait = firstMatch (t: (t.class or {}) != {}) node.is;
in entityTrait != null && entityTrait.class ? ${sel.name};walkDom drops the processedTraits parameter:
# Before
walkDom = processedTraits: dom: walkDomRec processedTraits "" null {} dom;
# After
walkDom = dom: walkDomRec "" null {} dom;buildDomGraph unchanged.
Replaces current stub/implementation with trait kind + registry helpers:
{ lib, schemaLib, aspects }:
{
# Define the trait kind with typed options
traitKind = {
options.needs = lib.mkOption {
type = lib.types.listOf (schemaLib.ref "trait");
default = [];
};
options.neededBy = lib.mkOption {
type = lib.types.listOf lib.types.raw;
default = [];
};
options.synth = lib.mkOption {
type = lib.types.listOf lib.types.raw;
default = [];
};
options.class = lib.mkOption {
type = lib.types.attrsOf lib.types.raw;
default = {};
};
};
# Create a self-referencing trait registry
mkTraitRegistry = schema: traits:
schemaLib.mkInstanceRegistry schema "trait" {
refs = { trait = traits; };
};
# Rules type via gen-aspects
mkRulesType = { classes ? { nixos = {}; homeManager = {}; } }:
aspects.aspectsType {
inherit classes;
aspectModules = [{
options.is = lib.mkOption {
type = lib.types.nullOr lib.types.raw;
default = null;
};
}];
};
# Full evalModules setup
evalNestModules = { modules, classes ? { nixos = {}; homeManager = {}; } }:
let
eval = lib.evalModules {
modules = [{
options.schema = schemaLib.mkSchemaOption {};
config.schema.trait = traitKind;
options.traits = mkTraitRegistry config.schema config.traits;
options.rules = lib.mkOption {
type = mkRulesType { inherit classes; };
default = {};
};
}] ++ modules;
};
in {
traits = eval.config.traits;
rules = eval.config.rules;
schema = eval.config.schema;
};
}All tests rewritten. mkTrait helper deleted. Traits defined as rec attrsets with name field. is contains instance refs.
# Before
mkTrait = name: extra: { __traitName = name; } // extra;
hostT = mkTrait "host" { class.nixos = mockNixos; };
serverT = mkTrait "server" { needs = [ nginxT ]; };
evalNest { trait = processedTraits; rules = [...]; igloo = { is = [ hostT ]; }; }
# After
traits = rec {
host = { name = "host"; class.nixos = mockNixos; needs = []; neededBy = []; synth = []; };
server = { name = "server"; needs = [ nginx ]; neededBy = []; synth = []; class = {}; };
nginx = { name = "nginx"; needs = []; neededBy = []; synth = []; class = {}; };
};
evalNest { inherit traits; rules = [...]; igloo = { is = [ traits.host ]; }; }Selector tests pass trait instances directly:
# Before
matchesOne web1 hostTrait (ctx web1)
# After
matchesOne web1 traits.host (ctx web1)Unit tests vs integration tests: Unit tests use rec attrsets with manual name fields for speed and isolation. Integration tests (in setup-tests suite) use evalNestModules with real gen-schema registries to verify the full coercion and validation path. Both are needed.
| Removed | Replaced by |
|---|---|
__traitName |
instance.name (gen-schema auto from key) |
injectNames |
Not needed — instances have names |
flattenTraitTree |
builtins.attrNames registry |
traitSpecialKeys |
gen-schema typed options |
mkTrait test helper |
rec attrsets with name field |
Magic key checks (t ? class) |
Typed option access (t.class or {}) |
| No validation | gen-schema strict mode + ref validation |
| File | Change |
|---|---|
setup.nix |
Define trait kind, mkTraitRegistry, evalNestModules |
engine.nix |
traits param (registry), instance-based access throughout |
selectors.nix |
Trait matching by instance .name, drop traits param |
dom.nix |
Drop processedTraits param from walkDom |
traits.nix |
Instance-based BFS, neededBy shape dispatch, delete flattenTraitTree/traitSpecialKeys |
css.nix |
No changes |
default.nix |
Update wiring |
tests.nix |
Full rewrite — rec trait registries, instance refs, string is |
README.md |
Update examples and API docs |