Skip to content

Instantly share code, notes, and snippets.

@sini
Last active May 28, 2026 03:48
Show Gist options
  • Select an option

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

Select an option

Save sini/b27f327ce5306658e707a755341d9d31 to your computer and use it in GitHub Desktop.
Gen Type Unification: Pluggable Entry Types, Aspects on Schema, Settings

Gen Type Unification: Pluggable Entry Types, Aspects on Schema, Settings

Date: 2026-05-27 Status: Design approved Libraries affected: gen-schema, gen-aspects, gen-algebra, gen-scope Consumer: den (wiring only — no gen-level domain concepts) Supersedes: 2026-05-27-gen-settings-design.md (draft)

Problem

  1. gen-aspects and gen-schema are parallel type systems. Aspects are typed records with identity, collections, extension, and introspection — exactly what gen-schema provides — but implemented independently. This duplicates infrastructure and prevents cross-cutting features.

  2. Aspects need tunable settings: typed configuration declared on aspects, assigned at entity scope levels, composed via scope graph resolution (D > I > P), validated lazily. Building this requires bridging two separate type systems — or unifying them.

  3. Users cannot attach arbitrary typed metadata to aspects without framework changes (cnf.aspectModules, cnf.metaModules). With gen-schema, extension is natural: any module can config.schema.aspect.options.myField = ....

Design Principle

Gen provides primitives. The consumer (den) composes them. No gen library knows about "settings," "hosts," or "aspects" as domain concepts. Each library contributes general-purpose machinery.

Solution Overview

  1. gen-schema gains pluggable entry typesmkType parameter on mkSchemaEntryType. Default: deferredModule (backwards compatible). Custom: any NixOS type.
  2. gen-aspects ports onto gen-schema — aspects use gen-schema's kind-level infrastructure (collections, introspection, extension) with a custom entry type. Clean API break (gen is unshipped greenfield).
  3. Settings become a gen-schema collection on aspect kinds — schema declared on aspects, values composed via scope graph, validated lazily.
  4. gen-scope gains a neron traverse mode — D > I > P ordered collection for fold-based composition.

Integration boundary

gen-schema's role for aspects is kind-level infrastructure only:

  • gen-schema DOES: collection extraction (settings, classes), introspection, extension, computed fields, topology
  • gen-schema DOES NOT: instance registry (mkInstanceRegistry), content-based identity (id_hash), strict validation (mkStrictModule)

Aspects use positional identity (path in aspect tree), not content identity (hash of field values). Two aspects with identical content at different positions are different aspects — the opposite of entities. Parametric aspects make content identity meaningless (same function resolves to different content per scope).

Instance-level infrastructure (mkInstanceType, mkInstanceRegistry) remains for flat entity registries (hosts, users). Aspects use their own recursive option type (lazyAttrsOf aspectType) directly.

1. gen-schema: Pluggable Entry Types

Change

mkSchemaEntryType gains an optional mkType parameter:

mkSchemaEntryType = {
  baseModule ? null,
  collections ? {},
  computed ? null,
  mixins ? [],
  mkType ? null,         # NEW — custom type constructor
}: ...

When mkType is null (default): current behavior — deferredModuleWith { staticModules = [kindModule]; }. Zero breaking change.

When mkType is provided: receives { kindModule, collections, schema }, returns a NixOS type. gen-schema uses the returned type for kind entries instead of deferredModule.

What stays entry-type-agnostic (kind-level infrastructure)

These operate on config.schema.<name>, not on individual entries:

  • Collection extraction from kind definitions
  • _kindNames, _topology, _roots, _leaves
  • _edges, _refEdges
  • _kindMeta
  • renderDocs
  • Computed fields

What becomes entry-type-specific (via mkType)

  • How individual entries are typed and merged
  • Whether freeform is allowed and what shape it takes
  • How function values are handled

Collection extraction and custom types

gen-schema extracts collections during merge by inspecting raw defs. This only applies to inline attrset defs — path defs and function defs get the collection's default value. This is compatible with gen-aspects: function-valued aspect defs are guard functions (deferred resolution), not inline collection contributors. Collections like includes and neededBy are set on inline attrset defs only.

When mkType is provided, gen-schema's collection extraction runs first on the raw defs, strips collection keys, then passes the stripped defs to the custom type's merge. The custom type never sees collection keys — they're already extracted. Mixin pipeline (baseRecord → applyMixin → emitModule) is skipped when mkType is provided — the custom type owns the full type construction. __functor wrapping on the merged result is likewise the custom type's responsibility.

Estimated size

~20 lines — conditional dispatch on mkType presence in mkSchemaEntryType.

2. gen-aspects: Ported onto gen-schema

Architecture

gen-aspects becomes a thin configuration layer over gen-schema. It provides the aspect-specific entry type and utilities; gen-schema provides the infrastructure.

gen-aspects provides (reduced surface):

  • aspectType — Palmer flat dispatch merge function (attrset → submodule, function → guard wrap, primitive → passthrough)
  • canTake / mkIsModuleFn — function arg introspection for module vs guard classification
  • identity — key, aspectPath, pathKey computation
  • mkAspectSchema — convenience constructor that configures gen-schema for aspects

gen-schema provides (already exists, used directly):

  • Collection extraction for kind-level data (settings, classes — extracted before merge, available as schema data)
  • Extension (any module can config.schema.aspect.options.myField = ...)
  • Introspection (_kindMeta, _kindNames)
  • Identity hashing (via mkIdentityModule)
  • Validation and refinements
  • Topology

Important distinction — collections vs options:

  • includes, excludes, provides, policies, meta: Options on the aspect entry type (per-instance, consumed at runtime by the pipeline). These live in the aspectType's submodule, not as gen-schema collections.
  • settings, classes: Gen-schema collections (kind-level data extracted before merge, available for schema-level operations). classes replaces den's current manual collection walk in aspect-schema.nix.
  • neededBy: New option on the aspect entry type (concept exists in gen-aspects README, not yet implemented in den).

New gen-aspects API (clean break)

mkAspectSchema returns two things: a schema option (kind-level infrastructure) and a type constructor (for creating aspect option points).

aspects = gen-aspects { inherit lib; gen-schema = gen-schema { inherit lib; }; };

cnf = {
  classes = { nixos = {}; homeManager = {}; };
  moduleArgs = { lib = true; config = true; options = true; pkgs = true; };
  collections = {
    settings = { default = {}; };
    classes = { default = {}; };
  };
};

# Schema option — kind-level infrastructure (one per den instance)
schema = aspects.mkAspectSchema cnf;
options.schema = schema.schemaOption;  # provides config.schema.aspect

# Aspect option points — multiple, sharing the same type
options.den.aspects = schema.mkAspectOption {};                    # providerPrefix = []
options.den.ful = lib.mkOption {
  type = lib.types.attrsOf (schema.mkNamespaceType {});            # per-namespace
};

# Usage is standard gen-schema + aspects:
config.aspects.networking = {
  nixos.networking.hostName = "myhost";
  includes = [ config.aspects.firewall ];
};

# User-defined metadata — just extend the kind:
config.schema.aspect.options.priority = lib.mkOption {
  type = lib.types.int;
  default = 50;
};
config.aspects.networking.priority = 10;

Under the hood

mkAspectSchema returns:

{
  # Kind-level: gen-schema option for collections, introspection, extension
  schemaOption = schemaLib.mkSchemaOption {
    inherit collections computed;
    mkType = { kindModule, ... }: aspectType {
      inherit kindModule;
      classes = cnf.classes;
      moduleArgs = cnf.moduleArgs;
    };
  };

  # Instance-level: creates an aspect option point with providerPrefix
  mkAspectOption = { providerPrefix ? [] }: lib.mkOption {
    type = lib.types.lazyAttrsOf (aspectType {
      inherit (cnf) classes moduleArgs;
      inherit providerPrefix;
    });
    default = {};
  };

  # Namespace-level: submodule with schema, classes, and aspect freeform
  mkNamespaceType = {}: lib.types.submodule ({ name, ... }: {
    options.schema = lib.mkOption { /* per-namespace schema overrides */ };
    options.classes = lib.mkOption { /* class declarations */ };
    freeformType = lib.types.lazyAttrsOf (aspectType {
      inherit (cnf) classes moduleArgs;
      providerPrefix = [ name ];
    });
  });
}

gen-schema provides kind-level infrastructure on config.schema.aspect. gen-aspects provides the recursive entry type. Multiple option points share the same type with different providerPrefix for identity scoping.

Namespace and flake-importable aspects

Current mechanism: den.ful.<name> namespaces use namespaceType — a submodule with freeformType = aspectsType { providerPrefix = [name] }. External flakes export flake.denful.<name>, imported via den.namespace "name" [inputs.externalFlake].

With gen-schema: Same structure, but:

  • All namespaces share one config.schema.aspect kind definition
  • Schema extensions from external flakes apply everywhere
  • Collections (settings, classes) compose across namespace boundaries
  • providerPrefix scopes positional identity: myLib/nginx vs aspects/nginx
# External flake (nginx-aspects)
{
  outputs = { ... }: {
    flake.denful.nginx-lib = {
      nginx = {
        settings.port = { type = types.int; default = 80; };
        nixos = { ... };
      };
    };

    # Schema extension — applies to ALL aspect option points
    flakeModules.default = { ... }: {
      config.schema.aspect.options.monitoring = lib.mkOption {
        type = lib.types.attrsOf lib.types.anything;
        default = {};
      };
    };
  };
}

# Consumer
{
  imports = [
    (den.namespace "nginx" [ inputs.nginx-aspects ])
    inputs.nginx-aspects.flakeModules.default
  ];

  # Imported aspects available via namespace
  den.hosts.web1.settings.nginx.port = 8080;

  # Schema extension visible on all aspects
  den.aspects.myApp.monitoring.enabled = true;
}

Key properties:

  • One shared kind definition, multiple option points
  • Schema extensions are global (kind-level)
  • Settings schemas compose across imports
  • Identity is namespace-scoped via providerPrefix
  • stripAliases in namespace.nix unchanged (entry-type concern)

What gen-aspects retains

Component Purpose Stays because
aspectType Palmer flat dispatch in merge Aspect-specific merge behavior; gen-schema has no equivalent
canTake / mkIsModuleFn Module vs guard function classification Depends on cnf.moduleArgs; aspect-specific concern
identity Key/path computation from name/meta Aspect-specific identity scheme (not gen-schema's id_hash)
Recursive freeform setup lazyAttrsOf (aspectType cnf) Self-recursive type; aspect-specific
Class option generation deferredModule per cnf.classes Aspect-specific content routing

Migration from current gen-aspects API

Current New
aspectsType cnf mkAspectSchema { ... }
aspectSubmodule cnf Internal to mkAspectSchema
aspectType cnf Internal to mkAspectSchema, or exported for advanced use
cnf.aspectModules Extend the aspect kind: config.schema.aspect.options.*
cnf.metaModules Extend meta: config.schema.aspect.options.meta.*
cnf.classes mkAspectSchema { classes = { ... }; }

Consumption pattern (follows gen-schema ← gen-algebra)

gen-schema consumes gen-algebra via inputs ? {} with CI flake.lock fallback, lib passed separately, specific functions threaded to sub-modules. gen-aspects follows identically:

# gen-aspects/nix/lib/default.nix
{
  inputs ? {},
  lib,
}:
let
  lock = builtins.fromJSON (builtins.readFile ../../ci/flake.lock);
  inherit (lock.nodes.gen-schema) locked;
  schemaSrc = builtins.fetchTarball {
    url = "https://github.com/${locked.owner}/${locked.repo}/archive/${locked.rev}.zip";
    sha256 = locked.narHash;
  };
  genSchema = inputs.gen-schema or (import schemaSrc { inherit lib; });
in
{
  inherit (genSchema) mkSchemaOption mkSchemaEntryType;
  # ... gen-aspects exports
}

Key pattern rules:

  • gen-schema is NOT a flake input — resolved via CI flake.lock fallback
  • lib passed separately (both gen-schema and gen-aspects receive it independently)
  • Specific genSchema functions threaded to sub-modules, not the whole genSchema attrset
  • Re-exports genSchema functions that consumers need (e.g., mkSchemaOption)

Dependency change

gen-aspects gains gen-schema as a dependency (currently independent). Acceptable: gen-aspects was reimplementing half of gen-schema's functionality.

gen-algebra (pure primitives)
├── gen-schema (typed registries)
│   └── gen-aspects (aspect types — NEW dependency)
├── gen-select (selector algebra)
│   └── gen-derive (rule dispatch)
│
gen-scope   (HOAG evaluator)        ← independent
gen-graph   (graph queries)         ← independent
gen-bind    (module binding)        ← independent

3. Settings: Declaration, Composition, Validation

Declaration — settings schema as a collection on aspects

config.aspects.nginx.settings = {
  port = { type = types.int; default = 80; };
  workers = { type = types.positive; default = 4; };
  locations = {
    type = types.attrsOf (types.submodule {
      options.proxy_pass = lib.mkOption { type = types.str; };
    });
    default = {};
    merge = "recursive";
  };
};

settings is a gen-schema collection on the aspect kind with { default = {}; } and attrset merge. Collection extraction pulls settings declarations out of the aspect definition and exposes them on the merged result.

Collection — values from entity scope levels

Entity-level settings are plain attrset assignments on scope node declarations. They do NOT module-system-merge across scope levels — each entity has its own scope node with its own declarations:

den.environments.prod.settings.nginx.port = 443;
den.hosts.web1.settings.nginx.port = 8080;
den.hosts.web1.settings.nginx.workers = 16;

Composition — gen-algebra record algebra, ordered by scope graph

gen-scope collects contributions via D > I > P traversal (section 4). The fold uses gen-algebra's record with scoped labels (Leijen 2005):

Each scope level's contribution is pushed onto the record via record.extend. record.select returns the head (most specific). Schema defaults sit at the bottom — pushed first, shadowed by any explicit value. No mkDefault needed.

The fold function respects per-field merge strategies from the settings schema:

Strategy Semantics Use case
"replace" (default) record.select — head of stack wins Scalars, enums, booleans
"append" List concatenation across all layers IP allowlists, package lists
"recursive" Nested attrset merge, recurse with sub-field strategies Location blocks, nested config

Example resolution for host:web1 with P-edge to env:prod:

D:  { port = 8080; workers = 16; }   ← host:web1 (most specific)
P:  { port = 443; }                    ← env:prod
base: { port = 80; workers = 4; }     ← schema defaults

Result: { port = 8080; workers = 16; }

Validation — gen-schema refinements, lazy (Chitil 2012)

The composed result wraps each field with a lazy refinement contract from the settings schema:

composed.portcontract.lazy { name = "nginx.port"; check = isInt; } 80808080  # validates on access, zero cost until forced
  • Type checking: from the settings schema type field
  • Cross-field validation: optional validators collection on the settings schema, run eagerly on the full composed result only when the settings attrset itself is forced
  • Blame: contract violations identify the aspect (schema source) and scope level (value source) via Palmer identity on contributions

Identity and dedup

Each settings contribution carries identity: { aspect = "nginx"; scope = "host:web1"; }. When the same contribution arrives via two graph paths (diamond), Palmer identity deduplicates before composition. Same pattern as gen-aspects' diamond dedup in fold-based collect.

Aspect identity vs entity identity

Aspects use positional identitykey = pathKey(aspectPath(a)). This is the aspect's position in the tree, NOT a hash of its content.

  • Static aspects: key = "networking" or "networking/firewall" (path chain)
  • Guard functions: key = pathKey(meta.loc) (merge location from module system — Palmer program-point identity)
  • Parametric aspects: identity from mkIntensional name or meta.loc fallback
  • Anonymous aspects (inline in includes): synthetic names from position, never appear as top-level entries
  • Namespaced aspects: providerPrefix scopes identity — "nginx-lib/nginx" vs "aspects/nginx"

gen-schema's id_hash (content-based SHA-256) is NOT used for aspects. Parametric aspects resolve to different content per scope, making content hashing meaningless. id_hash remains for flat entity registries (hosts, users) where content identity is correct.

Graph construction (gen-scope)

Aspects map to gen-scope graph nodes:

# Each top-level aspect → root node
{ id = "networking"; type = "aspect"; parent = null;
  decls = { settings = { ... }; __edges.I = [ "firewall" ]; }; }

# Nested aspect → child node
{ id = "networking/firewall"; type = "aspect"; parent = "networking";
  decls = { ... }; }

# includes → I-edges (decls.__edges.I)
# neededBy → reverse I-edges (computed attribute)
# Nesting → P-edges (parent field)
# Entity scoping → separate subtree with P-edges to entity nodes

New gen library additions

Library Addition Size estimate
gen-algebra Per-field-strategy fold over record layers (uses existing record.extend/record.select) ~40 lines
gen-schema Settings-aware collection type (schema declarations with type/default/merge) ~60 lines

4. gen-scope: Neron Collection Primitive

The gap

gen-scope's collectionAttr supports traverse modes: "imports", "children", "siblings", "ancestors", "label:<name>", and custom functions. None do full D > I > P ordered collection. query does D > I > P but returns a single shadowed result, not the full ordered list needed for fold-based composition.

The primitive: "neron" traverse mode

collectionAttr {
  traverse = "neron";
  extract = self: id: (self.node id).decls.settings or null;
  combine = layers: foldWithStrategies strategies layers;
}

Returns all contributions in specificity order (most-specific first): D (self), then I-edges, then recursively up the P-chain. Each P-node contributes its own D + I before yielding to its parent.

Implementation

Plugs into the existing traverse dispatch in collectionAttr:

else if traverse == "neron" then
  let
    neronCollect = seen: nid:
      let
        node = self.node nid;
        direct = [ nid ];
        importIds = self.get nid "imports";
        unseenImports = builtins.filter (iid: !(seen ? ${iid})) importIds;
        importContribs = unseenImports;
        newSeen = seen
          // { ${nid} = true; }
          // builtins.listToAttrs
            (map (iid: { name = iid; value = true; }) importIds);
        parentContribs =
          if node.parent != null && !(newSeen ? ${node.parent})
          then neronCollect newSeen node.parent
          else [];
      in
      direct ++ importContribs ++ parentContribs;
  in
  neronCollect { ${id} = true; } id

Relationship to query

query collectionAttr { traverse = "neron" }
Returns Single shadowed result Ordered list of all contributions
Use case Name resolution (one winner) Value aggregation (fold all layers)
Shadowing D shadows I shadows P Caller's combine function controls

Properties

  • D > I > P ordering: direct first, then imports, then parent chain (Neron 2015)
  • Recursive: parent's contribution includes its own D + I before its parent; memoized via _eval
  • Cycle-safe: seen set prevents re-visiting nodes (same pattern as query's _seen)
  • Returns node IDs: collectionAttr's existing extract and combine handle value extraction and folding
  • Not settings-specific: any attribute needing specificity-ordered aggregation

Estimated size

~30-40 lines — new branch in traverse dispatch + recursive collect function.

5. Aspect Registry: Flat View for Discovery and Queries

Aspects have fixed string identity via pathKey(aspectPath(a)) — this is how bracket notation (<den/networking/firewall>) works. The recursive tree is a declaration convenience; a flat view by path identity is the queryable surface for gen-graph/gen-select.

The flat view

The recursive aspect tree flattens to a registry keyed by path identity:

# Recursive tree (declaration surface)
config.aspects.networking = {
  nixos = { ... };
  tags = [ "infra" ];
  firewall = {
    nixos = { ... };
    tags = [ "security" "infra" ];
    includes = [ config.aspects.logging ];
  };
};

# Flat view (query surface)
{
  "networking" = {
    tags = [ "infra" ];
    classKeys = [ "nixos" ];
    includes = [ ... ];
  };
  "networking/firewall" = {
    tags = [ "security" "infra" ];
    classKeys = [ "nixos" ];
    includes = [ "logging" ];
  };
}

Parent relationships are implicit in the path key: "networking/firewall" → parent is "networking". Flatten does not inject any fields — entries are the aspect values unchanged.

Namespaced aspects include their prefix: "nginx-lib/nginx", "nginx-lib/certbot".

Path A: gen-schema computed field (pre-scope-engine)

A computed field on the aspect kind materializes the flat view from the recursive tree. Available before the scope engine swap — works with den's current fx pipeline.

schema = aspects.mkAspectSchema {
  # ...
  computed = collections: defs: {
    flat = flattenAspectTree defs;
  };
};

# Query via gen-graph
g = {
  nodes    = builtins.attrNames config.schema.aspect.flat;
  edges    = id: config.schema.aspect.flat.${id}.includes or [];
  parent   = id: let parts = lib.splitString "/" id; in
               if builtins.length parts <= 1 then null
               else lib.concatStringsSep "/" (lib.init parts);
  nodeData = id: config.schema.aspect.flat.${id};
};

graph.select g (d: lib.elem "security" (d.tags or []))
# → [ "networking/firewall" ]

graph.reachableFrom g "networking"
# → [ "networking/firewall" ]

# Query via gen-select
ctx = sel.adapters.graph.mkContext g;
sel.matches
  (sel.when (id: ctx: lib.elem "infra" ((ctx.data id).tags or [])))
  "networking" ctx
# → true

Properties:

  • Lazy — the flat view only materializes when accessed
  • Static — reflects the declaration-time aspect tree, no scope resolution
  • Useful for pre-pipeline discovery: "what aspects exist?", "which aspects have tag X?"
  • Does not reflect parametric aspects (unresolved until scope context available)

Path B: gen-scope Tier 2 (scope engine)

With gen-scope, aspects ARE scope graph nodes. result.allNodes is the flat view. gen-graph and gen-select wire directly to gen-scope's memoized accessors — no separate flattening needed.

result = engine.eval {
  roots = aspectRoots;
  attributes = {
    children = self: id: /* nested aspects */;
    imports = self: id: /* includes as I-edges */;
    tags = self: id: (self.node id).decls.tags or [];
    classKeys = self: id: /* registered class keys on this aspect */;
    settings-schema = self: id: (self.node id).decls.settings or {};
  };
};

# gen-graph wires to gen-scope accessors — O(1) amortized via _eval
g = {
  nodes    = builtins.attrNames result.allNodes;         # Tier 2: forces full tree
  edges    = id: result.get id "imports";                # Tier 1: O(1) cached
  parent   = id: (result.node id).parent;
  nodeData = id: result.node id;
};

# gen-select wires via adapter
ctx = sel.adapters.scope.mkContext {
  node = result.node;
  get = result.get;
};

# Queries hit memoized attributes — zero redundant computation
sel.matches
  (sel.when (id: ctx: lib.elem "security" (result.get id "tags")))
  "networking/firewall" ctx
# → true

# Selective materialization — avoid Tier 2 when possible
result.allNodesWhere (n: lib.elem "security" (n.decls.tags or []))
# → only materializes nodes matching predicate

Properties:

  • Dynamic — reflects resolved aspects including parametric (post-scope-context)
  • Memoized — every attribute evaluates once per node via _eval
  • No separate flattening — the scope graph IS the flat registry
  • Full gen-graph/gen-select/gen-derive integration via accessor pattern
  • Selective materialization avoids forcing the full tree for targeted queries

Relationship between paths

Path A (computed field) Path B (gen-scope)
When available Now (pre-scope-engine) After scope engine swap
Data source Declaration-time recursive tree Resolved scope graph
Parametric aspects Not visible (unresolved) Fully resolved per scope
Settings values Schema only (no composition) Composed via neron traverse
Performance Lazy but materializes full tree Tier 1 O(1), Tier 2 O(n) with memoization
gen-graph/gen-select Via flat attrset accessors Via gen-scope memoized accessors

Path A is a stepping stone. Path B is the target architecture. Both expose the same query surface (gen-graph/gen-select), just with different backing stores. Consumer code that uses gen-graph/gen-select accessors works unchanged across both paths.

Scope of Changes

Library Change Size Risk
gen-schema mkType parameter on mkSchemaEntryType ~20 lines None — additive, default preserves current behavior
gen-schema Settings collection type (field declarations with type/default/merge) ~60 lines Low — new collection variant
gen-aspects Port onto gen-schema, mkAspectSchema + mkAspectOption + mkNamespaceType ~250 lines delta Medium — clean break, new dependency
gen-aspects Flat registry computed field (path A) ~40 lines Low — recursive tree walk, lazy
gen-algebra Per-field-strategy fold over record layers ~40 lines Low — uses existing record primitives
gen-scope "neron" traverse mode for collectionAttr ~40 lines Medium — new traverse mode, needs topology testing
Total ~450 lines

Test Plan

gen-schema: pluggable entry types

  • Default mkType (null): existing behavior unchanged, all current tests pass
  • Custom mkType: receives kindModule and collections, returns a working type
  • Kind-level infrastructure (introspection, topology, collections) works with custom entry type
  • Custom entry type with freeform: unregistered keys handled by the custom type, not gen-schema

gen-aspects on gen-schema

  • mkAspectSchema produces working aspect type with class content, includes, neededBy
  • Class content is clean deferredModule (no structural keys injected)
  • Nested aspects via recursive freeform still work
  • Guard functions detected and wrapped (Palmer/Reynolds)
  • Module functions evaluated by submodule
  • Primitive passthrough unchanged
  • Identity computation (key, aspectPath) unchanged
  • User-defined metadata via schema extension: config.schema.aspect.options.myField
  • Collections (includes, neededBy) extracted and accessible
  • gen-schema introspection works on aspect kind

Settings

  • Aspect declares settings schema, accessible via collection
  • Entity assigns settings values at scope level
  • Composed result respects per-field merge strategies (replace, append, recursive)
  • Schema defaults used when no scope provides a value
  • Explicit values override schema defaults without priority annotations
  • Lazy: unaccessed fields never validated (verify with builtins.trace)
  • Type checking via contract on access
  • Cross-field validators run when settings attrset forced
  • Diamond dedup: same contribution via two paths contributes once
  • Blame identifies aspect (schema) and scope level (value)

Flat registry (path A)

  • Recursive tree flattens to { "a" = ...; "a/b" = ...; "ns/c" = ...; } by path identity
  • Parent derived from key: "a/b" → parent is "a" (no injected field)
  • Namespaced aspects include providerPrefix in path: "nginx-lib/nginx"
  • Lazy: only materializes when accessed
  • gen-graph accessor record built from flat view works with reachableFrom, select, cycles
  • gen-select queries match against nodeData (tags, classKeys, settings schema)
  • Parametric aspects (guard functions) appear as entries but with unresolved content

gen-scope: neron traverse mode

  • P-only graph: contributions from self → parent → grandparent in order
  • I-edge graph: contributions from self → imports → parent chain
  • D > I > P ordering: direct appears before import appears before parent
  • Recursive parent: parent's I-edge contributions appear before grandparent's D
  • Diamond dedup: node reachable via two paths contributes once (via seen)
  • Empty nodes: nodes with null extraction skipped
  • Cycle safety: circular P/I edges don't infinite-loop

Theoretical Foundations

Mechanism Paper Library
Scoped labels / shadow stacks Leijen 2005 (Extensible Records with Scoped Labels) gen-algebra record
Mixin composition direction Bracha & Cook 1990 (Mixin-Based Inheritance) gen-algebra record
Collection attributes with fold Van Wyk et al. 2010 (Silver); Sloane et al. 2010 (Kiama) gen-scope collectionAttr
Intensional identity / dedup Palmer et al. 2024 (Intensional Functions) gen-algebra, gen-aspects
Lazy contracts Chitil 2012 (Practical Typed Lazy Contracts) gen-schema refinements
D < I < P resolution Neron et al. 2015 (A Theory of Name Resolution) gen-scope query, neron traverse
Palmer flat typing Palmer et al. 2024 §2 (one type, dispatch in merge) gen-aspects aspectType
Guard defunctionalization Reynolds 1972 (Definitional Interpreters) gen-aspects canTake/wrap

Current State (Implementation Context)

Den does not use gen-aspects today. Den has its own 685-line type system in nix/lib/aspects/types.nix with providerType (6+ case merge), aspectContentType (content wrapping with __contentValues/__provider), and aspectSubmodule (structural options). gen-aspects is the clean-room replacement library (40 tests, shipped separately) that den will adopt when the scope-engine swap lands.

Den already uses gen-schema for entities. den.schema is a mkSchemaOption with collections for includes, excludes, and isEntity on entity kinds (host, user, home). den.schema.aspect currently exists only for strict mode injection — the actual aspect type system is separate.

den.reservedKeys is a string exclusion list (["settings", "tags"]) preventing pipeline dispatch on those keys. With gen-aspects on gen-schema, this becomes unnecessary — reserved keys are declared options on the aspect kind, and gen-schema's option/freeform separation handles classification naturally.

aspect-schema.nix manually walks the aspect tree to collect .classes declarations and merge them into den.classes. This is exactly what a gen-schema collection with attrset merge would handle automatically.

Open Questions

  1. I-edge ordering among imports. When a node has multiple I-edges, what determines relative priority? Declaration order? A priority attribute on the edge? gen-scope's existing query uses _seen tracking — the neron traverse mode should follow the same ordering. Initial implementation: declaration order (list order of decls.__edges.I).

  2. Interaction with gen-derive. Policies can inject settings via rules. Policy-produced settings enter the scope graph as node declarations before collectionAttr traverses. The timing is: gen-derive dispatch → enrich node decls → gen-scope attribute evaluation. This is existing pipeline order; settings does not change it.

  3. Nested settings namespaces. Aspects at den.aspects.services.nginx.settings — nested aspect paths create nested settings namespaces. The extract function in collectionAttr handles nested path lookup. This is den wiring, not a gen concern.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment