Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

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

Select an option

Save sini/31808d2ec88e034906e987fbded77de2 to your computer and use it in GitHub Desktop.
Nest traits schema-native redesign — replace __traitName conventions with gen-schema instances

Nest Traits Schema-Native Redesign

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)

Goal

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.

Motivation

The current template ports nest's raw-attrset conventions into a system that has typed registries. This creates redundancy:

  • __traitName duplicates gen-schema's name (auto from attrset key)
  • needs/neededBy use raw attrset references with no validation — gen-schema refs catch typos
  • injectNames/flattenTraitTree hand-walk trait trees — builtins.attrNames registry does this
  • firstMatch (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

Data Model

Trait kind definition

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

Global trait registry

Traits are instances in a global registry with self-referencing refs for needs:

options.traits = schemaLib.mkInstanceRegistry config.schema "trait" {
  refs = { trait = config.traits; };
};

Trait instances

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.

Node is field

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 refs

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

Engine Changes

evalNest signature

# Before
evalNest { trait = { host = { __traitName = "host"; ... }; }; rules = [...]; ...dom }

# After
evalNest { traits = traitRegistry; rules = [...]; ...dom }

traits is the evaluated registry (attrset of trait instances).

Trait expansion (expandTraits)

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.

neededBy dispatch

Each neededBy entry is dispatched by shape:

  • Trait instance (has .name and is in registry): match by builtins.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 via matchesSel
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;

Entity detection

# 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 {}).

Synth

# 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 [];

Output processing

Root-only output with collectChildFrags (already implemented in the fix commit). No changes needed beyond the trait access pattern updates.

Selector Changes

Trait matching in matchesOne

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.

callWithArgs entity arg

entityTrait = firstMatch (t: (t.class or {}) != {}) (node.is or []);
entityArgs = if entityTrait != null then { ${entityTrait.name} = node; } else { };

.class selector

class =
  let entityTrait = firstMatch (t: (t.class or {}) != {}) node.is;
  in entityTrait != null && entityTrait.class ? ${sel.name};

DOM Changes

walkDom drops the processedTraits parameter:

# Before
walkDom = processedTraits: dom: walkDomRec processedTraits "" null {} dom;

# After
walkDom = dom: walkDomRec "" null {} dom;

buildDomGraph unchanged.

setup.nix Changes

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

Test Changes

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.

Deleted Concepts

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 Impact

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment