Date: 2026-05-22
Status: Design approved
Location: scope-engine/templates/nest-traits/
Proof-of-concept that nest's traits model can be rebuilt on gen-schema + gen-aspects + scope-engine foundations. Full-fidelity replica of all four layers: trait composition (needs/neededBy), DOM traversal with attribute inheritance, CSS selector engine, and rule-based class content delivery. Also serves as a standalone demonstration template.
Three-layer design where each library does what it's best at:
- gen-schema — trait definitions (as schema entries with sidecars) + node instance registry
- gen-aspects — rule content with class-separated
deferredModuleoutput - scope-engine — DOM hierarchy (parent edges), trait needs (import edges), expansion (query with transitiveImports), structural queries, HOAG eval with synthesize callback
templates/nest-traits/
├── flake.nix # inputs: scope-engine, gen-schema, gen-aspects, nixpkgs
├── lib/
│ ├── default.nix # public API: evalNest, selectors, trait/node builders
│ ├── engine.nix # 5-phase evaluation pipeline
│ ├── selectors.nix # CSS selector matching engine
│ ├── css.nix # CSS string parser
│ ├── dom.nix # DOM traversal → scope-engine graph construction
│ └── traits.nix # trait expansion (needs/neededBy) via scope-engine
├── graph.nix # scope graph wiring (buildNodes from DOM + traits)
├── attributes.nix # HOAG attribute definitions (synth, rule annotation)
├── tests.nix # nix-unit test suite (~125 tests)
├── demo.nix # narrative fleet example
└── README.md
User input (traits, DOM, rules)
→ gen-schema: validate trait defs + node instances
→ dom.nix: walk DOM tree → scope-engine vertices + parent edges
→ traits.nix: trait needs → import edges, BFS expansion over scope-engine graph
→ traits.nix: neededBy → reverse queries, inject matching traits
→ engine.nix phase 2: trait synth → scope-engine eval with synthesize callback
→ engine.nix phase 3: rule matching via selectors.nix → gen-aspects class content collected
→ engine.nix phase 4: rule synth → re-annotate virtual children
→ engine.nix phase 5: output processing → class functions build final values
Traits are gen-schema entries with sidecars for nest's special keys:
options.schema = schemaLib.mkSchemaOption {
sidecars = {
needs = { default = []; }; # list of trait refs, merged via ++
neededBy = { default = []; }; # list of selectors, merged via ++
synth = { default = []; }; # list of synth fns, merged via ++
class = { default = {}; }; # attrset of class builders, merged via //
};
};All sidecars are composable across modules — multiple modules can contribute needs, neededBy, and synth to the same trait via ++ merge.
Design improvement over nest: synth is a list of functions (nest uses a single function). The engine folds all synth functions in order, deep-merging results. This enables multi-module composition — e.g., a monitoring plugin can add synth logic to a host trait without replacing the original synth.
neededBy dispatch: Each entry in the neededBy list is an independent selector. The engine iterates entries and injects the trait into any node matching any entry (OR semantics). This differs from passing the raw list to matchesOne (which would be AND). The engine explicitly iterates: builtins.any (sel: matchesOne node sel ctx) trait.neededBy.
Trait definitions:
config.schema.host = {
class.nixos = select: modules: mockNixosSystem { inherit modules; };
class.homeManager = select: modules: mockHmConfig { inherit modules; };
synth = [
({ select, ... }: {
node.userCount = builtins.length (select.children user);
})
];
};
config.schema.server = {
needs = [ config.schema.nginx config.schema.firewall ];
};
config.schema.monitoring = {
neededBy = [ config.schema.server ];
};Each trait gets __traitName from gen-schema's name option (attrset key). Sidecars stay outside the module system.
config.schema.node = {
options.is = lib.mkOption {
type = lib.types.listOf (schemaLib.ref "trait");
description = "Traits assigned to this node.";
};
options.system = lib.mkOption {
type = lib.types.str;
default = "x86_64-linux";
};
# freeform for user-defined attrs (env, region, etc.)
};Node instances in a registry:
options.fleet.nodes = schemaLib.mkInstanceRegistry config.schema "node" {
refs = { trait = config.schema; };
};
config.fleet.nodes.web-1 = {
is = [ "host" "web" ];
system = "x86_64-linux";
};gen-schema provides: strict validation, identity hashing, ref validation (trait names must exist), _meta introspection.
DOM hierarchy → parent edges:
parentGraph = scope-engine.overlays [
(scope-engine.edge "prod.web-1" "prod")
(scope-engine.edge "prod.web-2" "prod")
(scope-engine.edge "prod.web-1.users.alice" "prod.web-1")
];dom.nix walks the user's DOM attrset using the same recursive algorithm as nest's walkDom. A node is identified by having an is field that is a list (of traits). Attrsets without is are namespace folders — their non-attrset scalar values (e.g., env = "prod") are collected as inherited attributes and passed down to child nodes. The walk produces scope-engine vertices (with declarations for name, is, inherited attrs) + parent edges (P label).
Trait needs → import edges between trait vertices. Nest's expandTraits (following needs chains with BFS queue + seen-set dedup) is implemented in traits.nix as a BFS traversal over the import graph built from needs edges. This uses scope-engine's graph structure for edge lookups but implements its own queue-based expansion (matching nest's algorithm) rather than using scope-engine.query directly, since the expansion needs to produce an ordered trait list with dedup by __traitName.
For each trait with neededBy selectors, query all nodes. If a node matches any selector, inject the trait. Re-expand after injection (neededBy can trigger further needs chains).
Nest's trait synth is implemented by passing a synthesize callback to scope-engine.eval. The callback receives the current node state and returns new vertices (virtual children) via monotone-add. Synth functions from the trait's sidecar list are folded in order, deep-merging their results. The synthesize callback is not a standalone scope-engine export — it's a parameter to eval that the template provides.
- Graph structure (vertices, edges,
buildNodes) for DOM hierarchy and trait relationships buildNodesvalidates single-parent invariant on DOM- Parent/children/ancestors/siblings queries for selector context
evalwithsynthesizecallback for trait synth (virtual children, derived attrs)evalDebugfor diagnosing cycles in trait needs graphs
Rules are gen-aspects aspects with class-separated content:
rulesType = aspects.aspectsType {
classes = { nixos = {}; homeManager = {}; };
aspectModules = [
{
options.is = lib.mkOption {
type = lib.types.nullOr lib.types.raw;
default = null;
description = "Selector: trait, CSS string, selector attrset, or list (AND).";
};
}
];
};cnf.aspectModules injects the is selector option onto every rule aspect. Class keys become explicit deferredModule options.
Rule declarations:
config.rules.enable-nginx = {
is = config.schema.server;
nixos.services.nginx.enable = true;
};
config.rules.haproxy-backends = {
is = config.schema.lb;
nixos = { select, ... }: {
services.haproxy.backends =
map (w: w.addr) (select config.schema.web);
};
};
config.rules.sudo-for-admin-parents = {
is = [ config.schema.host (selectors.has config.schema.admin) ];
nixos.security.sudo.enable = true;
};For each node, the engine iterates all rules, runs selectors.matchesOne node rule.is ctx, and collects class-keyed content as lists. deferredModule values stay unevaluated until passed to the class function.
Rule functions ({ select, host, ... }:) use nest's callWithArgs pattern — the engine calls them with select + entity args at match time. These are raw functions in the aspect's freeform layer, not dispatched by gen-aspects' type system.
deferredModuleclass content — inspectable before forcing, correct NixOS module system merging- Palmer dispatch — attrsets and module functions both work as rule content
cnf.aspectModules— theisselector field injected without modifying gen-aspects core- Clean class output — no structural metadata mixed into nixos/homeManager content
Template-local code, ported from nest (~270 lines across two files).
matchesOne node sel ctx dispatches by selector shape:
- List → AND (all must match)
- String → parse via css.nix, re-dispatch
__selattrset → handler dispatch (star, id, attr, attrs, or, not, has, within, when, class, child, descendant)__traitNameattrset → trait membership check onnode.is
Context built from scope-engine queries, wrapped to produce node lists (nest's ctx shape):
# scope-engine queries return IDs/attrsets — mkCtx wraps them into
# flat node lists matching nest's expected ctx format.
mkCtx = nodes: nodeId:
let
childIds = scope-engine.childrenIds nodes nodeId;
toNode = id: nodes.${id};
in {
children = map toNode childIds;
ancestors = map toNode (scope-engine.ancestors nodes nodeId);
siblings = map toNode (scope-engine.siblings nodes nodeId);
allNodes = lib.mapAttrsToList (_: id: toNode id) nodes;
select = mkSelect nodes nodeId;
};Selector constructors exported:
selectors = {
star = { __sel = "star"; };
attrs = a: { __sel = "attrs"; attrs = a; };
or = ss: { __sel = "or"; selectors = ss; };
not = s: { __sel = "not"; selector = s; };
has = s: { __sel = "has"; selector = s; };
within = s: { __sel = "within"; selector = s; };
when = f: { __sel = "when"; fn = f; };
class = n: { __sel = "class"; name = n; };
child = p: c: { __sel = "child"; parentSel = p; childSel = c; };
descendant = a: d: { __sel = "descendant"; ancestorSel = a; descendantSel = d; };
};Direct port of nest's CSS string parser. Tokenizes into selector attrsets. Purely syntactic sugar.
dom.nixwalks user attrset → scope-engine vertices + parent edges viabuildNodes- Expand
istraits: BFS overneedsimport edges (queue + seen-set dedup) - Run
neededBy: for each trait withneededByselectors, query all nodes, inject into matches - Re-expand after injection (neededBy can trigger further needs chains)
- For each node, find entity trait (first trait with
classsidecar) - Run all
synthfunctions from that trait's sidecar list - Deep-merge results, inject virtual children as new scope-engine vertices via
eval'ssynthesizecallback - Virtual children get full trait expansion (phase 1 re-runs on them)
- Build context cache (one
ctxper node from scope-engine queries) - For each node, filter rules where
selectors.matchesOne node rule.is ctx - Collect class-keyed content as lists (deferredModules from gen-aspects)
- Synth keys deep-merged separately (not list-collected)
- Apply
__mergedCfg.synthon nodes with it - Inject virtual children, re-run trait expansion
- Re-annotate new children against all rules
- Find root nodes (no parent in annotated set)
- For each root, find entity trait's
classsidecar - Collect child class contributions recursively (
collectChildFrags) - Call class function:
classFn select modules - Route outputs into
{ outputs, byClass }
evalNest {
trait = { /* gen-schema trait definitions */ };
rules = [ /* gen-aspects rule aspects */ ];
# everything else is DOM
prod = {
web-1 = { is = [ trait.host trait.web ]; system = "x86_64-linux"; };
web-2 = { is = [ trait.host trait.web ]; };
};
}
# → { outputs = { web-1 = ...; web-2 = ...; }; byClass = { nixos = { ... }; }; }Same signature as nest's evalNest.
Mock by default, real nixpkgs optional:
- Mock mode (default): Class functions collect modules into plain attrsets. No nixpkgs dependency.
- Real mode: Pass nixpkgs as input, class functions call
nixosSystem/homeManagerConfiguration.
Controlled by the class sidecar on entity traits — users define their own class functions.
All tests in tests.nix, exported as flake.tests.nest-traits.* for nix-unit.
needsexpansion: single, chained, diamond dedup, circular safetyneededByinjection: trait match, selector match, re-expansionsynthcomposition: single, multiple fns merged, virtual children- Validation: ref to nonexistent trait errors, duplicate names
- Flat walk, nested namespaces, scalar attribute inheritance
- Node identification (
is= list → node, nois→ namespace) - Path construction (
__path,__parentPath) - Scope-engine graph output: correct parent edges, vertex declarations
- Trait matching,
#id,.class,[attr=val],[attr] :has,:within,:not,:when- Child combinator (
>), descendant combinator (+) - Compound selectors (list = AND)
- CSS string parsing: all token types, combinators,
,(OR) callWithArgs: arg injection, entity-typed args
- Full pipeline: traits + DOM + rules → outputs
- Rule matching with various selector types
- Class content collection as lists
- Trait synth + rule synth interaction
- Virtual children participate in rule matching
collectChildFrags: child contributions bubble upbyClassoutput routing- Mock vs real class functions
- Full fleet scenario (lb, web-1, web-2, users, admin)
- Cross-node select queries in rules
neededByinjection in realistic scenario- Output shape matches nest's equivalent