Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save sini/ac30bf7a21c28ced3d6592e06bc923ea to your computer and use it in GitHub Desktop.

Select an option

Save sini/ac30bf7a21c28ced3d6592e06bc923ea to your computer and use it in GitHub Desktop.
Nest traits model on gen-schema + gen-aspects + scope-engine — design spec

Nest Traits Model on gen-schema + gen-aspects + scope-engine

Date: 2026-05-22 Status: Design approved Location: scope-engine/templates/nest-traits/

Goal

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.

Architecture

Three-layer design where each library does what it's best at:

  1. gen-schema — trait definitions (as schema entries with sidecars) + node instance registry
  2. gen-aspects — rule content with class-separated deferredModule output
  3. scope-engine — DOM hierarchy (parent edges), trait needs (import edges), expansion (query with transitiveImports), structural queries, HOAG eval with synthesize callback

File Layout

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

Data Flow

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

gen-schema Layer

Trait Kind

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.

Node Kind

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.

scope-engine Layer

Graph Construction

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 Expansion

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.

neededBy — Reverse Query

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).

Trait Synth — HOAG Synthesis

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.

What scope-engine provides

  • Graph structure (vertices, edges, buildNodes) for DOM hierarchy and trait relationships
  • buildNodes validates single-parent invariant on DOM
  • Parent/children/ancestors/siblings queries for selector context
  • eval with synthesize callback for trait synth (virtual children, derived attrs)
  • evalDebug for diagnosing cycles in trait needs graphs

gen-aspects Layer

Rule Content

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;
};

Processing

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.

What gen-aspects provides

  • deferredModule class content — inspectable before forcing, correct NixOS module system merging
  • Palmer dispatch — attrsets and module functions both work as rule content
  • cnf.aspectModules — the is selector field injected without modifying gen-aspects core
  • Clean class output — no structural metadata mixed into nixos/homeManager content

Selector Engine

Template-local code, ported from nest (~270 lines across two files).

selectors.nix (~135 lines)

matchesOne node sel ctx dispatches by selector shape:

  • List → AND (all must match)
  • String → parse via css.nix, re-dispatch
  • __sel attrset → handler dispatch (star, id, attr, attrs, or, not, has, within, when, class, child, descendant)
  • __traitName attrset → trait membership check on node.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; };
};

css.nix (~133 lines)

Direct port of nest's CSS string parser. Tokenizes into selector attrsets. Purely syntactic sugar.

Engine Pipeline (engine.nix)

Phase 1 — DOM traversal + trait expansion

  • dom.nix walks user attrset → scope-engine vertices + parent edges via buildNodes
  • Expand is traits: BFS over needs import edges (queue + seen-set dedup)
  • Run neededBy: for each trait with neededBy selectors, query all nodes, inject into matches
  • Re-expand after injection (neededBy can trigger further needs chains)

Phase 2 — Trait synth

  • For each node, find entity trait (first trait with class sidecar)
  • Run all synth functions from that trait's sidecar list
  • Deep-merge results, inject virtual children as new scope-engine vertices via eval's synthesize callback
  • Virtual children get full trait expansion (phase 1 re-runs on them)

Phase 3 — Rule annotation

  • Build context cache (one ctx per 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)

Phase 4 — Rule synth

  • Apply __mergedCfg.synth on nodes with it
  • Inject virtual children, re-run trait expansion
  • Re-annotate new children against all rules

Phase 5 — Output processing

  • Find root nodes (no parent in annotated set)
  • For each root, find entity trait's class sidecar
  • Collect child class contributions recursively (collectChildFrags)
  • Call class function: classFn select modules
  • Route outputs into { outputs, byClass }

Public API

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.

Class Output Modes

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.

Test Suite (~125 tests)

All tests in tests.nix, exported as flake.tests.nest-traits.* for nix-unit.

Trait tests (~30)

  • needs expansion: single, chained, diamond dedup, circular safety
  • neededBy injection: trait match, selector match, re-expansion
  • synth composition: single, multiple fns merged, virtual children
  • Validation: ref to nonexistent trait errors, duplicate names

DOM tests (~20)

  • Flat walk, nested namespaces, scalar attribute inheritance
  • Node identification (is = list → node, no is → namespace)
  • Path construction (__path, __parentPath)
  • Scope-engine graph output: correct parent edges, vertex declarations

Selector tests (~35)

  • 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

Engine integration tests (~30)

  • 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 up
  • byClass output routing
  • Mock vs real class functions

Demo test (~10)

  • Full fleet scenario (lb, web-1, web-2, users, admin)
  • Cross-node select queries in rules
  • neededBy injection in realistic scenario
  • Output shape matches nest's equivalent
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment